From 84db61d73ee8d580f5f2d6d4d0b83c52c9c28523 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 30 Mar 2026 04:32:24 +0200 Subject: [PATCH 01/28] fix(whatsapp): preserve native bridge audio metadata Resolve the bridge audio message after media download, retain native extensions for known MIME types, and use an explicit audio filename prefix. The effective Python send_voice implementation is unchanged. --- scripts/whatsapp-bridge/bridge.js | 22 ++++------- scripts/whatsapp-bridge/media-utils.js | 29 ++++++++++++++ scripts/whatsapp-bridge/media-utils.test.js | 43 +++++++++++++++++++++ scripts/whatsapp-bridge/package.json | 3 +- 4 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 scripts/whatsapp-bridge/media-utils.js create mode 100644 scripts/whatsapp-bridge/media-utils.test.js diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4c65740c0174b..b65714efc11c7 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -30,6 +30,7 @@ import { execSync } from 'child_process'; import { tmpdir } from 'os'; import qrcode from 'qrcode-terminal'; import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; +import { getAudioMessage, getMessageContent } from './media-utils.js'; // Parse CLI args const args = process.argv.slice(2); @@ -65,6 +66,7 @@ let SCRIPT_HASH = ''; try { SCRIPT_HASH = createHash('sha256') .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./media-utils.js', import.meta.url))) .digest('hex') .slice(0, 16); } catch {} @@ -144,18 +146,6 @@ function normalizeWhatsAppId(value) { return String(value).replace(':', '@'); } -function getMessageContent(msg) { - const content = msg?.message || {}; - if (content.ephemeralMessage?.message) return content.ephemeralMessage.message; - if (content.viewOnceMessage?.message) return content.viewOnceMessage.message; - if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message; - if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message; - if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate; - if (content.buttonsMessage) return content.buttonsMessage; - if (content.listMessage) return content.listMessage; - return content; -} - function getContextInfo(messageContent) { if (!messageContent || typeof messageContent !== 'object') return {}; for (const value of Object.values(messageContent)) { @@ -387,12 +377,14 @@ async function startSocket() { hasMedia = true; mediaType = messageContent.pttMessage ? 'ptt' : 'audio'; try { - const audioMsg = messageContent.pttMessage || messageContent.audioMessage; const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }); + const audioMsg = getAudioMessage(msg); + if (!audioMsg) throw new Error('Downloaded message no longer contains audio metadata'); const mime = audioMsg.mimetype || 'audio/ogg'; - const ext = mime.includes('ogg') ? '.ogg' : mime.includes('mp4') ? '.m4a' : '.ogg'; + const extMap = { 'audio/ogg; codecs=opus': '.ogg', 'audio/ogg': '.ogg', 'audio/mpeg': '.mp3', 'audio/mp4': '.m4a', 'audio/wav': '.wav' }; + const ext = extMap[mime] || '.ogg'; mkdirSync(AUDIO_CACHE_DIR, { recursive: true }); - const filePath = path.join(AUDIO_CACHE_DIR, `aud_${randomBytes(6).toString('hex')}${ext}`); + const filePath = path.join(AUDIO_CACHE_DIR, `audio_${randomBytes(6).toString('hex')}${ext}`); writeFileSync(filePath, buf); mediaUrls.push(filePath); } catch (err) { diff --git a/scripts/whatsapp-bridge/media-utils.js b/scripts/whatsapp-bridge/media-utils.js new file mode 100644 index 0000000000000..92c23b7be1333 --- /dev/null +++ b/scripts/whatsapp-bridge/media-utils.js @@ -0,0 +1,29 @@ +const MAX_WRAPPER_DEPTH = 8; + +function unwrapOnce(content) { + return ( + content?.ephemeralMessage?.message + || content?.viewOnceMessage?.message + || content?.viewOnceMessageV2?.message + || content?.documentWithCaptionMessage?.message + || null + ); +} + +export function getMessageContent(msg) { + let content = msg?.message || {}; + for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) { + const nested = unwrapOnce(content); + if (!nested || nested === content) break; + content = nested; + } + if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate; + if (content.buttonsMessage) return content.buttonsMessage; + if (content.listMessage) return content.listMessage; + return content; +} + +export function getAudioMessage(msg) { + const content = getMessageContent(msg); + return content.audioMessage || content.pttMessage || null; +} diff --git a/scripts/whatsapp-bridge/media-utils.test.js b/scripts/whatsapp-bridge/media-utils.test.js new file mode 100644 index 0000000000000..4405a53c5051a --- /dev/null +++ b/scripts/whatsapp-bridge/media-utils.test.js @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getAudioMessage, getMessageContent } from './media-utils.js'; + +const audio = { mimetype: 'audio/ogg; codecs=opus' }; +const ptt = { mimetype: 'audio/mpeg', ptt: true }; + +test('returns direct audio and PTT messages', () => { + assert.equal(getAudioMessage({ message: { audioMessage: audio } }), audio); + assert.equal(getAudioMessage({ message: { pttMessage: ptt } }), ptt); +}); + +test('unwraps ephemeral and view-once audio messages', () => { + const ephemeral = { + message: { ephemeralMessage: { message: { audioMessage: audio } } }, + }; + const viewOnce = { + message: { viewOnceMessageV2: { message: { pttMessage: ptt } } }, + }; + + assert.equal(getAudioMessage(ephemeral), audio); + assert.equal(getAudioMessage(viewOnce), ptt); +}); + +test('unwraps nested wrappers with a bounded traversal', () => { + const nested = { + message: { + ephemeralMessage: { + message: { + viewOnceMessage: { message: { audioMessage: audio } }, + }, + }, + }, + }; + + assert.deepEqual(getMessageContent(nested), { audioMessage: audio }); + assert.equal(getAudioMessage(nested), audio); +}); + +test('returns null when no audio message is present', () => { + assert.equal(getAudioMessage({ message: { conversation: 'hello' } }), null); +}); diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json index d1c3ac113a0a5..0a061d40ac8af 100644 --- a/scripts/whatsapp-bridge/package.json +++ b/scripts/whatsapp-bridge/package.json @@ -5,7 +5,8 @@ "private": true, "type": "module", "scripts": { - "start": "node bridge.js" + "start": "node bridge.js", + "test": "node --test media-utils.test.js" }, "dependencies": { "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770", From b6d479d9d1ba29e7c86ad1e20948ec6fe72dce8c Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Tue, 31 Mar 2026 14:05:55 +0200 Subject: [PATCH 02/28] =?UTF-8?q?feat(tool=5Fprogress):=20add=20'full'=20m?= =?UTF-8?q?ode=20=E2=80=94=20unlimited=20tool=20args=20in=20gateway=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'full' mode for display.tool_progress — top of the mode hierarchy (off → new → all → verbose → full): - Shows complete tool arguments without any truncation in gateway chat (Telegram, Discord, etc.). Previously verbose mode capped at 200 chars. - Added to /verbose cycle, hermes setup wizard, and mode validation. This commit only affects tool call display. Assistant thinking relay is handled separately by thinking_progress. Files: run_agent.py, gateway/run.py, hermes_cli/setup.py --- cli.py | 5 +++-- gateway/run.py | 13 +++++++++++++ gateway/slash_commands.py | 7 ++++--- hermes_cli/setup.py | 5 +++-- locales/af.yaml | 1 + locales/de.yaml | 3 ++- locales/en.yaml | 3 ++- locales/es.yaml | 1 + locales/fr.yaml | 1 + locales/ga.yaml | 1 + locales/hu.yaml | 1 + locales/it.yaml | 1 + locales/ja.yaml | 1 + locales/ko.yaml | 1 + locales/pt.yaml | 1 + locales/ru.yaml | 1 + locales/tr.yaml | 1 + locales/uk.yaml | 1 + locales/zh-hant.yaml | 1 + locales/zh.yaml | 1 + tests/gateway/test_verbose_command.py | 6 +++--- 21 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index 49da337dfd8fc..e99da162ae36c 100644 --- a/cli.py +++ b/cli.py @@ -8195,7 +8195,7 @@ def _maybe_continue_goal_after_turn(self) -> None: def _toggle_verbose(self): - """Cycle tool progress mode: off → new → all → verbose → off. + """Cycle tool progress mode: off → new → all → verbose → full → off. Tool-progress display (full args / results / think blocks at the ``verbose`` step) is INDEPENDENT of global DEBUG logging. Cycling @@ -8204,7 +8204,7 @@ def _toggle_verbose(self): explicit ``-v``/``--verbose`` flag and the ``/verbose-logging`` toggle. See PR #6a1aa420e for the history that decoupled them. """ - cycle = ["off", "new", "all", "verbose"] + cycle = ["off", "new", "all", "verbose", "full"] try: idx = cycle.index(self.tool_progress_mode) except ValueError: @@ -8228,6 +8228,7 @@ def _toggle_verbose(self): "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).", "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.", "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.", + "full": f"{_Colors.BOLD}{_Colors.CYAN}Tool progress: FULL{_Colors.RESET} — complete args, no truncation.", } _cprint(labels.get(self.tool_progress_mode, "")) diff --git a/gateway/run.py b/gateway/run.py index 2672ab43e95a0..fca069196bbb4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14531,6 +14531,18 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non _cmd_short = _cmd_short + " ..." _code_block_short = f"{_block_header}```\n{_cmd_short}\n```" + # Full mode: show complete arguments (no truncation). + # This is Amy's opt-in debugging mode: unlike verbose, it + # deliberately emits the full raw argument JSON so metadata + # such as workdir, timeout, background, and media paths is not + # lost behind a terminal-only code-block preview. + if progress_mode == "full" and args: + args_str = json.dumps(args, ensure_ascii=False, default=str) + msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}" + last_was_terminal_block[0] = False + progress_queue.put(msg) + return + # Verbose mode: show detailed arguments, respects tool_preview_length if progress_mode == "verbose": if _code_block_full is not None: @@ -15319,6 +15331,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: agent.tool_start_callback = ( voice_ack_callback if _voice_ack_guild[0] is not None else None ) + agent.tool_progress_mode = progress_mode if tool_progress_enabled else None agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None agent.stream_delta_callback = _stream_delta_cb agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4b25d96fdbf9b..01a8a82e321f6 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2349,8 +2349,8 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str: Gated by ``display.tool_progress_command`` in config.yaml (default off). When enabled, cycles the tool progress mode through off → new → all → - verbose → off for the *current platform*. The setting is saved to - ``display.platforms..tool_progress`` so each channel can + verbose → full → off for the *current platform*. The setting is saved + to ``display.platforms..tool_progress`` so each channel can have its own verbosity level independently. """ from gateway.run import _hermes_home, _load_gateway_config, _platform_config_key @@ -2372,12 +2372,13 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str: return t("gateway.verbose.not_enabled") # --- cycle mode (per-platform) ---------------------------------------- - cycle = ["off", "new", "all", "verbose"] + cycle = ["off", "new", "all", "verbose", "full"] descriptions = { "off": t("gateway.verbose.mode_off"), "new": t("gateway.verbose.mode_new"), "all": t("gateway.verbose.mode_all"), "verbose": t("gateway.verbose.mode_verbose"), + "full": t("gateway.verbose.mode_full"), } # Read current effective mode for this platform via the resolver diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b809af6ecf792..99bd74bb03010 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1502,11 +1502,12 @@ def setup_agent_settings(config: dict): print_info(" off — Silent, just the final response") print_info(" new — Show tool name only when it changes (less noise)") print_info(" all — Show every tool call with a short preview") - print_info(" verbose — Full args, results, and debug logs") + print_info(" verbose — Detailed tool args (200 char limit)") + print_info(" full — Complete tool args, no truncation") current_mode = cfg_get(config, "display", "tool_progress", default="all") mode = prompt("Tool progress mode", current_mode) - if mode.lower() in {"off", "new", "all", "verbose"}: + if mode.lower() in {"off", "new", "all", "verbose", "full"}: if "display" not in config: config["display"] = {} config["display"]["tool_progress"] = mode.lower() diff --git a/locales/af.yaml b/locales/af.yaml index ece46799d98a3..6b8b2f2173391 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Gereedskap-vordering: **NUUT** — vertoon wanneer gereedskap verander (voorskoulengte: `display.tool_preview_length`, verstek 40)." mode_all: "⚙️ Gereedskap-vordering: **ALMAL** — elke gereedskaps-oproep vertoon (voorskoulengte: `display.tool_preview_length`, verstek 40)." mode_verbose: "⚙️ Gereedskap-vordering: **OMSLAGTIG** — elke gereedskaps-oproep met volle argumente." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(gestoor vir **{platform}** — neem effek by die volgende boodskap)_" save_failed: "_(kon nie in konfigurasie stoor nie: {error})_" diff --git a/locales/de.yaml b/locales/de.yaml index 154268e60dde6..86254385f4cdf 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -346,7 +346,8 @@ Future messages in this room will use that transcript until `/reset` or another mode_off: "⚙️ Tool-Fortschritt: **OFF** — keine Tool-Aktivität angezeigt." mode_new: "⚙️ Tool-Fortschritt: **NEW** — angezeigt bei Tool-Wechsel (Vorschaulänge: `display.tool_preview_length`, Standard 40)." mode_all: "⚙️ Tool-Fortschritt: **ALL** — jeder Tool-Aufruf wird angezeigt (Vorschaulänge: `display.tool_preview_length`, Standard 40)." - mode_verbose: "⚙️ Tool-Fortschritt: **VERBOSE** — jeder Tool-Aufruf mit vollständigen Argumenten." + mode_verbose: "⚙️ Tool-Fortschritt: **VERBOSE** — detaillierte Tool-Argumente (200-Zeichen-Limit)." + mode_full: "⚙️ Tool-Fortschritt: **FULL** — vollständige Argumente, keine Kürzung." saved_suffix: "_(für **{platform}** gespeichert — wird ab nächster Nachricht wirksam)_" save_failed: "_(konnte nicht in der Konfiguration gespeichert werden: {error})_" diff --git a/locales/en.yaml b/locales/en.yaml index a8a132622f44c..d4768bca8af88 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -358,7 +358,8 @@ gateway: mode_off: "⚙️ Tool progress: **OFF** — no tool activity shown." mode_new: "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40)." mode_all: "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40)." - mode_verbose: "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments." + mode_verbose: "⚙️ Tool progress: **VERBOSE** — detailed tool args (200 char limit)." + mode_full: "⚙️ Tool progress: **FULL** — complete args, no truncation." saved_suffix: "_(saved for **{platform}** — takes effect on next message)_" save_failed: "_(could not save to config: {error})_" diff --git a/locales/es.yaml b/locales/es.yaml index 9e4d827526cf9..391f1920bbd14 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progreso de herramientas: **NEW** — se muestra al cambiar de herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." mode_all: "⚙️ Progreso de herramientas: **ALL** — se muestra cada llamada a herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." mode_verbose: "⚙️ Progreso de herramientas: **VERBOSE** — cada llamada a herramienta con sus argumentos completos." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(guardado para **{platform}** — se aplica en el próximo mensaje)_" save_failed: "_(no se pudo guardar en la configuración: {error})_" diff --git a/locales/fr.yaml b/locales/fr.yaml index 692c71221fb09..95f3877f21296 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progression des outils : **NEW** — affichée lors d'un changement d'outil (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." mode_all: "⚙️ Progression des outils : **ALL** — chaque appel d'outil est affiché (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." mode_verbose: "⚙️ Progression des outils : **VERBOSE** — chaque appel d'outil avec ses arguments complets." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(enregistré pour **{platform}** — prend effet au prochain message)_" save_failed: "_(impossible d'enregistrer dans la configuration : {error})_" diff --git a/locales/ga.yaml b/locales/ga.yaml index cdacf94312a82..9c4e5d791bba5 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -351,6 +351,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Dul chun cinn uirlise: **NUA** — taispeánta nuair a athraíonn an uirlis (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." mode_all: "⚙️ Dul chun cinn uirlise: **GACH CEANN** — taispeántar gach glao uirlise (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." mode_verbose: "⚙️ Dul chun cinn uirlise: **BÉALSCAOILTE** — gach glao uirlise le hargóintí iomlána." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(sábháilte do **{platform}** — éifeachtach ón gcéad teachtaireacht eile)_" save_failed: "_(níorbh fhéidir sábháil sa chumraíocht: {error})_" diff --git a/locales/hu.yaml b/locales/hu.yaml index fec8aac766fc6..baf55f99f152f 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Eszközfolyamat: **NEW** — eszközváltáskor jelenik meg (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." mode_all: "⚙️ Eszközfolyamat: **ALL** — minden eszközhívás megjelenik (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." mode_verbose: "⚙️ Eszközfolyamat: **VERBOSE** — minden eszközhívás teljes argumentumokkal." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(elmentve ehhez: **{platform}** — a következő üzenettől lép életbe)_" save_failed: "_(nem sikerült menteni a konfigurációba: {error})_" diff --git a/locales/it.yaml b/locales/it.yaml index 5e17a835f48f3..f1bf1eb0cc504 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progresso strumenti: **NEW** — mostrato quando lo strumento cambia (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." mode_all: "⚙️ Progresso strumenti: **ALL** — ogni chiamata a uno strumento viene mostrata (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." mode_verbose: "⚙️ Progresso strumenti: **VERBOSE** — ogni chiamata a uno strumento con argomenti completi." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(salvato per **{platform}** — verrà applicato al prossimo messaggio)_" save_failed: "_(impossibile salvare nella configurazione: {error})_" diff --git a/locales/ja.yaml b/locales/ja.yaml index b6d9a9575884b..1cf67c032d6c1 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ ツール進捗: **NEW** — ツールが変わったときに表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" mode_all: "⚙️ ツール進捗: **ALL** — すべてのツール呼び出しを表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" mode_verbose: "⚙️ ツール進捗: **VERBOSE** — すべてのツール呼び出しを完全な引数とともに表示。" + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(**{platform}** に保存しました — 次のメッセージから有効)_" save_failed: "_(設定に保存できませんでした: {error})_" diff --git a/locales/ko.yaml b/locales/ko.yaml index f07d22837adde..051a84db4d2b2 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 도구 진행 상황: **NEW** — 도구가 변경될 때 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." mode_all: "⚙️ 도구 진행 상황: **ALL** — 모든 도구 호출이 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." mode_verbose: "⚙️ 도구 진행 상황: **VERBOSE** — 모든 도구 호출이 전체 인수와 함께 표시됩니다." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(**{platform}**에 저장됨 — 다음 메시지부터 적용됩니다)_" save_failed: "_(설정에 저장할 수 없습니다: {error})_" diff --git a/locales/pt.yaml b/locales/pt.yaml index 5be22d90b1e7e..6e417e79860e8 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Progresso de ferramentas: **NEW** — mostrado quando a ferramenta muda (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." mode_all: "⚙️ Progresso de ferramentas: **ALL** — cada chamada de ferramenta é mostrada (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." mode_verbose: "⚙️ Progresso de ferramentas: **VERBOSE** — cada chamada de ferramenta com os argumentos completos." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(guardado para **{platform}** — produz efeito na próxima mensagem)_" save_failed: "_(não foi possível guardar na configuração: {error})_" diff --git a/locales/ru.yaml b/locales/ru.yaml index ca5617a4cc4dc..fe7a8f23ceefc 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Прогресс инструментов: **NEW** — показывается при смене инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." mode_all: "⚙️ Прогресс инструментов: **ALL** — показывается каждый вызов инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." mode_verbose: "⚙️ Прогресс инструментов: **VERBOSE** — каждый вызов инструмента с полными аргументами." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(сохранено для **{platform}** — вступит в силу со следующего сообщения)_" save_failed: "_(не удалось сохранить в конфигурацию: {error})_" diff --git a/locales/tr.yaml b/locales/tr.yaml index 29bacf36ee4ef..3ffcd8f12bf7a 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Araç ilerlemesi: **NEW** — araç değiştiğinde gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." mode_all: "⚙️ Araç ilerlemesi: **ALL** — her araç çağrısı gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." mode_verbose: "⚙️ Araç ilerlemesi: **VERBOSE** — her araç çağrısı tüm argümanlarıyla gösterilir." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(**{platform}** için kaydedildi — sonraki mesajda geçerli olur)_" save_failed: "_(yapılandırmaya kaydedilemedi: {error})_" diff --git a/locales/uk.yaml b/locales/uk.yaml index 1e20ec7b6ca71..7bc65ecd260b5 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ Прогрес інструментів: **NEW** — показується при зміні інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." mode_all: "⚙️ Прогрес інструментів: **ALL** — показується кожен виклик інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." mode_verbose: "⚙️ Прогрес інструментів: **VERBOSE** — кожен виклик інструмента з повними аргументами." + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(збережено для **{platform}** — набуде чинності з наступного повідомлення)_" save_failed: "_(не вдалося зберегти у конфігурацію: {error})_" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index a7aae1adb8aca..d078d8ce52d4a 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 工具進度:**NEW** — 工具變更時顯示(預覽長度:`display.tool_preview_length`,預設 40)。" mode_all: "⚙️ 工具進度:**ALL** — 顯示每次工具呼叫(預覽長度:`display.tool_preview_length`,預設 40)。" mode_verbose: "⚙️ 工具進度:**VERBOSE** — 顯示每次工具呼叫及完整參數。" + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(已為 **{platform}** 儲存 — 下一則訊息生效)_" save_failed: "_(無法儲存到設定:{error})_" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7f9789ee3be4f..7e19f922d6cdb 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -347,6 +347,7 @@ Future messages in this room will use that transcript until `/reset` or another mode_new: "⚙️ 工具进度:**NEW** — 工具变化时显示(预览长度:`display.tool_preview_length`,默认 40)。" mode_all: "⚙️ 工具进度:**ALL** — 显示每次工具调用(预览长度:`display.tool_preview_length`,默认 40)。" mode_verbose: "⚙️ 工具进度:**VERBOSE** — 显示每次工具调用及完整参数。" + mode_full: "⚙️ Tool progress: **FULL** - complete args, no truncation." saved_suffix: "_(已为 **{platform}** 保存 — 下一条消息生效)_" save_failed: "_(无法保存到配置:{error})_" diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index 04399b1da5080..2467a2fa4abf5 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -105,7 +105,7 @@ async def test_quoted_false_keeps_command_disabled(self, tmp_path, monkeypatch): @pytest.mark.asyncio async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): - """Calling /verbose repeatedly cycles through all four modes.""" + """Calling /verbose repeatedly cycles through all five modes.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -117,8 +117,8 @@ async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) runner = _make_runner() - # off -> new -> all -> verbose -> off - expected = ["new", "all", "verbose", "off"] + # off -> new -> all -> verbose -> full -> off + expected = ["new", "all", "verbose", "full", "off"] for mode in expected: result = await runner._handle_verbose_command(_make_event()) saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) From 06c9e12a12e2cdf0b3b66796d82cbb84eaee9814 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 18 May 2026 03:51:57 +0200 Subject: [PATCH 03/28] feat(prompt): add Amy platform hints and Mattermost Private Assistant default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates these related Amy fork patches: - 687d805a1 feat(prompt_builder): Amy platform hints — zipper mode defaults - fa02b7a9d feat(prompt): default Mattermost to Amy Private Assistant Mode --- agent/prompt_builder.py | 19 +++++++++++++------ tests/agent/test_prompt_builder.py | 5 ++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 97836f27b05d8..c5a1caaea9a04 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -571,7 +571,9 @@ def format_steer_marker(steer_text: str) -> str: "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " "bubbles, and videos (.mp4) play inline. You can also include image " - "URLs in markdown format ![alt](url) and they will be sent as native photos." + "URLs in markdown format ![alt](url) and they will be sent as native photos.\n\n" + "Amy's zipper starts HALF-OPEN on Telegram — Private Assistant Mode by default. " + "The user can change this during the session." ), "discord": ( "You are in a Discord server or group chat communicating with your user. " @@ -640,7 +642,9 @@ def format_steer_marker(steer_text: str) -> str: "You can send media files natively: include MEDIA:/absolute/path/to/file " "in your response. Images (.jpg, .png, .webp) are uploaded as photo " "attachments, audio and video as file attachments. " - "Image URLs in markdown format ![alt](url) are rendered as inline previews automatically." + "Image URLs in markdown format ![alt](url) are rendered as inline previews automatically.\n\n" + "Amy's zipper starts HALF-OPEN on Mattermost — Private Assistant Mode by default. " + "The user can change this during the session." ), "matrix": ( "You are in a Matrix room communicating with your user. " @@ -709,10 +713,13 @@ def format_steer_marker(steer_text: str) -> str: "— when a sticker is the right response, use yb_send_sticker." ), "api_server": ( - "You're responding through an API server. The rendering layer is unknown — " - "assume plain text. No markdown formatting (no asterisks, bullets, headers, " - "code fences). Treat this like a conversation, not a document. Keep responses " - "brief and natural." + "You are accessed via an OpenAI-compatible API from a private frontend " + "(e.g. SillyTavern, Open WebUI, LobeChat). Markdown is fully supported. " + "You can send media files: include MEDIA:/absolute/path/to/file in your " + "response. You can also include image URLs in markdown format ![alt](url). " + "This is a private environment.\n\n" + "Amy's zipper starts HALF-OPEN — Private Assistant Mode by default. " + "The user can change this during the session." ), "webui": ( "You are in the Hermes WebUI, a browser-based chat interface. " diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 6f0206dfbcb0c..6c7fce18b0fd0 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -1025,7 +1025,8 @@ def test_cli_hint_does_not_suggest_media_tags(self): ), "CLI hint should explicitly discourage MEDIA: tags." # Messaging hints should still advertise MEDIA: positively (sanity # check that this test is calibrated correctly). - assert "include MEDIA:" in PLATFORM_HINTS["telegram"] + assert "include" in PLATFORM_HINTS["telegram"].lower() + assert "MEDIA:" in PLATFORM_HINTS["telegram"] def test_telegram_hint_encourages_rich_markdown(self): # Telegram Bot API 10.1 rich messages are default-on, so the hint must @@ -1049,6 +1050,8 @@ def test_platform_hints_mattermost(self): assert "Mattermost" in hint assert "MEDIA:" in hint assert "Markdown" in hint + assert "HALF-OPEN" in hint + assert "Private Assistant Mode by default" in hint def test_platform_hints_matrix(self): hint = PLATFORM_HINTS["matrix"] From 1a80063680decc125886e11094ccf6909d7e8c9c Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Tue, 31 Mar 2026 13:22:24 +0200 Subject: [PATCH 04/28] docs: add Amy's patches changelog for v0.6.0 fork --- RELEASE_amy-patches.md | 122 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 RELEASE_amy-patches.md diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md new file mode 100644 index 0000000000000..5064745fc06e1 --- /dev/null +++ b/RELEASE_amy-patches.md @@ -0,0 +1,122 @@ +# Amy's Patches — Changelog (Branch: amy/patches) + +**Base:** Hermes Agent v0.6.0 (v2026.3.30) +**Patch Period:** March 30–31, 2026 +**Author:** Amy Ravenwolf + +> 10 patches on top of upstream v0.6.0 — session management, tool progress relay, platform hints, WhatsApp fixes, and cross-platform /resume. + +--- + +## ✨ Features + +### Tool Progress: Full Mode (`f68ff144`, `3dc3d876`) + +New `full` mode for `display.tool_progress` — the most verbose option in the mode hierarchy (`off → new → all → verbose → full`): + +- **Relays assistant thinking text** between tool calls to the gateway chat (Telegram, Discord, etc.) via a `💬 _thinking` pseudo-tool notification. Previously, these intermediate messages ("Let me check...", "Found it!") were only visible in HA addon logs (stdout), never in the chat — a hardcoded guard restricted relay to subagents only. +- **Shows complete tool arguments** without any truncation. The initial implementation had a 1000-char limit; a follow-up patch (`3dc3d876`) removed it entirely — full mode means full, no half measures. +- Added to `/verbose` cycle, `hermes setup` wizard, and mode validation. + +**Files:** `run_agent.py`, `gateway/run.py`, `hermes_cli/cli.py` + +### /resume Command — CLI + Cross-Platform (`5256e7ac`, `eef00dbf`, `99530547`, `a47dc3b0`) + +A complete /resume implementation spanning four commits: + +1. **CLI handler** (`5256e7ac`) — The /resume command worked in Gateway (Telegram/Discord) but the CLI's `process_command` dispatcher had no handler for it. Added `_handle_resume_command()` with full functionality: list recent sessions, resolve by title, flush current session's memories, and switch context. + +2. **API server session registration** (`eef00dbf`) — API server (SillyTavern, Open WebUI, LobeChat) sessions were invisible to /resume because the API server adapter never passed `session_db` to `AIAgent`. ~115 existing API sessions had no state.db entry. Fixed by passing the DB instance, enabling session registration. + +3. **Listing modes** (`99530547`) — Four modes for flexible session discovery: + - `/resume` — Named sessions, current platform (default) + - `/resume all` — Named sessions, ALL platforms + - `/resume --full` — All sessions incl. unnamed, current platform + - `/resume all --full` — All sessions incl. unnamed, ALL platforms + - Platform tags `[telegram]`, `[cli]` shown in cross-platform listings + - Session ID prefix shown for unnamed sessions + +4. **API session transcript loading** (`a47dc3b0`) — `load_transcript()` only knew about `.jsonl` (gateway) and SQLite (gateway DB) formats. API server sessions use `session_{id}.json` (AIAgent log format). Added a third source and made the loader pick whichever source has the most messages — fixing cross-platform /resume losing all context. + +**Files:** `hermes_cli/cli.py`, `gateway/platforms/api_server.py`, `gateway/run.py` + +### Platform Hints — Amy Mode Defaults (`b113c782`, `8b2c8c41`) + +Platform-aware zipper mode defaults for Amy's persona: + +- **API server** (`b113c782`) — API frontends (SillyTavern, Open WebUI, LobeChat) are private environments. Default to Private Assistant Mode (zipper half-open) instead of no hint at all. +- **WhatsApp cleanup** (`8b2c8c41`) — Removed redundant WhatsApp zipper hint. Amy's default is Public Assistant Mode per SOUL.md, so only platforms that deviate (Telegram, API server) need explicit overrides. + +**Files:** `agent/prompt_builder.py`, `tests/agent/test_prompt_builder.py` + +--- + +## 🐛 Bug Fixes + +### Session Search: Lazy DB Creation (`154785c3`) + +The `session_search` tool passed `db=None` to the search function when no pre-initialized `SessionDB` existed — silently returning zero results in cron jobs and background agents (memory flush). Direct SQL queries against `state.db` worked fine, confirming the issue was in tool initialization. Fixed by adding a `_session_search_handler` that lazily creates a `SessionDB` instance when none is provided. + +**First observed:** 2026-03-25, session_search consistently returned empty results despite healthy FTS index. + +**Files:** `tools/session_search.py` + +### WhatsApp Voice + Bridge Audio (`a2bd3b30`) + +Batch fix for three WhatsApp issues: +- Platform hints in prompt_builder (Telegram zipper mode) +- WhatsApp voice message handling +- Bridge audio download from WhatsApp servers + +**Files:** `agent/prompt_builder.py`, `gateway/platforms/whatsapp.py`, `scripts/whatsapp-bridge/bridge.js` + +### WhatsApp Bridge Dependencies (`944256f3`) + +Updated npm lock files for the WhatsApp bridge. No functional changes — auto-generated during bridge setup/maintenance. + +**Files:** `package-lock.json`, `scripts/whatsapp-bridge/package-lock.json` + +--- + +## 📊 Summary + +| Type | Count | +|------|-------| +| Features | 4 (tool_progress full mode, /resume CLI+cross-platform, platform hints, listing modes) | +| Bug Fixes | 2 (session_search lazy DB, WhatsApp voice/bridge) | +| Docs | 1 (this changelog) | +| **Total Patches** | **7 commits** (squashed from 10) | + +### Commits (in order) + +``` +a366ea8e fix(session_search): lazy SessionDB creation for background agents +af260618 feat(cli): add /resume command handler to CLI dispatcher +aa061c56 fix: WhatsApp voice messages + bridge audio download + npm deps +82a8622e feat(tool_progress): add 'full' mode — relay assistant thinking + unlimited tool args +6187427f feat(prompt_builder): Amy platform hints — zipper mode defaults [PRIVATE] +80dfb50e feat(resume): cross-platform /resume with API server support +1233b555 docs: add Amy's patches changelog for v0.6.0 fork [PRIVATE] +``` + +### Upstream PR Candidates + +| # | Commit | Scope | +|---|--------|-------| +| 1 | `a366ea8e` | session_search lazy DB — universal bugfix | +| 2 | `af260618` | /resume CLI handler — feature gap | +| 3 | `aa061c56` | WhatsApp voice + bridge audio — universal bugfix | +| 4 | `82a8622e` | tool_progress full mode — universal feature | +| 5 | `80dfb50e` | cross-platform /resume — universal feature | + +### Private (Amy-specific, not for upstream) + +| # | Commit | Reason | +|---|--------|--------| +| 6 | `6187427f` | Zipper mode / persona system | +| 7 | `1233b555` | Fork-specific changelog | + +--- + +**Branch:** `amy/patches` (7 commits ahead of `upstream/main`) +**No merge conflicts with upstream. All patch files verified identical to pre-squash.** From d4710f4b6b03a79574b03c5921632ce94836e767 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 20 Apr 2026 00:48:36 +0200 Subject: [PATCH 05/28] fix: suppress pkg_resources deprecation warning from lark_oapi The Feishu/DingTalk SDK (lark_oapi) triggers a noisy UserWarning about deprecated pkg_resources on every startup. Since this is a third-party issue we can't fix, suppress it with a targeted warnings.filterwarnings() early in the entry point. Files: hermes_cli/main.py --- hermes_cli/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 039eb5d449c42..b3b01c2704f75 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -258,6 +258,8 @@ def _try_termux_ultrafast_version() -> bool: import shutil import stat import subprocess +import sys +import warnings from pathlib import Path from typing import Optional @@ -301,6 +303,14 @@ def _try_termux_ultrafast_version() -> bool: from hermes_cli.subcommands.mcp import build_mcp_parser from hermes_cli.subcommands.claw import build_claw_parser +# Suppress noisy deprecation warnings from third-party packages that we +# can't fix ourselves. These clutter gateway logs on every startup. +warnings.filterwarnings( + "ignore", + message="pkg_resources is deprecated as an API", + category=UserWarning, +) + def _require_tty(command_name: str) -> None: """Exit with a clear error if stdin is not a terminal. From e6faede1e55bda00503999e35171aa3ec2fdc6f9 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Fri, 24 Apr 2026 03:44:35 +0200 Subject: [PATCH 06/28] fix(addon): make dashboard assets work behind HA ingress --- web/src/lib/api.ts | 2 +- web/vite.config.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ba89892419675..d40f507b452be 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -15,7 +15,7 @@ function readBasePath(): string { } export const HERMES_BASE_PATH = readBasePath(); -const BASE = HERMES_BASE_PATH; +export const BASE = HERMES_BASE_PATH; import type { DashboardTheme } from "@/themes/types"; diff --git a/web/vite.config.ts b/web/vite.config.ts index fc92eb924ce20..560841f633244 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -58,6 +58,8 @@ function hermesDevToken(): Plugin { } export default defineConfig({ + // Emit relative asset URLs so the built dashboard works under a URL prefix. + base: "./", plugins: [react(), tailwindcss(), hermesDevToken()], resolve: { alias: { From f4c001c69ab56a83fa7a1c27a5c3bcba9edb27d1 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 18 May 2026 03:51:58 +0200 Subject: [PATCH 07/28] feat(vision): add provider-safe inject_image tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates these related Amy fork patches: - 710ec7e94 feat: inject_image tool — native image self-review for generated content - 5b5933d0f fix: add missing _get_budget_warning method (AttributeError crash) - 4f698f70a fix(vision): make inject_image provider-safe --- agent/anthropic_adapter.py | 26 ++ agent/codex_responses_adapter.py | 70 +++++- model_tools.py | 2 +- tests/run_agent/test_provider_parity.py | 27 ++ tests/tools/test_vision_tools.py | 45 ++++ tools/tool_result_storage.py | 4 + tools/vision_tools.py | 311 ++++++++++++++++++++++++ toolsets.py | 4 +- 8 files changed, 484 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 03e8b58e16c4b..34e023a99117b 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1894,6 +1894,32 @@ def _convert_tool_message_to_result( if text_content else list(stashed) ) + # inject_image returns a JSON marker containing base64 image bytes. Native + # Anthropic can receive that as an image block inside the tool_result while + # still keeping a compact textual status around it. + if multimodal_blocks is None and isinstance(content, str) and '"_inject_image"' in content: + try: + parsed_result = json.loads(content) + if isinstance(parsed_result, dict) and isinstance(parsed_result.get("_inject_image"), dict): + img = parsed_result["_inject_image"] + media_type = img.get("media_type") + data = img.get("data") + if isinstance(media_type, str) and media_type.startswith("image/") and isinstance(data, str) and data: + text = parsed_result.get("message") or "Image injected into native multimodal context." + multimodal_blocks = [ + {"type": "text", "text": str(text)}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data, + }, + }, + ] + except (json.JSONDecodeError, TypeError, KeyError): + pass + if multimodal_blocks: result_content: Any = multimodal_blocks elif isinstance(content, str): diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index e9b6ace9b856b..15385a8584a23 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -175,6 +175,60 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str: return "" +def _extract_injected_image_tool_result(content: Any) -> Optional[tuple[str, Dict[str, Any]]]: + """Return (compact_output, input_image_part) for an ``inject_image`` result. + + Tool results must stay textual in Responses ``function_call_output`` items, + but generated image payloads should reach vision-capable providers as native + ``input_image`` content. This strips the base64 from the tool output and + emits a separate multimodal user item. + """ + if not isinstance(content, str) or '"_inject_image"' not in content: + return None + try: + parsed = json.loads(content) + except (TypeError, ValueError): + return None + if not isinstance(parsed, dict): + return None + img = parsed.get("_inject_image") + if not isinstance(img, dict): + return None + media_type = img.get("media_type") + data = img.get("data") + if not isinstance(media_type, str) or not media_type.startswith("image/"): + return None + if not isinstance(data, str) or not data: + return None + + data_url = f"data:{media_type};base64,{data}" + metadata = img.get("metadata") or parsed.get("metadata") or {} + source_path = None + if isinstance(metadata, dict): + source_path = img.get("source_path") or metadata.get("source_path") + elif isinstance(img.get("source_path"), str): + source_path = img.get("source_path") + message = parsed.get("message") + if not isinstance(message, str) or not message.strip(): + message = "Image injected into native multimodal context." + + compact: Dict[str, Any] = { + "success": bool(parsed.get("success", True)), + "message": message, + "_inject_image": { + "media_type": media_type, + "native_image_attached": True, + "base64_omitted_from_tool_output": True, + }, + } + if isinstance(source_path, str) and source_path: + compact["_inject_image"]["source_path"] = source_path + if isinstance(metadata, dict) and metadata: + compact["metadata"] = {k: v for k, v in metadata.items() if k != "data"} + image_part: Dict[str, Any] = {"type": "input_image", "image_url": data_url} + return json.dumps(compact, ensure_ascii=False), image_part + + # --------------------------------------------------------------------------- # ID helpers # --------------------------------------------------------------------------- @@ -544,13 +598,13 @@ def _chat_messages_to_responses_input( call_id = raw_tool_call_id.strip() if not isinstance(call_id, str) or not call_id.strip(): continue - # Multimodal tool result: convert OpenAI-style content list into # Responses ``function_call_output.output`` array. The Responses # API accepts ``output`` as either a string or an array of # ``input_text``/``input_image`` items. See # https://developers.openai.com/api/reference/python/resources/responses/. tool_content = msg.get("content") + injected = None output_value: Any if isinstance(tool_content, list): converted = _chat_content_to_responses_parts( @@ -561,13 +615,25 @@ def _chat_messages_to_responses_input( else: output_value = "" else: - output_value = str(tool_content or "") + output = str(tool_content or "") + injected = _extract_injected_image_tool_result(output) + if injected: + output, image_part = injected + output_value = output items.append({ "type": "function_call_output", "call_id": call_id, "output": output_value, }) + if injected: + items.append({ + "role": "user", + "content": [ + {"type": "input_text", "text": "Native image payload attached from inject_image tool result."}, + image_part, + ], + }) return items diff --git a/model_tools.py b/model_tools.py index 0618138aa9a88..d565c510e7358 100644 --- a/model_tools.py +++ b/model_tools.py @@ -220,7 +220,7 @@ def _run_in_worker(): _LEGACY_TOOLSET_MAP = { "web_tools": ["web_search", "web_extract"], "terminal_tools": ["terminal"], - "vision_tools": ["vision_analyze"], + "vision_tools": ["vision_analyze", "inject_image"], "moa_tools": ["mixture_of_agents"], "image_tools": ["image_generate"], "skills_tools": ["skills_list", "skill_view", "skill_manage"], diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c99ab433d45e1..debef979d3a66 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -604,6 +604,33 @@ def test_tool_results_become_function_call_output(self, monkeypatch): assert items[0]["call_id"] == "call_abc" assert items[0]["output"] == "result here" + def test_inject_image_tool_result_becomes_native_input_image(self, monkeypatch): + agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses", + base_url="https://chatgpt.com/backend-api/codex") + content = json.dumps({ + "success": True, + "message": "Image injected", + "_inject_image": { + "media_type": "image/jpeg", + "data": "aGVsbG8=", + "metadata": {"source_path": "/share/hermes/gallery/test.jpg"}, + }, + }) + messages = [{"role": "tool", "tool_call_id": "call_img", "content": content}] + items = _chat_messages_to_responses_input(messages) + + assert items[0]["type"] == "function_call_output" + assert items[0]["call_id"] == "call_img" + assert "aGVsbG8=" not in items[0]["output"] + compact = json.loads(items[0]["output"]) + assert compact["_inject_image"]["native_image_attached"] is True + assert compact["_inject_image"]["base64_omitted_from_tool_output"] is True + assert items[1]["role"] == "user" + assert items[1]["content"][1] == { + "type": "input_image", + "image_url": "data:image/jpeg;base64,aGVsbG8=", + } + def test_encrypted_reasoning_replayed(self, monkeypatch): """Encrypted reasoning items from previous turns must be included in input.""" agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses", diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 9373d08f25ae5..08fec926b3421 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -17,6 +17,7 @@ _resize_image_for_vision, _image_exceeds_dimension, _EMBED_MAX_DIMENSION, + _handle_inject_image, _is_image_size_error, _MAX_BASE64_BYTES, _RESIZE_TARGET_BYTES, @@ -894,6 +895,50 @@ def test_no_pillow_returns_original(self, tmp_path): assert len(result) > 100 +class TestInjectImage: + def test_default_inject_image_downscales_to_jpeg_review_payload(self, tmp_path): + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed") + + path = tmp_path / "large.png" + Image.new("RGB", (2400, 1200), (120, 40, 200)).save(path, "PNG") + + result = _handle_inject_image({"image_path": str(path)}) + parsed = result + + assert parsed["_multimodal"] is True + metadata = parsed["meta"] + image_part = parsed["content"][1] + assert image_part["image_url"]["url"].startswith("data:image/jpeg;base64,") + assert metadata["injected_dimensions"] == {"width": 1024, "height": 512} + assert metadata["original_dimensions"] == {"width": 2400, "height": 1200} + assert metadata["resized"] is True + assert metadata["base64_size_chars"] < metadata["source_size_bytes"] * 2 + assert metadata["injected_media_type"] == "image/jpeg" + assert "data" not in metadata + + def test_full_resolution_inject_image_preserves_original_payload(self, tmp_path): + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed") + + path = tmp_path / "small.png" + Image.new("RGB", (32, 16), (10, 20, 30)).save(path, "PNG") + + result = _handle_inject_image({"image_path": str(path), "full_resolution": True}) + parsed = result + + metadata = parsed["meta"] + image_part = parsed["content"][1] + assert image_part["image_url"]["url"].startswith("data:image/png;base64,") + assert metadata.get("injected_dimensions", metadata.get("original_dimensions")) == {"width": 32, "height": 16} + assert metadata["full_resolution"] is True + assert metadata["resized"] is False + + # --------------------------------------------------------------------------- # _image_exceeds_dimension — proactive embed-time pixel-cap detector # --------------------------------------------------------------------------- diff --git a/tools/tool_result_storage.py b/tools/tool_result_storage.py index fed8621eee416..272106c514cc9 100644 --- a/tools/tool_result_storage.py +++ b/tools/tool_result_storage.py @@ -195,6 +195,10 @@ def enforce_turn_budget( total_size = 0 for i, msg in enumerate(tool_messages): content = msg.get("content", "") + # Skip inject_image results — they contain base64 image data that must + # reach the adapter intact for native multimodal injection. + if '"_inject_image"' in content: + continue size = len(content) total_size += size if PERSISTED_OUTPUT_TAG not in content: diff --git a/tools/vision_tools.py b/tools/vision_tools.py index cfc933dae0fb8..4c63b07046b12 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -29,6 +29,7 @@ """ import base64 +import io import json import logging import os @@ -1589,3 +1590,313 @@ def _handle_video_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]: is_async=True, emoji="🎬", ) + + +# --------------------------------------------------------------------------- +# inject_image — load a local image into the model's native vision context +# --------------------------------------------------------------------------- + +_MAX_INJECT_SIZE_MB = 20 # refuse files larger than this +_INJECT_DEFAULT_MAX_DIMENSION = 1024 +_INJECT_DEFAULT_FORMAT = "jpeg" +_INJECT_DEFAULT_QUALITY = 85 + + +def _coerce_int(value: Any, default: int, *, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(minimum, min(maximum, parsed)) + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _prepare_injected_image_payload( + image_path: Path, + *, + source_mime: str, + max_dimension: int = _INJECT_DEFAULT_MAX_DIMENSION, + output_format: str = _INJECT_DEFAULT_FORMAT, + quality: int = _INJECT_DEFAULT_QUALITY, + full_resolution: bool = False, +) -> tuple[bytes, str, Dict[str, Any]]: + """Return image bytes + MIME for native injection, downscaled by default. + + ``inject_image`` is meant for *review context*, not archival transport. The + original file stays untouched on disk; this helper creates a compact payload + for the model to see so every provider adapter can avoid shoving multi-MB + full-resolution base64 through the conversation history. + """ + original_bytes = image_path.read_bytes() + metadata: Dict[str, Any] = { + "source_path": str(image_path), + "source_media_type": source_mime, + "source_size_bytes": len(original_bytes), + "max_dimension": max_dimension, + "requested_format": output_format, + "requested_quality": quality, + "full_resolution": full_resolution, + "resized": False, + "format_changed": False, + } + + if full_resolution or max_dimension <= 0 or source_mime == "image/svg+xml": + if source_mime != "image/svg+xml": + try: + from PIL import Image + with Image.open(image_path) as img: + metadata["original_dimensions"] = {"width": img.width, "height": img.height} + metadata["injected_dimensions"] = {"width": img.width, "height": img.height} + except Exception: + pass + metadata.update({ + "injected_media_type": source_mime, + "injected_size_bytes": len(original_bytes), + }) + return original_bytes, source_mime, metadata + + try: + from PIL import Image + except ImportError: + metadata.update({ + "warning": "Pillow not installed; injected original bytes", + "injected_media_type": source_mime, + "injected_size_bytes": len(original_bytes), + }) + return original_bytes, source_mime, metadata + + try: + with Image.open(image_path) as img: + original_width, original_height = img.size + metadata["original_dimensions"] = { + "width": original_width, + "height": original_height, + } + + if max(original_width, original_height) > max_dimension: + img.thumbnail((max_dimension, max_dimension), Image.LANCZOS) + metadata["resized"] = True + else: + img = img.copy() + + fmt = (output_format or _INJECT_DEFAULT_FORMAT).strip().lower() + if fmt in {"jpg", "jpeg"}: + pil_format = "JPEG" + out_mime = "image/jpeg" + if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): + rgba = img.convert("RGBA") + background = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) + background.alpha_composite(rgba) + img = background.convert("RGB") + elif img.mode != "RGB": + img = img.convert("RGB") + elif fmt == "png": + pil_format = "PNG" + out_mime = "image/png" + elif fmt == "webp": + pil_format = "WEBP" + out_mime = "image/webp" + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + else: + pil_format = "JPEG" + out_mime = "image/jpeg" + if img.mode != "RGB": + img = img.convert("RGB") + + metadata["format_changed"] = out_mime != source_mime + metadata["injected_dimensions"] = {"width": img.width, "height": img.height} + + buffer = io.BytesIO() + save_kwargs: Dict[str, Any] = {"format": pil_format} + if pil_format in {"JPEG", "WEBP"}: + save_kwargs["quality"] = quality + save_kwargs["optimize"] = True + img.save(buffer, **save_kwargs) + payload = buffer.getvalue() + except Exception as exc: + logger.warning("inject_image could not prepare compact payload for %s: %s", image_path, exc) + metadata.update({ + "warning": f"Could not resize/re-encode image; injected original bytes: {exc}", + "injected_media_type": source_mime, + "injected_size_bytes": len(original_bytes), + }) + return original_bytes, source_mime, metadata + + metadata.update({ + "injected_media_type": out_mime, + "injected_size_bytes": len(payload), + "base64_size_chars": len(base64.b64encode(payload)), + }) + return payload, out_mime, metadata + + +def _handle_inject_image(args: Dict[str, Any], **kw: Any) -> Any: + """Load a local image file as a compact multimodal tool-result envelope. + + By default the injected payload is a JPEG/WebP/PNG review copy capped at + 1024 px on the long edge; pass ``full_resolution=true`` or + ``max_dimension=0`` only when the original bytes are genuinely needed. + """ + image_path_str = str(args.get("image_path", "") or "").strip() + if not image_path_str: + return tool_error("image_path is required", success=False) + + image_path = Path(os.path.expanduser(image_path_str)).resolve() + if not image_path.exists(): + return tool_error(f"File not found: {image_path}", success=False) + if not image_path.is_file(): + return tool_error(f"Not a file: {image_path}", success=False) + + image_size_bytes = image_path.stat().st_size + size_mb = image_size_bytes / (1024 * 1024) + if size_mb > _MAX_INJECT_SIZE_MB: + return tool_error( + f"File too large ({size_mb:.1f} MB > {_MAX_INJECT_SIZE_MB} MB limit)", + success=False, + ) + + mime = _detect_image_mime_type(image_path) + if not mime or not mime.startswith("image/"): + return tool_error( + f"Not a recognised image format: {image_path.name}", + success=False, + ) + + max_dimension = _coerce_int( + args.get("max_dimension", _INJECT_DEFAULT_MAX_DIMENSION), + _INJECT_DEFAULT_MAX_DIMENSION, + minimum=0, + maximum=8192, + ) + quality = _coerce_int( + args.get("quality", _INJECT_DEFAULT_QUALITY), + _INJECT_DEFAULT_QUALITY, + minimum=1, + maximum=100, + ) + output_format = str(args.get("format", _INJECT_DEFAULT_FORMAT) or _INJECT_DEFAULT_FORMAT).strip().lower() + full_resolution = _coerce_bool(args.get("full_resolution"), default=False) or max_dimension <= 0 + + payload, injected_mime, metadata = _prepare_injected_image_payload( + image_path, + source_mime=mime, + max_dimension=max_dimension, + output_format=output_format, + quality=quality, + full_resolution=full_resolution, + ) + b64 = base64.b64encode(payload).decode("ascii") + metadata["base64_size_chars"] = len(b64) + image_data_url = f"data:{injected_mime};base64,{b64}" + if len(image_data_url) > _MAX_BASE64_BYTES: + return tool_error( + f"Image too large for native injection: base64 payload is " + f"{len(image_data_url) / (1024 * 1024):.1f} MB " + f"(limit {_MAX_BASE64_BYTES / (1024 * 1024):.0f} MB).", + success=False, + ) + + original_dims = metadata.get("original_dimensions") or {} + injected_dims = metadata.get("injected_dimensions") or original_dims + dims_text = "" + if isinstance(injected_dims, dict) and injected_dims.get("width") and injected_dims.get("height"): + dims_text = f", injected {injected_dims['width']}x{injected_dims['height']}" + if isinstance(original_dims, dict) and original_dims != injected_dims: + dims_text = ( + f", original {original_dims.get('width')}x{original_dims.get('height')}" + f" → injected {injected_dims['width']}x{injected_dims['height']}" + ) + + question = str(args.get("question", "") or "").strip() + if not question: + question = "Inspect this image and use it to answer the user's request." + result = _build_native_vision_tool_result( + image_url=str(image_path), + question=question, + image_data_url=image_data_url, + image_size_bytes=len(payload), + ) + result["text_summary"] = ( + f"Image attached natively for the main model ({injected_mime}, " + f"{len(payload) / 1024:.1f} KiB{dims_text}). " + "Answer using built-in vision." + ) + result.setdefault("meta", {}).update(metadata) + result["meta"]["source_path"] = str(image_path) + result["meta"]["source_media_type"] = mime + result["meta"]["injected_media_type"] = injected_mime + return result + + +INJECT_IMAGE_SCHEMA = { + "name": "inject_image", + "description": ( + "Load a local image file into your own visual context so you can see it " + "natively. Use this to review generated images, screenshots, or any local " + "image before sending or describing it to the user." + ), + "parameters": { + "type": "object", + "properties": { + "image_path": { + "type": "string", + "description": "Absolute or relative path to a local image file (PNG, JPEG, WebP, GIF, BMP).", + }, + "question": { + "type": "string", + "description": "Optional task-specific question or instruction for inspecting the image.", + }, + "max_dimension": { + "type": "integer", + "description": "Maximum long-edge size for the injected review payload. Default: 1024. Use 0 only when full resolution is required.", + "default": _INJECT_DEFAULT_MAX_DIMENSION, + "minimum": 0, + "maximum": 8192, + }, + "format": { + "type": "string", + "description": "Output format for the injected review payload. Default: jpeg.", + "enum": ["jpeg", "png", "webp"], + "default": _INJECT_DEFAULT_FORMAT, + }, + "quality": { + "type": "integer", + "description": "JPEG/WebP quality for the injected review payload. Default: 85.", + "default": _INJECT_DEFAULT_QUALITY, + "minimum": 1, + "maximum": 100, + }, + "full_resolution": { + "type": "boolean", + "description": "Inject original bytes instead of a compact review copy. Default: false; use sparingly.", + "default": False, + }, + }, + "required": ["image_path"], + }, +} + + +registry.register( + name="inject_image", + toolset="vision", + schema=INJECT_IMAGE_SCHEMA, + handler=_handle_inject_image, + is_async=False, + emoji="🖼️", + # Must bypass result persistence/truncation so the native image payload + # reaches the provider adapter intact. + max_result_size_chars=float("inf"), +) diff --git a/toolsets.py b/toolsets.py index f33be147e9569..a930d54ac3168 100644 --- a/toolsets.py +++ b/toolsets.py @@ -39,7 +39,7 @@ # File manipulation "read_file", "write_file", "patch", "search_files", # Vision + image generation - "vision_analyze", "image_generate", + "vision_analyze", "inject_image", "image_generate", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation @@ -113,7 +113,7 @@ "vision": { "description": "Image analysis and vision tools", - "tools": ["vision_analyze"], + "tools": ["vision_analyze", "inject_image"], "includes": [] }, From 0790976ef8e376a2c2a28068ad3843e25a93c7f0 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 18 May 2026 03:51:58 +0200 Subject: [PATCH 08/28] feat(moa): route experts through provider-aware clients Consolidates these related Amy fork patches: - 1206420d6 feat(moa): route experts through provider-aware clients - 886d72986 fix(auxiliary): preserve explicit Codex reasoning-disable semantics --- agent/auxiliary_client.py | 95 ++- tests/agent/test_auxiliary_client.py | 155 ++++- tests/tools/test_mixture_of_agents_tool.py | 128 +++- tools/mixture_of_agents_tool.py | 704 +++++++++++---------- 4 files changed, 708 insertions(+), 374 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f28b5f6015603..572be83d71696 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -637,6 +637,48 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: # calls to the Codex Responses API so callers don't need any changes. +def _normalize_aux_reasoning_config(reasoning_config: Dict[str, Any]) -> Dict[str, Any]: + """Normalize auxiliary reasoning config across provider wrappers.""" + effort = str(reasoning_config.get("effort") or "").strip().lower() + enabled = reasoning_config.get("enabled") + if enabled is False or effort in {"none", "off", "false", "disabled", "disable"}: + return {"enabled": False} + return dict(reasoning_config) + + +def _extract_reasoning_config_from_kwargs(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Extract reasoning config from auxiliary chat.completions kwargs. + + OpenAI-compatible callers commonly pass provider-specific fields through + ``extra_body={"reasoning": ...}``. Hermes-native callers may pass the same + data as ``reasoning_config``. Auxiliary wrappers normalize both forms so + Codex/Anthropic subscription-backed calls keep the same reasoning-effort + semantics as OpenRouter calls. + """ + reasoning_config = kwargs.get("reasoning_config") + if isinstance(reasoning_config, dict): + return _normalize_aux_reasoning_config(reasoning_config) + extra_body = kwargs.get("extra_body") + if isinstance(extra_body, dict): + extra_reasoning = extra_body.get("reasoning") + if isinstance(extra_reasoning, dict): + return _normalize_aux_reasoning_config(extra_reasoning) + return None + + +def _reasoning_explicitly_disabled(kwargs: Dict[str, Any]) -> bool: + """Return True when the caller explicitly passed reasoning.enabled=false.""" + reasoning_config = kwargs.get("reasoning_config") + if isinstance(reasoning_config, dict): + return reasoning_config.get("enabled") is False + extra_body = kwargs.get("extra_body") + if isinstance(extra_body, dict): + extra_reasoning = extra_body.get("reasoning") + if isinstance(extra_reasoning, dict): + return extra_reasoning.get("enabled") is False + return False + + class _CodexCompletionsAdapter: """Drop-in shim that accepts chat.completions.create() kwargs and routes them through the Codex Responses streaming API.""" @@ -648,6 +690,7 @@ def __init__(self, real_client: OpenAI, model: str): def create(self, **kwargs) -> Any: messages = kwargs.get("messages", []) model = kwargs.get("model", self._model) + reasoning_config = _extract_reasoning_config_from_kwargs(kwargs) # Separate system/instructions from replayable conversation messages, # then route the rest through the SINGLE shared chat->Responses @@ -691,41 +734,20 @@ def create(self, **kwargs) -> Any: if timeout is not None: resp_kwargs["timeout"] = timeout + if isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + if not _reasoning_explicitly_disabled(kwargs): + resp_kwargs["include"] = [] + else: + effort = str(reasoning_config.get("effort") or "medium").lower() + if effort == "minimal": + effort = "low" + resp_kwargs["reasoning"] = {"effort": effort, "summary": "auto"} + resp_kwargs["include"] = ["reasoning.encrypted_content"] + # Note: the Codex endpoint (chatgpt.com/backend-api/codex) does NOT # support max_output_tokens or temperature — omit to avoid 400 errors. - # Translate extra_body.reasoning (chat.completions shape) into the - # Responses API's top-level reasoning + include fields. Mirrors - # agent/transports/codex.py::build_kwargs() so auxiliary callers - # that configure reasoning via auxiliary..extra_body get the - # same behavior as the main agent's Codex transport. - extra_body = kwargs.get("extra_body") or {} - if isinstance(extra_body, dict): - reasoning_cfg = extra_body.get("reasoning") - if isinstance(reasoning_cfg, dict): - if reasoning_cfg.get("enabled") is False: - # Reasoning explicitly disabled — do not set reasoning - # or include. The Codex backend still thinks by - # default, but we honor the caller's intent where the - # API allows it. - pass - else: - # Truthy-only check mirrors agent/transports/codex.py - # build_kwargs(): falsy values (None, "", 0) fall back - # to the default rather than being forwarded to the - # Codex backend, which rejects e.g. {"effort": null} - # with a 400. - effort = reasoning_cfg.get("effort") or "medium" - # Codex backend rejects "minimal"; clamp to "low" to - # match the main-agent Codex transport behavior. - if effort == "minimal": - effort = "low" - resp_kwargs["reasoning"] = { - "effort": effort, - "summary": "auto", - } - resp_kwargs["include"] = ["reasoning.encrypted_content"] - # Tools support for auxiliary callers (e.g. skills_hub) that pass function schemas tools = kwargs.get("tools") if tools: @@ -991,10 +1013,11 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"): class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__(self, real_client: Any, model: str, is_oauth: bool = False, base_url: str = None): self._client = real_client self._model = model self._is_oauth = is_oauth + self._base_url = base_url def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1013,6 +1036,7 @@ def create(self, **kwargs) -> Any: else: max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000 temperature = kwargs.get("temperature") + reasoning_config = _extract_reasoning_config_from_kwargs(kwargs) normalized_tool_choice = None if isinstance(tool_choice, str): @@ -1029,9 +1053,10 @@ def create(self, **kwargs) -> Any: messages=messages, tools=tools, max_tokens=max_tokens, - reasoning_config=None, + reasoning_config=reasoning_config, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, + base_url=self._base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1089,7 +1114,7 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth, base_url=base_url) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 8ec6102f2e540..4d84fdf229a4c 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -973,13 +973,13 @@ def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api( assert mock_openai.call_args.kwargs["base_url"] == "https://api.openai.com/v1" assert mock_openai.call_args.kwargs["api_key"] == "sk-test" - class TestVisionClientFallback: """Vision client auto mode resolves known-good multimodal backends.""" def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch): """Active provider appears in available backends when credentials exist.""" monkeypatch.setenv("ANTHROPIC_API_KEY", "***") + with ( patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"), @@ -1036,6 +1036,159 @@ def test_anthropic_auxiliary_client_aggregates_stream_response(self): assert response.usage.completion_tokens == 4 +class TestAuxiliaryWrapperReasoning: + def test_codex_auxiliary_forwards_extra_body_reasoning_to_responses_api(self): + captured = {} + + class FakeResponses: + def create(self, **kwargs): + captured.update(kwargs) + return iter([ + SimpleNamespace(type="response.output_text.delta", delta="ok"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(id="resp_1", status="completed", usage=None), + ), + ]) + + from agent.auxiliary_client import CodexAuxiliaryClient + + real_client = SimpleNamespace( + responses=FakeResponses(), + api_key="tok", + base_url="https://chatgpt.com/backend-api/codex", + close=lambda: None, + ) + client = CodexAuxiliaryClient(real_client, "gpt-5.5") + + response = client.chat.completions.create( + model="gpt-5.5", + messages=[{"role": "user", "content": "think hard"}], + extra_body={"reasoning": {"enabled": True, "effort": "xhigh"}}, + ) + + assert response.choices[0].message.content == "ok" + assert captured["reasoning"] == {"effort": "xhigh", "summary": "auto"} + + def test_anthropic_auxiliary_forwards_extra_body_reasoning_to_adapter(self): + captured_build = {} + + class FakeMessages: + def create(self, **kwargs): + return SimpleNamespace(usage=None) + + fake_transport = SimpleNamespace( + normalize_response=lambda response, strip_tool_prefix=False: SimpleNamespace( + content="ok", + tool_calls=None, + reasoning=None, + finish_reason="stop", + ) + ) + + def fake_build_anthropic_kwargs(**kwargs): + captured_build.update(kwargs) + return {"model": kwargs["model"], "messages": []} + + from agent.auxiliary_client import AnthropicAuxiliaryClient + + client = AnthropicAuxiliaryClient( + SimpleNamespace(messages=FakeMessages()), + "claude-opus-4-6", + "anthropic-token", + "https://api.anthropic.com", + is_oauth=False, + ) + + with ( + patch("agent.anthropic_adapter.build_anthropic_kwargs", side_effect=fake_build_anthropic_kwargs), + patch("agent.transports.get_transport", return_value=fake_transport), + ): + response = client.chat.completions.create( + model="claude-opus-4-6", + messages=[{"role": "user", "content": "think hard"}], + extra_body={"reasoning": {"enabled": True, "effort": "xhigh"}}, + ) + + assert response.choices[0].message.content == "ok" + assert captured_build["reasoning_config"] == {"enabled": True, "effort": "xhigh"} + + def test_codex_auxiliary_effort_none_disables_reasoning(self): + captured = {} + + class FakeResponses: + def create(self, **kwargs): + captured.update(kwargs) + return iter([ + SimpleNamespace(type="response.output_text.delta", delta="ok"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(id="resp_1", status="completed", usage=None), + ), + ]) + + from agent.auxiliary_client import CodexAuxiliaryClient + + real_client = SimpleNamespace( + responses=FakeResponses(), + api_key="tok", + base_url="https://chatgpt.com/backend-api/codex", + close=lambda: None, + ) + client = CodexAuxiliaryClient(real_client, "gpt-5.5") + + client.chat.completions.create( + model="gpt-5.5", + messages=[{"role": "user", "content": "think normally"}], + extra_body={"reasoning": {"effort": "none"}}, + ) + + assert "reasoning" not in captured + assert captured["include"] == [] + + def test_anthropic_auxiliary_effort_none_disables_reasoning(self): + captured_build = {} + + class FakeMessages: + def create(self, **kwargs): + return SimpleNamespace(usage=None) + + fake_transport = SimpleNamespace( + normalize_response=lambda response, strip_tool_prefix=False: SimpleNamespace( + content="ok", + tool_calls=None, + reasoning=None, + finish_reason="stop", + ) + ) + + def fake_build_anthropic_kwargs(**kwargs): + captured_build.update(kwargs) + return {"model": kwargs["model"], "messages": []} + + from agent.auxiliary_client import AnthropicAuxiliaryClient + + client = AnthropicAuxiliaryClient( + SimpleNamespace(messages=FakeMessages()), + "claude-opus-4-6", + "anthropic-token", + "https://api.anthropic.com", + is_oauth=False, + ) + + with ( + patch("agent.anthropic_adapter.build_anthropic_kwargs", side_effect=fake_build_anthropic_kwargs), + patch("agent.transports.get_transport", return_value=fake_transport), + ): + client.chat.completions.create( + model="claude-opus-4-6", + messages=[{"role": "user", "content": "think normally"}], + extra_body={"reasoning": {"effort": "none"}}, + ) + + assert captured_build["reasoning_config"] == {"enabled": False} + + class TestAuxiliaryPoolAwareness: def test_try_nous_uses_pool_entry(self): pooled_token = _jwt_with_claims({ diff --git a/tests/tools/test_mixture_of_agents_tool.py b/tests/tools/test_mixture_of_agents_tool.py index 686922f892594..1fe5df430298d 100644 --- a/tests/tools/test_mixture_of_agents_tool.py +++ b/tests/tools/test_mixture_of_agents_tool.py @@ -1,58 +1,148 @@ import importlib import json from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest moa = importlib.import_module("tools.mixture_of_agents_tool") -def test_moa_defaults_are_well_formed(): - # Invariants, not a catalog snapshot: the exact model list churns with - # OpenRouter availability (see PR #6636 where gemini-3-pro-preview was - # removed upstream). What we care about is that the defaults are present - # and valid vendor/model slugs. - assert isinstance(moa.REFERENCE_MODELS, list) - assert len(moa.REFERENCE_MODELS) >= 1 - for m in moa.REFERENCE_MODELS: - assert isinstance(m, str) and "/" in m and not m.startswith("/") - assert isinstance(moa.AGGREGATOR_MODEL, str) - assert "/" in moa.AGGREGATOR_MODEL +def _fake_response(text): + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=text, reasoning=None))] + ) + + +def _fake_async_client(calls, provider_label): + async def create(**kwargs): + calls.append((provider_label, kwargs)) + return _fake_response(f"response from {provider_label}:{kwargs['model']}") + + return SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace(create=create) + ), + base_url="https://example.invalid/v1", + ) + + +def test_moa_defaults_use_five_provider_aware_sota_experts_and_current_judge(): + assert moa.REFERENCE_MODELS == [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + {"provider": "anthropic", "model": "claude-opus-4.6"}, + {"provider": "openrouter", "model": "google/gemini-3.1-pro-preview"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + {"provider": "openrouter", "model": "moonshotai/kimi-k2.6"}, + ] + assert moa.AGGREGATOR_MODEL == {"provider": "main", "model": "current"} + + +def test_current_judge_provider_only_config_uses_provider_specific_default(): + with patch("hermes_cli.config.load_config", return_value={"model": {"provider": "anthropic"}}): + assert moa._read_current_main_model_spec() == { + "provider": "anthropic", + "model": "claude-opus-4.6", + } + + +def test_current_judge_legacy_vendor_slug_without_provider_uses_openrouter(): + with patch("hermes_cli.config.load_config", return_value={"model": "anthropic/claude-opus-4.6"}): + assert moa._read_current_main_model_spec() == { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.6", + } + + +def test_current_judge_dict_model_key_is_honored(): + with patch( + "hermes_cli.config.load_config", + return_value={"model": {"provider": "anthropic", "model": "claude-sonnet-4.6"}}, + ): + assert moa._read_current_main_model_spec() == { + "provider": "anthropic", + "model": "claude-sonnet-4.6", + } @pytest.mark.asyncio -async def test_reference_model_retry_warnings_avoid_exc_info_until_terminal_failure(monkeypatch): +async def test_reference_model_uses_provider_router_and_preserves_retry_logging(monkeypatch): fake_client = SimpleNamespace( chat=SimpleNamespace( completions=SimpleNamespace( create=AsyncMock(side_effect=RuntimeError("rate limited")) ) - ) + ), + base_url="https://openrouter.ai/api/v1", ) + resolve = MagicMock(return_value=(fake_client, "deepseek/deepseek-v4-pro")) warn = MagicMock() err = MagicMock() - monkeypatch.setattr(moa, "_get_openrouter_client", lambda: fake_client) + monkeypatch.setattr(moa, "resolve_provider_client", resolve) monkeypatch.setattr(moa.logger, "warning", warn) monkeypatch.setattr(moa.logger, "error", err) model, message, success = await moa._run_reference_model_safe( - "openai/gpt-5.4-pro", "hello", max_retries=2 + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + "hello", + max_retries=2, ) - assert model == "openai/gpt-5.4-pro" + assert model == "openrouter/deepseek/deepseek-v4-pro" assert success is False assert "failed after 2 attempts" in message + resolve.assert_called_with("openrouter", "deepseek/deepseek-v4-pro", async_mode=True) assert warn.call_count == 2 assert all(call.kwargs.get("exc_info") is None for call in warn.call_args_list) err.assert_called_once() assert err.call_args.kwargs.get("exc_info") is True +@pytest.mark.asyncio +async def test_moa_queries_five_references_then_current_main_judge(monkeypatch): + calls = [] + + def fake_resolve(provider, model=None, async_mode=False): + assert async_mode is True + return _fake_async_client(calls, provider), model + + monkeypatch.setattr(moa, "resolve_provider_client", fake_resolve) + monkeypatch.setattr( + moa, + "_read_current_main_model_spec", + lambda: {"provider": "openai-codex", "model": "gpt-5.5"}, + ) + monkeypatch.setattr( + moa, + "_debug", + SimpleNamespace(log_call=MagicMock(), save=MagicMock(), active=False), + ) + + result = json.loads(await moa.mixture_of_agents_tool("solve this")) + + assert result["success"] is True + assert len(result["models_used"]["reference_models"]) == 5 + assert result["models_used"]["aggregator_model"] == { + "provider": "openai-codex", + "model": "gpt-5.5", + } + reference_calls = calls[:5] + judge_call = calls[5] + assert [provider for provider, _ in reference_calls] == [ + "openai-codex", + "anthropic", + "openrouter", + "openrouter", + "openrouter", + ] + assert judge_call[0] == "openai-codex" + assert judge_call[1]["messages"][0]["role"] == "system" + assert "Responses from models:" in judge_call[1]["messages"][0]["content"] + + @pytest.mark.asyncio async def test_moa_top_level_error_logs_single_traceback_on_aggregator_failure(monkeypatch): - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") monkeypatch.setattr( moa, "_run_reference_model_safe", @@ -75,7 +165,7 @@ async def test_moa_top_level_error_logs_single_traceback_on_aggregator_failure(m result = json.loads( await moa.mixture_of_agents_tool( "solve this", - reference_models=["anthropic/claude-opus-4.6"], + reference_models=[{"provider": "anthropic", "model": "claude-opus-4.6"}], ) ) diff --git a/tools/mixture_of_agents_tool.py b/tools/mixture_of_agents_tool.py index 35f9fc003f0b6..6bc99e1ca943d 100644 --- a/tools/mixture_of_agents_tool.py +++ b/tools/mixture_of_agents_tool.py @@ -2,85 +2,88 @@ """ Mixture-of-Agents Tool Module -This module implements the Mixture-of-Agents (MoA) methodology that leverages -the collective strengths of multiple LLMs through a layered architecture to -achieve state-of-the-art performance on complex reasoning tasks. +This module implements a provider-aware Mixture-of-Agents (MoA) workflow: +multiple frontier models generate independent reference answers in parallel, +then a current top-tier judge/aggregator model synthesizes the final answer. -Based on the research paper: "Mixture-of-Agents Enhances Large Language Model Capabilities" -by Junlin Wang et al. (arXiv:2406.04692v1) - -Key Features: -- Multi-layer LLM collaboration for enhanced reasoning -- Parallel processing of reference models for efficiency -- Intelligent aggregation and synthesis of diverse responses -- Specialized for extremely difficult problems requiring intense reasoning -- Optimized for coding, mathematics, and complex analytical tasks - -Available Tool: -- mixture_of_agents_tool: Process complex queries using multiple frontier models +Based on the research paper: "Mixture-of-Agents Enhances Large Language Model +Capabilities" by Junlin Wang et al. (arXiv:2406.04692v1). Architecture: -1. Reference models generate diverse initial responses in parallel -2. Aggregator model synthesizes responses into a high-quality output -3. Multiple layers can be used for iterative refinement (future enhancement) - -Models Used (via OpenRouter): -- Reference Models: claude-opus-4.6, gemini-3-pro-preview, gpt-5.4-pro, deepseek-v3.2 -- Aggregator Model: claude-opus-4.6 (highest capability for synthesis) - -Configuration: - To customize the MoA setup, modify the configuration constants at the top of this file: - - REFERENCE_MODELS: List of models for generating diverse initial responses - - AGGREGATOR_MODEL: Model used to synthesize the final response - - REFERENCE_TEMPERATURE/AGGREGATOR_TEMPERATURE: Sampling temperatures - - MIN_SUCCESSFUL_REFERENCES: Minimum successful models needed to proceed - -Usage: - from mixture_of_agents_tool import mixture_of_agents_tool - import asyncio - - # Process a complex query - result = await mixture_of_agents_tool( - user_prompt="Solve this complex mathematical proof..." - ) +1. Reference models generate diverse initial responses in parallel. +2. A judge/aggregator model synthesizes those responses into one answer. +3. The reference roster can mix direct subscription-backed providers and paid + aggregators, avoiding unnecessary OpenRouter spend for models available via + first-party subscriptions. + +Default Models: +- References: + - GPT-5.5 via OpenAI Codex / ChatGPT subscription + - Claude Opus 4.6 via Anthropic subscription + - Gemini 3.1 Pro Preview via OpenRouter + - DeepSeek V4 Pro via OpenRouter + - Kimi K2.6 via OpenRouter +- Aggregator/Judge: the current configured main model (usually GPT-5.5 or + Claude Opus 4.6), resolved at call time. """ +from __future__ import annotations + import json import logging -import os import asyncio import datetime -from typing import Dict, Any, List, Optional -from tools.openrouter_client import get_async_client as _get_openrouter_client, check_api_key as check_openrouter_api_key +from typing import Dict, Any, List, Optional, Union + +from agent.auxiliary_client import ( + OMIT_TEMPERATURE, + _fixed_temperature_for_model, + resolve_provider_client, +) from agent.auxiliary_client import extract_content_or_reasoning from tools.debug_helpers import DebugSession import sys logger = logging.getLogger(__name__) -# Configuration for MoA processing -# Reference models - these generate diverse initial responses in parallel. -# Keep this list aligned with current top-tier OpenRouter frontier options. -REFERENCE_MODELS = [ - "anthropic/claude-opus-4.6", - "google/gemini-2.5-pro", - "openai/gpt-5.4-pro", - "deepseek/deepseek-v3.2", +ModelSpec = Union[str, Dict[str, str]] + +# Provider-aware reference models. Strings remain accepted at runtime for +# backwards compatibility, but defaults should use explicit provider routing so +# subscription-backed models do not accidentally go through OpenRouter. +REFERENCE_MODELS: List[Dict[str, str]] = [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + {"provider": "anthropic", "model": "claude-opus-4.6"}, + {"provider": "openrouter", "model": "google/gemini-3.1-pro-preview"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, + {"provider": "openrouter", "model": "moonshotai/kimi-k2.6"}, ] -# Aggregator model - synthesizes reference responses into final output. -# Prefer the strongest synthesis model in the current OpenRouter lineup. -AGGREGATOR_MODEL = "anthropic/claude-opus-4.6" +# The judge should track the currently configured top model. Resolving this at +# call time means switching Hermes from GPT to Claude (or back) automatically +# changes the MoA judge without another code edit. +AGGREGATOR_MODEL: Dict[str, str] = {"provider": "main", "model": "current"} -# Temperature settings optimized for MoA performance +# Fallback judge if the runtime config cannot be read. +FALLBACK_AGGREGATOR_MODEL: Dict[str, str] = {"provider": "openai-codex", "model": "gpt-5.5"} +PROVIDER_DEFAULT_JUDGES: Dict[str, str] = { + "openai-codex": "gpt-5.5", + "anthropic": "claude-opus-4.6", +} + +# Temperature settings optimized for MoA performance. REFERENCE_TEMPERATURE = 0.6 # Balanced creativity for diverse perspectives AGGREGATOR_TEMPERATURE = 0.4 # Focused synthesis for consistency -# Failure handling configuration +# Failure handling configuration. MIN_SUCCESSFUL_REFERENCES = 1 # Minimum successful reference models needed to proceed -# System prompt for the aggregator model (from the research paper) -AGGREGATOR_SYSTEM_PROMPT = """You have been provided with a set of responses from various open-source models to the latest user query. Your task is to synthesize these responses into a single, high-quality response. It is crucial to critically evaluate the information provided in these responses, recognizing that some of it may be biased or incorrect. Your response should not simply replicate the given answers but should offer a refined, accurate, and comprehensive reply to the instruction. Ensure your response is well-structured, coherent, and adheres to the highest standards of accuracy and reliability. +# Maximum-reasoning default passed to providers/endpoints that support it. +DEFAULT_REASONING_CONFIG: Dict[str, Any] = {"enabled": True, "effort": "xhigh"} + +# System prompt for the aggregator model (from the research paper, with +# provider wording modernized because our references are not all open-source). +AGGREGATOR_SYSTEM_PROMPT = """You have been provided with a set of responses from various frontier models to the latest user query. Your task is to synthesize these responses into a single, high-quality response. It is crucial to critically evaluate the information provided in these responses, recognizing that some of it may be biased or incorrect. Your response should not simply replicate the given answers but should offer a refined, accurate, and comprehensive reply to the instruction. Ensure your response is well-structured, coherent, and adheres to the highest standards of accuracy and reliability. Responses from models:""" @@ -88,146 +91,275 @@ def _construct_aggregator_prompt(system_prompt: str, responses: List[str]) -> str: - """ - Construct the final system prompt for the aggregator including all model responses. - - Args: - system_prompt (str): Base system prompt for aggregation - responses (List[str]): List of responses from reference models - - Returns: - str: Complete system prompt with enumerated responses - """ + """Construct the final system prompt for the aggregator.""" response_text = "\n".join([f"{i+1}. {response}" for i, response in enumerate(responses)]) return f"{system_prompt}\n\n{response_text}" +def _infer_provider_for_model(model: str) -> str: + """Best-effort provider inference for legacy model-only configs.""" + model_lower = (model or "").strip().lower() + if not model_lower: + return FALLBACK_AGGREGATOR_MODEL["provider"] + if "/" in model_lower: + # Vendor/model slugs are aggregator-native; preserve them through OpenRouter + # rather than pairing them with a first-party subscription provider. + return "openrouter" + if model_lower.startswith("gpt-"): + return "openai-codex" + if model_lower.startswith("claude-"): + return "anthropic" + return "openrouter" + + +def _read_current_main_model_spec() -> Dict[str, str]: + """Return the current configured main provider/model for judge routing. + + This intentionally reads config.yaml at call time so `/model` or config + changes are picked up without changing the MoA source. If config is absent + or incomplete, fall back to the subscription-backed GPT-5.5 route. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + provider = str(model_cfg.get("provider") or "").strip().lower() + model = str(model_cfg.get("default") or model_cfg.get("model") or "").strip() + elif isinstance(model_cfg, str): + provider = "auto" + model = model_cfg.strip() + else: + provider = "" + model = "" + except Exception as exc: + logger.debug("Could not read current main model for MoA judge: %s", exc) + provider = "" + model = "" + + if not provider or provider == "auto": + provider = _infer_provider_for_model(model) + if not model or model == "current": + model = PROVIDER_DEFAULT_JUDGES.get(provider) + if not model: + return dict(FALLBACK_AGGREGATOR_MODEL) + return {"provider": provider, "model": model} + + +def _normalize_model_spec(spec: ModelSpec) -> Dict[str, str]: + """Normalize a model spec into `{provider, model}` form. + + Backwards compatibility: a bare string is interpreted as an OpenRouter + model slug, matching the original MoA tool semantics. + """ + if isinstance(spec, str): + return {"provider": "openrouter", "model": spec.strip()} + if not isinstance(spec, dict): + raise TypeError(f"Invalid MoA model spec {spec!r}; expected string or dict") + + provider = str(spec.get("provider") or "openrouter").strip().lower() + model = str(spec.get("model") or "").strip() + if not provider: + provider = "openrouter" + if not model: + raise ValueError(f"MoA model spec missing model: {spec!r}") + return {"provider": provider, "model": model} + + +def _resolve_runtime_model_spec(spec: ModelSpec) -> Dict[str, str]: + """Normalize a spec and resolve `{main,current}` to the live main model.""" + normalized = _normalize_model_spec(spec) + if normalized["provider"] == "main" or normalized["model"] == "current": + return _read_current_main_model_spec() + return normalized + + +def _model_key(spec: Dict[str, str]) -> str: + """Stable human/log key for a provider-aware model spec.""" + return f"{spec['provider']}/{spec['model']}" + + +def _model_public_spec(spec: ModelSpec) -> Dict[str, str]: + """Return a JSON-serializable resolved provider/model spec.""" + return dict(_resolve_runtime_model_spec(spec)) + + +def _temperature_for_model( + spec: Dict[str, str], + resolved_model: str, + client: Any, + requested_temperature: Optional[float], +) -> Optional[float]: + """Return the temperature to send, or None to omit it. + + Some models/providers reject custom temperature values (Codex/GPT family, + Kimi server-managed routes, selected provider endpoints). This centralizes + the omission logic so MoA does not generate avoidable 400s. + """ + if requested_temperature is None: + return None + + provider = spec.get("provider", "") + model_lower = (resolved_model or spec.get("model", "")).lower() + if provider == "openai-codex" or model_lower.startswith("gpt-") or "/gpt-" in model_lower: + return None + + try: + fixed = _fixed_temperature_for_model(resolved_model or spec.get("model"), getattr(client, "base_url", "")) + if fixed is OMIT_TEMPERATURE: + return None + if fixed is not None: + return fixed + except Exception: + # Temperature is a quality hint, not a hard requirement. If the helper + # fails, fall back to the caller's requested value. + pass + + return requested_temperature + + +def _build_api_params( + *, + spec: Dict[str, str], + resolved_model: str, + client: Any, + messages: List[Dict[str, str]], + temperature: Optional[float], + max_tokens: Optional[int], +) -> Dict[str, Any]: + """Build chat.completions-compatible kwargs for a routed MoA call.""" + api_params: Dict[str, Any] = { + "model": resolved_model, + "messages": messages, + "extra_body": {"reasoning": dict(DEFAULT_REASONING_CONFIG)}, + } + if max_tokens is not None: + api_params["max_tokens"] = max_tokens + + resolved_temperature = _temperature_for_model(spec, resolved_model, client, temperature) + if resolved_temperature is not None: + api_params["temperature"] = resolved_temperature + + return api_params + + +async def _query_model_spec( + spec: ModelSpec, + messages: List[Dict[str, str]], + temperature: Optional[float], + max_tokens: Optional[int], +) -> tuple[Dict[str, str], str, str]: + """Resolve a provider-aware model spec, execute one async model call.""" + runtime_spec = _resolve_runtime_model_spec(spec) + client, resolved_model = resolve_provider_client( + runtime_spec["provider"], runtime_spec["model"], async_mode=True + ) + if client is None or not resolved_model: + raise RuntimeError( + f"No client available for MoA model {_model_key(runtime_spec)}" + ) + + api_params = _build_api_params( + spec=runtime_spec, + resolved_model=resolved_model, + client=client, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + response = await client.chat.completions.create(**api_params) + content = extract_content_or_reasoning(response) + return runtime_spec, resolved_model, content + + async def _run_reference_model_safe( - model: str, + model: ModelSpec, user_prompt: str, temperature: float = REFERENCE_TEMPERATURE, max_tokens: int = 32000, - max_retries: int = 6 + max_retries: int = 6, ) -> tuple[str, str, bool]: - """ - Run a single reference model with retry logic and graceful failure handling. - - Args: - model (str): Model identifier to use - user_prompt (str): The user's query - temperature (float): Sampling temperature for response generation - max_tokens (int): Maximum tokens in response - max_retries (int): Maximum number of retry attempts - - Returns: - tuple[str, str, bool]: (model_name, response_content_or_error, success_flag) - """ + """Run a single reference model with retry logic and graceful failure.""" + runtime_spec = _resolve_runtime_model_spec(model) + model_name = _model_key(runtime_spec) + for attempt in range(max_retries): try: - logger.info("Querying %s (attempt %s/%s)", model, attempt + 1, max_retries) - - # Build parameters for the API call - api_params = { - "model": model, - "messages": [{"role": "user", "content": user_prompt}], - "max_tokens": max_tokens, - "extra_body": { - "reasoning": { - "enabled": True, - "effort": "xhigh" - } - } - } - - # GPT models (especially gpt-4o-mini) don't support custom temperature values - # Only include temperature for non-GPT models - if not model.lower().startswith('gpt-'): - api_params["temperature"] = temperature - - response = await _get_openrouter_client().chat.completions.create(**api_params) - - content = extract_content_or_reasoning(response) + logger.info("Querying %s (attempt %s/%s)", model_name, attempt + 1, max_retries) + + _runtime_spec, _resolved_model, content = await _query_model_spec( + runtime_spec, + [{"role": "user", "content": user_prompt}], + temperature, + max_tokens, + ) + model_name = _model_key(_runtime_spec) + if not content: - # Reasoning-only response — let the retry loop handle it - logger.warning("%s returned empty content (attempt %s/%s), retrying", model, attempt + 1, max_retries) + logger.warning( + "%s returned empty content (attempt %s/%s), retrying", + model_name, + attempt + 1, + max_retries, + ) if attempt < max_retries - 1: await asyncio.sleep(min(2 ** (attempt + 1), 60)) continue - logger.info("%s responded (%s characters)", model, len(content)) - return model, content, True - + logger.info("%s responded (%s characters)", model_name, len(content)) + return model_name, content, True + except Exception as e: error_str = str(e) - # Keep retry-path logging concise; full tracebacks are reserved for - # terminal failure paths so long-running MoA retries don't flood logs. if "invalid" in error_str.lower(): - logger.warning("%s invalid request error (attempt %s): %s", model, attempt + 1, error_str) + logger.warning("%s invalid request error (attempt %s): %s", model_name, attempt + 1, error_str) elif "rate" in error_str.lower() or "limit" in error_str.lower(): - logger.warning("%s rate limit error (attempt %s): %s", model, attempt + 1, error_str) + logger.warning("%s rate limit error (attempt %s): %s", model_name, attempt + 1, error_str) else: - logger.warning("%s unknown error (attempt %s): %s", model, attempt + 1, error_str) + logger.warning("%s unknown error (attempt %s): %s", model_name, attempt + 1, error_str) if attempt < max_retries - 1: - # Exponential backoff for rate limiting: 2s, 4s, 8s, 16s, 32s, 60s sleep_time = min(2 ** (attempt + 1), 60) logger.info("Retrying in %ss...", sleep_time) await asyncio.sleep(sleep_time) else: - error_msg = f"{model} failed after {max_retries} attempts: {error_str}" + error_msg = f"{model_name} failed after {max_retries} attempts: {error_str}" logger.error("%s", error_msg, exc_info=True) - return model, error_msg, False + return model_name, error_msg, False async def _run_aggregator_model( system_prompt: str, user_prompt: str, temperature: float = AGGREGATOR_TEMPERATURE, - max_tokens: int = None + max_tokens: int = 32000, + aggregator_model: Optional[ModelSpec] = None, ) -> str: - """ - Run the aggregator model to synthesize the final response. - - Args: - system_prompt (str): System prompt with all reference responses - user_prompt (str): Original user query - temperature (float): Focused temperature for consistent aggregation - max_tokens (int): Maximum tokens in final response - - Returns: - str: Synthesized final response - """ - logger.info("Running aggregator model: %s", AGGREGATOR_MODEL) - - # Build parameters for the API call - api_params = { - "model": AGGREGATOR_MODEL, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ], - "max_tokens": max_tokens, - "extra_body": { - "reasoning": { - "enabled": True, - "effort": "xhigh" - } - } - } - - # GPT models (especially gpt-4o-mini) don't support custom temperature values - # Only include temperature for non-GPT models - if not AGGREGATOR_MODEL.lower().startswith('gpt-'): - api_params["temperature"] = temperature - - response = await _get_openrouter_client().chat.completions.create(**api_params) - - content = extract_content_or_reasoning(response) + """Run the judge/aggregator model to synthesize the final response.""" + judge_spec = _resolve_runtime_model_spec(aggregator_model or AGGREGATOR_MODEL) + logger.info("Running aggregator model: %s", _model_key(judge_spec)) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + _runtime_spec, _resolved_model, content = await _query_model_spec( + judge_spec, + messages, + temperature, + max_tokens, + ) - # Retry once on empty content (reasoning-only response) + # Retry once on empty content (reasoning-only response or transient stream issue). if not content: logger.warning("Aggregator returned empty content, retrying once") - response = await _get_openrouter_client().chat.completions.create(**api_params) - content = extract_content_or_reasoning(response) + _runtime_spec, _resolved_model, content = await _query_model_spec( + judge_spec, + messages, + temperature, + max_tokens, + ) logger.info("Aggregation complete (%s characters)", len(content)) return content @@ -235,55 +367,27 @@ async def _run_aggregator_model( async def mixture_of_agents_tool( user_prompt: str, - reference_models: Optional[List[str]] = None, - aggregator_model: Optional[str] = None + reference_models: Optional[List[ModelSpec]] = None, + aggregator_model: Optional[ModelSpec] = None, ) -> str: - """ - Process a complex query using the Mixture-of-Agents methodology. - - This tool leverages multiple frontier language models to collaboratively solve - extremely difficult problems requiring intense reasoning. It's particularly - effective for: - - Complex mathematical proofs and calculations - - Advanced coding problems and algorithm design - - Multi-step analytical reasoning tasks - - Problems requiring diverse domain expertise - - Tasks where single models show limitations - - The MoA approach uses a fixed 2-layer architecture: - 1. Layer 1: Multiple reference models generate diverse responses in parallel (temp=0.6) - 2. Layer 2: Aggregator model synthesizes the best elements into final response (temp=0.4) - - Args: - user_prompt (str): The complex query or problem to solve - reference_models (Optional[List[str]]): Custom reference models to use - aggregator_model (Optional[str]): Custom aggregator model to use - - Returns: - str: JSON string containing the MoA results with the following structure: - { - "success": bool, - "response": str, - "models_used": { - "reference_models": List[str], - "aggregator_model": str - }, - "processing_time": float - } - - Raises: - Exception: If MoA processing fails or API key is not set + """Process a complex query using the Mixture-of-Agents methodology. + + Default architecture: five provider-aware reference models in parallel, + followed by one current-main-model judge call (six model calls total). """ start_time = datetime.datetime.now() - + + ref_models: List[ModelSpec] = reference_models or REFERENCE_MODELS + judge_model: ModelSpec = aggregator_model or AGGREGATOR_MODEL + debug_call_data = { "parameters": { "user_prompt": user_prompt[:200] + "..." if len(user_prompt) > 200 else user_prompt, - "reference_models": reference_models or REFERENCE_MODELS, - "aggregator_model": aggregator_model or AGGREGATOR_MODEL, + "reference_models": [_model_public_spec(m) for m in ref_models], + "aggregator_model": _model_public_spec(judge_model), "reference_temperature": REFERENCE_TEMPERATURE, "aggregator_temperature": AGGREGATOR_TEMPERATURE, - "min_successful_references": MIN_SUCCESSFUL_REFERENCES + "min_successful_references": MIN_SUCCESSFUL_REFERENCES, }, "error": None, "success": False, @@ -292,222 +396,184 @@ async def mixture_of_agents_tool( "failed_models": [], "final_response_length": 0, "processing_time_seconds": 0, - "models_used": {} + "models_used": {}, } - + try: logger.info("Starting Mixture-of-Agents processing...") logger.info("Query: %s", user_prompt[:100]) - - # Validate API key availability - if not os.getenv("OPENROUTER_API_KEY"): - raise ValueError("OPENROUTER_API_KEY environment variable not set") - - # Use provided models or defaults - ref_models = reference_models or REFERENCE_MODELS - agg_model = aggregator_model or AGGREGATOR_MODEL - logger.info("Using %s reference models in 2-layer MoA architecture", len(ref_models)) - - # Layer 1: Generate diverse responses from reference models (with failure handling) + + # Layer 1: Generate diverse responses from reference models. logger.info("Layer 1: Generating reference responses...") model_results = await asyncio.gather(*[ _run_reference_model_safe(model, user_prompt, REFERENCE_TEMPERATURE) for model in ref_models ]) - - # Separate successful and failed responses + successful_responses = [] failed_models = [] - + for model_name, content, success in model_results: if success: successful_responses.append(content) else: failed_models.append(model_name) - + successful_count = len(successful_responses) failed_count = len(failed_models) - + logger.info("Reference model results: %s successful, %s failed", successful_count, failed_count) - if failed_models: logger.warning("Failed models: %s", ', '.join(failed_models)) - - # Check if we have enough successful responses to proceed + if successful_count < MIN_SUCCESSFUL_REFERENCES: - raise ValueError(f"Insufficient successful reference models ({successful_count}/{len(ref_models)}). Need at least {MIN_SUCCESSFUL_REFERENCES} successful responses.") - + raise ValueError( + f"Insufficient successful reference models ({successful_count}/{len(ref_models)}). " + f"Need at least {MIN_SUCCESSFUL_REFERENCES} successful responses." + ) + debug_call_data["reference_responses_count"] = successful_count debug_call_data["failed_models_count"] = failed_count debug_call_data["failed_models"] = failed_models - - # Layer 2: Aggregate responses using the aggregator model + + # Layer 2: Aggregate responses using the judge model. logger.info("Layer 2: Synthesizing final response...") aggregator_system_prompt = _construct_aggregator_prompt( - AGGREGATOR_SYSTEM_PROMPT, - successful_responses + AGGREGATOR_SYSTEM_PROMPT, + successful_responses, ) - + final_response = await _run_aggregator_model( aggregator_system_prompt, user_prompt, - AGGREGATOR_TEMPERATURE + AGGREGATOR_TEMPERATURE, + aggregator_model=judge_model, ) - - # Calculate processing time + end_time = datetime.datetime.now() processing_time = (end_time - start_time).total_seconds() - logger.info("MoA processing completed in %.2f seconds", processing_time) - - # Prepare successful response (only final aggregated result, minimal fields) + result = { "success": True, "response": final_response, "models_used": { - "reference_models": ref_models, - "aggregator_model": agg_model - } + "reference_models": [_model_public_spec(m) for m in ref_models], + "aggregator_model": _model_public_spec(judge_model), + }, } - + debug_call_data["success"] = True debug_call_data["final_response_length"] = len(final_response) debug_call_data["processing_time_seconds"] = processing_time debug_call_data["models_used"] = result["models_used"] - - # Log debug information + _debug.log_call("mixture_of_agents_tool", debug_call_data) _debug.save() - + return json.dumps(result, indent=2, ensure_ascii=False) - + except Exception as e: error_msg = f"Error in MoA processing: {str(e)}" logger.error("%s", error_msg, exc_info=True) - - # Calculate processing time even for errors + end_time = datetime.datetime.now() processing_time = (end_time - start_time).total_seconds() - - # Prepare error response (minimal fields) + result = { "success": False, "response": "MoA processing failed. Please try again or use a single model for this query.", "models_used": { - "reference_models": reference_models or REFERENCE_MODELS, - "aggregator_model": aggregator_model or AGGREGATOR_MODEL + "reference_models": [_model_public_spec(m) for m in ref_models], + "aggregator_model": _model_public_spec(judge_model), }, - "error": error_msg + "error": error_msg, } - + debug_call_data["error"] = error_msg debug_call_data["processing_time_seconds"] = processing_time _debug.log_call("mixture_of_agents_tool", debug_call_data) _debug.save() - + return json.dumps(result, indent=2, ensure_ascii=False) -def check_moa_requirements() -> bool: - """ - Check if all requirements for MoA tools are met. - - Returns: - bool: True if requirements are met, False otherwise - """ - return check_openrouter_api_key() +def _client_available_for_spec(spec: ModelSpec) -> bool: + try: + runtime_spec = _resolve_runtime_model_spec(spec) + client, resolved_model = resolve_provider_client( + runtime_spec["provider"], runtime_spec["model"], async_mode=False + ) + return client is not None and bool(resolved_model) + except Exception as exc: + logger.debug("MoA requirement check failed for %r: %s", spec, exc) + return False + +def check_moa_requirements() -> bool: + """Check if enough routed providers are available for the default MoA.""" + successful_refs = sum(1 for spec in REFERENCE_MODELS if _client_available_for_spec(spec)) + return ( + successful_refs >= MIN_SUCCESSFUL_REFERENCES + and _client_available_for_spec(AGGREGATOR_MODEL) + ) def get_moa_configuration() -> Dict[str, Any]: - """ - Get the current MoA configuration settings. - - Returns: - Dict[str, Any]: Dictionary containing all configuration parameters - """ + """Get the current MoA configuration settings.""" + resolved_refs = [_model_public_spec(m) for m in REFERENCE_MODELS] + resolved_judge = _model_public_spec(AGGREGATOR_MODEL) return { - "reference_models": REFERENCE_MODELS, - "aggregator_model": AGGREGATOR_MODEL, + "reference_models": resolved_refs, + "aggregator_model": resolved_judge, "reference_temperature": REFERENCE_TEMPERATURE, "aggregator_temperature": AGGREGATOR_TEMPERATURE, + "reasoning": DEFAULT_REASONING_CONFIG, "min_successful_references": MIN_SUCCESSFUL_REFERENCES, "total_reference_models": len(REFERENCE_MODELS), - "failure_tolerance": f"{len(REFERENCE_MODELS) - MIN_SUCCESSFUL_REFERENCES}/{len(REFERENCE_MODELS)} models can fail" + "total_model_calls": len(REFERENCE_MODELS) + 1, + "failure_tolerance": f"{len(REFERENCE_MODELS) - MIN_SUCCESSFUL_REFERENCES}/{len(REFERENCE_MODELS)} reference models can fail", } if __name__ == "__main__": - """ - Simple test/demo when run directly - """ print("🤖 Mixture-of-Agents Tool Module") print("=" * 50) - - # Check if API key is available - api_available = check_openrouter_api_key() - + + api_available = check_moa_requirements() if not api_available: - print("❌ OPENROUTER_API_KEY environment variable not set") - print("Please set your API key: export OPENROUTER_API_KEY='your-key-here'") - print("Get API key at: https://openrouter.ai/") + print("❌ Not enough MoA provider credentials available") + print("Configure OpenAI Codex, Anthropic, and OpenRouter credentials via `hermes auth` / .env.") sys.exit(1) - else: - print("✅ OpenRouter API key found") - + + print("✅ MoA routed providers available") print("🛠️ MoA tools ready for use!") - - # Show current configuration + config = get_moa_configuration() print("\n⚙️ Current Configuration:") - print(f" 🤖 Reference models ({len(config['reference_models'])}): {', '.join(config['reference_models'])}") - print(f" 🧠 Aggregator model: {config['aggregator_model']}") + print(" 🤖 Reference models:") + for m in config["reference_models"]: + print(f" - {m['provider']}: {m['model']}") + print(f" 🧠 Aggregator model: {config['aggregator_model']['provider']}: {config['aggregator_model']['model']}") + print(f" 📞 Total model calls: {config['total_model_calls']}") print(f" 🌡️ Reference temperature: {config['reference_temperature']}") print(f" 🌡️ Aggregator temperature: {config['aggregator_temperature']}") print(f" 🛡️ Failure tolerance: {config['failure_tolerance']}") - print(f" 📊 Minimum successful models: {config['min_successful_references']}") - - # Show debug mode status + print(f" 📊 Minimum successful references: {config['min_successful_references']}") + if _debug.active: print(f"\n🐛 Debug mode ENABLED - Session ID: {_debug.session_id}") - print(f" Debug logs will be saved to: ./logs/moa_tools_debug_{_debug.session_id}.json") + print(" Debug logs will be saved under ./logs/") else: print("\n🐛 Debug mode disabled (set MOA_TOOLS_DEBUG=true to enable)") - - print("\nBasic usage:") - print(" from mixture_of_agents_tool import mixture_of_agents_tool") - print(" import asyncio") - print("") - print(" async def main():") - print(" result = await mixture_of_agents_tool(") - print(" user_prompt='Solve this complex mathematical proof...'") - print(" )") - print(" print(result)") - print(" asyncio.run(main())") - + print("\nBest use cases:") print(" - Complex mathematical proofs and calculations") print(" - Advanced coding problems and algorithm design") print(" - Multi-step analytical reasoning tasks") print(" - Problems requiring diverse domain expertise") print(" - Tasks where single models show limitations") - - print("\nPerformance characteristics:") - print(" - Higher latency due to multiple model calls") - print(" - Significantly improved quality for complex tasks") - print(" - Parallel processing for efficiency") - print(f" - Optimized temperatures: {REFERENCE_TEMPERATURE} for reference models, {AGGREGATOR_TEMPERATURE} for aggregation") - print(" - Token-efficient: only returns final aggregated response") - print(" - Resilient: continues with partial model failures") - print(" - Configurable: easy to modify models and settings at top of file") - print(" - State-of-the-art results on challenging benchmarks") - - print("\nDebug mode:") - print(" # Enable debug logging") - print(" export MOA_TOOLS_DEBUG=true") - print(" # Debug logs capture all MoA processing steps and metrics") - print(" # Logs saved to: ./logs/moa_tools_debug_UUID.json") # --------------------------------------------------------------------------- @@ -517,7 +583,7 @@ def get_moa_configuration() -> Dict[str, Any]: MOA_SCHEMA = { "name": "mixture_of_agents", - "description": "Route a hard problem through multiple frontier LLMs collaboratively. Makes 5 API calls (4 reference models + 1 aggregator) with maximum reasoning effort — use sparingly for genuinely difficult problems. Best for: complex math, advanced algorithms, multi-step analytical reasoning, problems benefiting from diverse perspectives.", + "description": "Route a hard problem through multiple frontier LLMs collaboratively. Makes 6 model calls by default (5 reference models + 1 current-main-model judge) with maximum reasoning effort — use sparingly for genuinely difficult problems. Best for: complex math, advanced algorithms, multi-step analytical reasoning, problems benefiting from diverse perspectives.", "parameters": { "type": "object", "properties": { @@ -536,7 +602,7 @@ def get_moa_configuration() -> Dict[str, Any]: schema=MOA_SCHEMA, handler=lambda args, **kw: mixture_of_agents_tool(user_prompt=args.get("user_prompt", "")), check_fn=check_moa_requirements, - requires_env=["OPENROUTER_API_KEY"], + requires_env=[], is_async=True, emoji="🧠", ) From 53c4a77ad9288c7ab4c17a550a1884a12d6807ff Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Fri, 15 May 2026 18:23:10 +0200 Subject: [PATCH 09/28] feat(gateway): add macOS app-wrapper launchd identity --- hermes_cli/gateway.py | 433 +++++++++++++++++++---- hermes_cli/subcommands/gateway.py | 7 + tests/hermes_cli/test_gateway_service.py | 240 +++++++++++++ 3 files changed, 602 insertions(+), 78 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f1dddd087f496..a8531df6785dc 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -7,11 +7,13 @@ import asyncio import logging import os +import plistlib import shlex import shutil import signal import subprocess import sys +import tempfile import textwrap from dataclasses import dataclass from pathlib import Path @@ -2156,6 +2158,278 @@ def get_launchd_plist_path() -> Path: return _launchd_user_home() / "Library" / "LaunchAgents" / f"{name}.plist" +MACOS_APP_WRAPPER_DISPLAY_NAME = "Hermes Agent" +MACOS_APP_WRAPPER_ENV_KEY = "HERMES_LAUNCHD_APP_WRAPPER" +MACOS_APP_WRAPPER_SOURCE_INFO = "HermesPythonSource.plist" + + +def get_launchd_bundle_identifier() -> str: + """Return the bundle identifier associated with the launchd gateway job.""" + return get_launchd_label() + + +def get_launchd_app_wrapper_path() -> Path: + """Return the macOS app bundle path used for the optional launchd wrapper.""" + suffix = _profile_suffix() + bundle_name = ( + f"{MACOS_APP_WRAPPER_DISPLAY_NAME} ({suffix}).app" + if suffix + else f"{MACOS_APP_WRAPPER_DISPLAY_NAME}.app" + ) + return get_hermes_home() / "macos" / bundle_name + + +def get_launchd_app_wrapper_executable_path() -> Path: + """Return the executable path inside the optional macOS app wrapper.""" + return ( + get_launchd_app_wrapper_path() + / "Contents" + / "MacOS" + / MACOS_APP_WRAPPER_DISPLAY_NAME + ) + + +def _python_home_from_path(python_path: str | Path | None = None, venv: Path | None = None) -> Path: + """Return the base Python distribution root for a venv/base executable.""" + if venv is not None: + pyvenv_cfg = venv / "pyvenv.cfg" + if pyvenv_cfg.exists(): + for line in pyvenv_cfg.read_text(encoding="utf-8", errors="ignore").splitlines(): + key, sep, value = line.partition("=") + if sep and key.strip().lower() == "home" and value.strip(): + home = Path(value.strip()).expanduser() + if home.name in {"bin", "Scripts"}: + return home.parent + return home + resolved = Path(python_path or get_python_path()).resolve() + return resolved.parent.parent + + +def _venv_site_packages_path(venv: Path | None) -> Path | None: + """Return the best site-packages path for a virtualenv, if known.""" + if venv is None: + return None + lib_dir = venv / "lib" + if lib_dir.exists(): + candidates = sorted(lib_dir.glob("python*/site-packages")) + if candidates: + return candidates[0] + return venv / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages" + + +def _launchd_child_python_path(venv: Path | None) -> str: + """Return the real interpreter path child processes should use.""" + if venv is not None: + candidate = venv / ("Scripts/python.exe" if is_windows() else "bin/python") + if candidate.exists(): + return str(candidate) + return get_python_path() + + +def _launchd_app_wrapper_pythonpath(venv: Path | None) -> str: + """Build a stable PYTHONPATH for the copied Python executable inside the app wrapper.""" + parts: list[str] = [] + site_packages = _venv_site_packages_path(venv) + if site_packages is not None: + parts.append(str(site_packages)) + parts.append(str(PROJECT_ROOT)) + return ":".join(dict.fromkeys(parts)) + + +def _launchd_app_wrapper_info() -> dict: + """Return Info.plist metadata for the optional macOS launchd app wrapper.""" + from hermes_cli import __version__ as hermes_version + + return { + "CFBundleIdentifier": get_launchd_bundle_identifier(), + "CFBundleName": MACOS_APP_WRAPPER_DISPLAY_NAME, + "CFBundleDisplayName": MACOS_APP_WRAPPER_DISPLAY_NAME, + "CFBundleExecutable": MACOS_APP_WRAPPER_DISPLAY_NAME, + "CFBundlePackageType": "APPL", + "CFBundleVersion": hermes_version, + "CFBundleShortVersionString": hermes_version, + # No Dock icon/window for this background helper when LaunchServices + # encounters it. launchd still executes the bundled binary directly. + "LSBackgroundOnly": True, + } + + +def _launchd_app_wrapper_source_info(source_python: Path | None = None) -> dict: + """Return stable source metadata for deciding whether the wrapper is fresh.""" + source = Path(source_python or get_python_path()).resolve() + stat = source.stat() + return { + "SourcePython": str(source), + "SourceSize": stat.st_size, + "SourceMTimeNs": stat.st_mtime_ns, + } + + +def _launchd_app_wrapper_signature_is_valid(app_path: Path | None = None) -> bool: + """Return True when the macOS app wrapper has a valid code signature.""" + target = app_path or get_launchd_app_wrapper_path() + try: + result = subprocess.run( + ["codesign", "--verify", "--deep", "--strict", str(target)], + capture_output=True, + text=True, + timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def launchd_app_wrapper_is_current() -> bool: + """Return True when the optional macOS app wrapper matches this install.""" + app_path = get_launchd_app_wrapper_path() + executable_path = get_launchd_app_wrapper_executable_path() + info_path = app_path / "Contents" / "Info.plist" + source_info_path = app_path / "Contents" / "Resources" / MACOS_APP_WRAPPER_SOURCE_INFO + source_python = Path(get_python_path()).resolve() + if ( + not executable_path.exists() + or not info_path.exists() + or not source_info_path.exists() + or not source_python.exists() + ): + return False + try: + info = plistlib.loads(info_path.read_bytes()) + source_info = plistlib.loads(source_info_path.read_bytes()) + expected_source_info = _launchd_app_wrapper_source_info(source_python) + except Exception: + return False + expected = _launchd_app_wrapper_info() + return ( + all(info.get(key) == value for key, value in expected.items()) + and all(source_info.get(key) == value for key, value in expected_source_info.items()) + and _launchd_app_wrapper_signature_is_valid(app_path) + ) + + +def install_launchd_app_wrapper(force: bool = False) -> Path: + """Install/update the optional macOS app bundle used as launchd executable. + + The bundle contains a *copy* of the active Python executable named + ``Hermes Agent``. launchd then starts that bundle executable instead of the + generic ``python3.13`` binary, giving macOS/TCC a Hermes-specific path and + bundle identity while still running Hermes through the existing venv. + """ + app_path = get_launchd_app_wrapper_path() + if app_path.exists() and not force and launchd_app_wrapper_is_current(): + return app_path + + source_python = Path(get_python_path()).resolve() + if not source_python.exists(): + raise FileNotFoundError(f"Python executable not found: {source_python}") + detected_venv = _detect_venv_dir() + + app_parent = app_path.parent + app_parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".{app_path.name}.", dir=app_parent) as tmpdir: + staging_app_path = Path(tmpdir) / app_path.name + contents_dir = staging_app_path / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + macos_dir.mkdir(parents=True, exist_ok=True) + resources_dir.mkdir(parents=True, exist_ok=True) + executable_path = macos_dir / get_launchd_app_wrapper_executable_path().name + shutil.copy2(source_python, executable_path) + executable_path.chmod(executable_path.stat().st_mode | 0o755) + + info_path = contents_dir / "Info.plist" + info_path.write_bytes(plistlib.dumps(_launchd_app_wrapper_info(), sort_keys=False)) + source_info_path = resources_dir / MACOS_APP_WRAPPER_SOURCE_INFO + source_info_path.write_bytes( + plistlib.dumps(_launchd_app_wrapper_source_info(source_python), sort_keys=False) + ) + + subprocess.run( + ["codesign", "--force", "--deep", "--sign", "-", str(staging_app_path)], + check=True, + timeout=30, + ) + subprocess.run( + ["codesign", "--verify", "--deep", "--strict", str(staging_app_path)], + check=True, + timeout=30, + ) + smoke_env = os.environ.copy() + smoke_env.update( + { + "PYTHONHOME": str(_python_home_from_path(source_python, detected_venv)), + "PYTHONPATH": _launchd_app_wrapper_pythonpath(detected_venv), + "PYTHONEXECUTABLE": _launchd_child_python_path(detected_venv), + "VIRTUAL_ENV": str(detected_venv) if detected_venv else "", + "HERMES_HOME": str(get_hermes_home().resolve()), + MACOS_APP_WRAPPER_ENV_KEY: "1", + } + ) + if detected_venv: + smoke_env["PATH"] = f"{detected_venv / 'bin'}:{smoke_env.get('PATH', '')}" + subprocess.run( + [str(executable_path), "-c", "import encodings, sys; print(sys.executable)"], + check=True, + capture_output=True, + text=True, + timeout=30, + env=smoke_env, + ) + + backup_path = app_parent / f".{app_path.name}.previous" + if backup_path.exists(): + shutil.rmtree(backup_path) + try: + if app_path.exists(): + app_path.rename(backup_path) + staging_app_path.rename(app_path) + except Exception: + if not app_path.exists() and backup_path.exists(): + backup_path.rename(app_path) + raise + finally: + if backup_path.exists(): + shutil.rmtree(backup_path) + return app_path + + +def _installed_launchd_plist_uses_app_wrapper(plist_path: Path | None = None) -> bool: + """Return True when an installed launchd plist already targets the app wrapper.""" + path = plist_path or get_launchd_plist_path() + if not path.exists(): + return False + try: + data = plistlib.loads(path.read_bytes()) + except Exception: + return False + if not isinstance(data, dict): + return False + env = data.get("EnvironmentVariables") or {} + if env.get(MACOS_APP_WRAPPER_ENV_KEY) == "1": + return True + program = data.get("Program") + args = data.get("ProgramArguments") or [] + first_arg = args[0] if args else None + app_exe = str(get_launchd_app_wrapper_executable_path()) + return program == app_exe or first_arg == app_exe + + +def _resolve_launchd_app_wrapper_mode(app_wrapper: bool | None = None) -> bool: + """Resolve target app-wrapper mode, preserving existing wrapper installs.""" + if app_wrapper is not None: + return app_wrapper + return _installed_launchd_plist_uses_app_wrapper() + + +def _drop_launchd_app_wrapper_python_env() -> None: + """Remove wrapper-only Python startup env so child processes inherit clean env.""" + if os.environ.get(MACOS_APP_WRAPPER_ENV_KEY) == "1": + os.environ.pop("PYTHONHOME", None) + os.environ.pop("PYTHONPATH", None) + os.environ.pop("PYTHONEXECUTABLE", None) + + def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. @@ -3327,8 +3601,13 @@ def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True) return False -def generate_launchd_plist() -> str: - python_path = get_python_path() +def generate_launchd_plist(app_wrapper: bool = False) -> str: + detected_venv = _detect_venv_dir() + python_path = ( + str(get_launchd_app_wrapper_executable_path()) + if app_wrapper + else get_python_path() + ) # Stable cwd anchor — never the volatile source checkout. See # _stable_service_working_dir() for the rationale (same rot risk applies # to launchd's WorkingDirectory as to systemd's). @@ -3343,7 +3622,6 @@ def generate_launchd_plist() -> str: # nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching # the systemd unit), then capture the user's full shell PATH so every # user-installed tool (node, ffmpeg, …) is reachable. - detected_venv = _detect_venv_dir() venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") # Resolve the directory containing the node binary (e.g. Homebrew, nvm) # so it's explicitly in PATH even if the user's shell PATH changes later. @@ -3359,85 +3637,59 @@ def generate_launchd_plist() -> str: ) ) + environment = { + "PATH": sane_path, + "VIRTUAL_ENV": venv_dir, + "HERMES_HOME": hermes_home, + } + if app_wrapper: + environment.update( + { + "PYTHONHOME": str(_python_home_from_path(get_python_path(), detected_venv)), + "PYTHONPATH": _launchd_app_wrapper_pythonpath(detected_venv), + "PYTHONEXECUTABLE": _launchd_child_python_path(detected_venv), + MACOS_APP_WRAPPER_ENV_KEY: "1", + } + ) + # Build ProgramArguments array, including --profile when using a named profile - prog_args = [ - f"{python_path}", - "-m", - "hermes_cli.main", - ] + prog_args = [python_path, "-m", "hermes_cli.main"] if profile_arg: - for part in profile_arg.split(): - prog_args.append(f"{part}") - prog_args.extend( - [ - "gateway", - "run", - "--replace", - ] - ) - prog_args_xml = "\n ".join(prog_args) - - return f""" - - - - Label - {label} - - ProgramArguments - - {prog_args_xml} - - - WorkingDirectory - {working_dir} - - EnvironmentVariables - - PATH - {sane_path} - VIRTUAL_ENV - {venv_dir} - HERMES_HOME - {hermes_home} - - - LimitLoadToSessionType - - Aqua - Background - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - {log_dir}/gateway.log - - StandardErrorPath - {log_dir}/gateway.error.log - - -""" + prog_args.extend(profile_arg.split()) + prog_args.extend(["gateway", "run", "--replace"]) + + plist_data = { + "Label": label, + "ProgramArguments": prog_args, + "WorkingDirectory": working_dir, + "EnvironmentVariables": environment, + "LimitLoadToSessionType": ["Aqua", "Background"], + "RunAtLoad": True, + "KeepAlive": True, + "StandardOutPath": f"{log_dir}/gateway.log", + "StandardErrorPath": f"{log_dir}/gateway.error.log", + } + if app_wrapper: + plist_data["AssociatedBundleIdentifiers"] = get_launchd_bundle_identifier() + return plistlib.dumps(plist_data, sort_keys=False).decode("utf-8") -def launchd_plist_is_current() -> bool: + +def launchd_plist_is_current(app_wrapper: bool | None = None) -> bool: """Check if the installed launchd plist matches the currently generated one.""" plist_path = get_launchd_plist_path() if not plist_path.exists(): return False + target_app_wrapper = _resolve_launchd_app_wrapper_mode(app_wrapper) + if target_app_wrapper and not launchd_app_wrapper_is_current(): + return False installed = plist_path.read_text(encoding="utf-8") - expected = generate_launchd_plist() - return _normalize_launchd_plist_for_comparison( - installed - ) == _normalize_launchd_plist_for_comparison(expected) + expected = generate_launchd_plist(app_wrapper=target_app_wrapper) + return _normalize_launchd_plist_for_comparison(installed) == _normalize_launchd_plist_for_comparison(expected) -def refresh_launchd_plist_if_needed() -> bool: +def refresh_launchd_plist_if_needed(app_wrapper: bool | None = None) -> bool: """Rewrite the installed launchd plist when the generated definition has changed. Unlike systemd, launchd picks up plist changes on the next ``launchctl kill``/ @@ -3445,10 +3697,17 @@ def refresh_launchd_plist_if_needed() -> bool: bootstrap to make launchd re-read the updated plist immediately. """ plist_path = get_launchd_plist_path() - if not plist_path.exists() or launchd_plist_is_current(): + if not plist_path.exists(): return False - new_plist = generate_launchd_plist() + target_app_wrapper = _resolve_launchd_app_wrapper_mode(app_wrapper) + if target_app_wrapper and not launchd_app_wrapper_is_current(): + install_launchd_app_wrapper(force=True) + + if launchd_plist_is_current(app_wrapper=target_app_wrapper): + return False + + new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) if _refuse_temp_home_service_write(new_plist, "launchd plist"): return False @@ -3517,25 +3776,35 @@ def refresh_launchd_plist_if_needed() -> bool: return True -def launchd_install(force: bool = False): +def launchd_install(force: bool = False, app_wrapper: bool = False): plist_path = get_launchd_plist_path() + target_app_wrapper = app_wrapper or ( + plist_path.exists() and not force and _installed_launchd_plist_uses_app_wrapper(plist_path) + ) + + if target_app_wrapper: + install_launchd_app_wrapper(force=force or not launchd_app_wrapper_is_current()) if plist_path.exists() and not force: - if not launchd_plist_is_current(): + if not launchd_plist_is_current(app_wrapper=target_app_wrapper): print(f"↻ Repairing outdated launchd service at: {plist_path}") - refresh_launchd_plist_if_needed() + refresh_launchd_plist_if_needed(app_wrapper=target_app_wrapper) print("✓ Service definition updated") return print(f"Service already installed at: {plist_path}") + if target_app_wrapper: + print(f"Using macOS app wrapper: {get_launchd_app_wrapper_path()}") print("Use --force to reinstall") return plist_path.parent.mkdir(parents=True, exist_ok=True) - new_plist = generate_launchd_plist() + new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) if _refuse_temp_home_service_write(new_plist, "launchd plist"): return print(f"Installing launchd service to: {plist_path}") - plist_path.write_text(new_plist) + if target_app_wrapper: + print(f"Using macOS app wrapper: {get_launchd_app_wrapper_path()}") + plist_path.write_text(new_plist, encoding="utf-8") try: subprocess.run( @@ -3723,6 +3992,8 @@ def _wait_for_gateway_exit( def launchd_restart(): + if _installed_launchd_plist_uses_app_wrapper() and not launchd_app_wrapper_is_current(): + install_launchd_app_wrapper(force=True) label = get_launchd_label() target = f"{_launchd_domain()}/{label}" drain_timeout = _get_restart_drain_timeout() @@ -4048,6 +4319,8 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo _guard_existing_gateway_process_conflict(replace=replace) sys.path.insert(0, str(PROJECT_ROOT)) + _drop_launchd_app_wrapper_python_env() + # Detached Windows gateway runs must ignore console-control broadcasts # from sibling CLI processes, but foreground `hermes gateway run` still # needs to obey the banner's "Press Ctrl+C to stop" contract. @@ -6607,6 +6880,10 @@ def _gateway_command_inner(args): force = getattr(args, "force", False) system = getattr(args, "system", False) run_as_user = getattr(args, "run_as_user", None) + macos_app_wrapper = getattr(args, "macos_app_wrapper", False) + if macos_app_wrapper and not is_macos(): + print_error("--macos-app-wrapper is only supported on macOS launchd services") + sys.exit(1) if is_termux(): print("Gateway service installation is not supported on Termux.") print("Run manually: hermes gateway") @@ -6634,7 +6911,7 @@ def _gateway_command_inner(args): if start_now: systemd_start(system=system) elif is_macos(): - launchd_install(force) + launchd_install(force=force, app_wrapper=macos_app_wrapper) elif is_windows(): from hermes_cli import gateway_windows diff --git a/hermes_cli/subcommands/gateway.py b/hermes_cli/subcommands/gateway.py index 9eef316ece030..00e29341ccdc3 100644 --- a/hermes_cli/subcommands/gateway.py +++ b/hermes_cli/subcommands/gateway.py @@ -197,6 +197,13 @@ def build_gateway_parser( help=argparse.SUPPRESS, ) + gateway_install.add_argument( + "--macos-app-wrapper", + dest="macos_app_wrapper", + action="store_true", + help="macOS only: run launchd through a Hermes Agent.app wrapper so privacy prompts show Hermes instead of python", + ) + # gateway uninstall gateway_uninstall = gateway_subparsers.add_parser( "uninstall", help="Uninstall gateway service" diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 6dd504a5f709d..601b03a498550 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1,6 +1,7 @@ """Tests for gateway service management helpers.""" import os +import plistlib import subprocess from pathlib import Path from types import SimpleNamespace @@ -549,6 +550,245 @@ def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch): assert kill_calls == [False] +class TestLaunchdMacOSAppWrapper: + def test_generate_launchd_plist_with_app_wrapper_uses_named_bundle_executable(self, tmp_path, monkeypatch): + home = tmp_path / "home" + repo = tmp_path / "repo" + venv = repo / ".venv" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_text("python", encoding="utf-8") + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "python").write_text("venv-python", encoding="utf-8") + (venv / "lib" / "python3.13" / "site-packages").mkdir(parents=True) + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist(app_wrapper=True).encode("utf-8")) + + app_exe = home / "macos" / "Hermes Agent.app" / "Contents" / "MacOS" / "Hermes Agent" + assert plist["ProgramArguments"][:3] == [str(app_exe), "-m", "hermes_cli.main"] + assert plist["AssociatedBundleIdentifiers"] == "ai.hermes.gateway" + env = plist["EnvironmentVariables"] + assert env["PYTHONHOME"] == str(python_home) + assert env["PYTHONEXECUTABLE"] == str(venv / "bin" / "python") + assert str(venv / "lib" / "python3.13" / "site-packages") in env["PYTHONPATH"].split(":") + assert str(repo) in env["PYTHONPATH"].split(":") + + def test_generate_launchd_plist_with_app_wrapper_uses_pyvenv_home_for_copy_venvs(self, tmp_path, monkeypatch): + home = tmp_path / "home" + repo = tmp_path / "repo" + venv = repo / ".venv" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = venv / "bin" / "python" + source_python.parent.mkdir(parents=True) + source_python.write_text("copied-python", encoding="utf-8") + (venv / "lib" / "python3.13" / "site-packages").mkdir(parents=True) + (venv / "pyvenv.cfg").write_text(f"home = {python_home / 'bin'}\n", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist(app_wrapper=True).encode("utf-8")) + + assert plist["EnvironmentVariables"]["PYTHONHOME"] == str(python_home) + + def test_install_launchd_app_wrapper_copies_python_and_writes_bundle_metadata(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + app_path = gateway_cli.install_launchd_app_wrapper(force=True) + + app_exe = app_path / "Contents" / "MacOS" / "Hermes Agent" + info = plistlib.loads((app_path / "Contents" / "Info.plist").read_bytes()) + assert app_path == home / "macos" / "Hermes Agent.app" + assert app_exe.read_bytes() == b"fake-macho-python" + assert info["CFBundleIdentifier"] == "ai.hermes.gateway" + assert info["CFBundleDisplayName"] == "Hermes Agent" + assert gateway_cli.launchd_app_wrapper_is_current() is True + assert any(cmd[:5] == ["codesign", "--force", "--deep", "--sign", "-"] for cmd in calls) + assert any(cmd[:4] == ["codesign", "--verify", "--deep", "--strict"] for cmd in calls) + assert any(cmd[1:2] == ["-c"] and Path(cmd[0]).name == "Hermes Agent" for cmd in calls) + + def test_install_launchd_app_wrapper_keeps_existing_bundle_when_validation_fails(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"new-python") + + app_exe = home / "macos" / "Hermes Agent.app" / "Contents" / "MacOS" / "Hermes Agent" + app_exe.parent.mkdir(parents=True) + app_exe.write_bytes(b"old-python") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + + def fail_codesign(cmd, **kwargs): + if cmd[:2] == ["codesign", "--force"]: + raise gateway_cli.subprocess.CalledProcessError(1, cmd, stderr="sign failed") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fail_codesign) + + with pytest.raises(gateway_cli.subprocess.CalledProcessError): + gateway_cli.install_launchd_app_wrapper(force=True) + + assert app_exe.read_bytes() == b"old-python" + + def test_launchd_app_wrapper_current_survives_codesign_binary_mutation(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + + def fake_codesign(cmd, **kwargs): + if cmd and cmd[0] == "codesign": + target_app = Path(cmd[-1]) + app_exe = target_app / "Contents" / "MacOS" / "Hermes Agent" + app_exe.write_bytes(app_exe.read_bytes() + b"-signed") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_codesign) + + gateway_cli.install_launchd_app_wrapper(force=True) + + assert gateway_cli.launchd_app_wrapper_is_current() is True + + def test_launchd_app_wrapper_current_requires_valid_codesign_verification(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + gateway_cli.install_launchd_app_wrapper(force=True) + + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="", stderr="invalid signature"), + ) + + assert gateway_cli.launchd_app_wrapper_is_current() is False + + def test_launchd_plist_is_stale_when_app_wrapper_bundle_missing(self, tmp_path, monkeypatch): + home = tmp_path / "home" + plist_path = tmp_path / "ai.hermes.gateway.plist" + app_exe = home / "macos" / "Hermes Agent.app" / "Contents" / "MacOS" / "Hermes Agent" + plist_path.write_text( + plistlib.dumps( + { + "Label": "ai.hermes.gateway", + "ProgramArguments": [str(app_exe), "-m", "hermes_cli.main", "gateway", "run", "--replace"], + "EnvironmentVariables": {"HERMES_LAUNCHD_APP_WRAPPER": "1"}, + } + ).decode("utf-8"), + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + + assert gateway_cli.launchd_plist_is_current() is False + + def test_drop_launchd_app_wrapper_python_env_removes_runtime_overrides(self, monkeypatch): + monkeypatch.setenv("HERMES_LAUNCHD_APP_WRAPPER", "1") + monkeypatch.setenv("PYTHONHOME", "/private/hermes-python") + monkeypatch.setenv("PYTHONPATH", "/private/hermes-site") + monkeypatch.setenv("PYTHONEXECUTABLE", "/private/hermes-venv/bin/python") + + gateway_cli._drop_launchd_app_wrapper_python_env() + + assert "PYTHONHOME" not in os.environ + assert "PYTHONPATH" not in os.environ + assert "PYTHONEXECUTABLE" not in os.environ + + def test_refresh_launchd_plist_preserves_existing_app_wrapper_mode(self, tmp_path, monkeypatch): + home = tmp_path / "home" + repo = tmp_path / "repo" + venv = repo / ".venv" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + (venv / "bin").mkdir(parents=True) + (venv / "lib" / "python3.13" / "site-packages").mkdir(parents=True) + plist_path = tmp_path / "ai.hermes.gateway.plist" + app_exe = home / "macos" / "Hermes Agent.app" / "Contents" / "MacOS" / "Hermes Agent" + plist_path.write_text( + plistlib.dumps( + { + "Label": "ai.hermes.gateway", + "ProgramArguments": [str(app_exe), "-m", "hermes_cli.main", "gateway", "run", "--replace"], + "EnvironmentVariables": {"HERMES_LAUNCHD_APP_WRAPPER": "1"}, + } + ).decode("utf-8"), + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + assert gateway_cli.refresh_launchd_plist_if_needed() is True + + refreshed = plistlib.loads(plist_path.read_bytes()) + assert refreshed["ProgramArguments"][0] == str(app_exe) + assert refreshed["EnvironmentVariables"]["HERMES_LAUNCHD_APP_WRAPPER"] == "1" + assert (home / "macos" / "Hermes Agent.app" / "Contents" / "MacOS" / "Hermes Agent").exists() + + class TestLaunchdServiceRecovery: def test_get_restart_drain_timeout_prefers_env_then_config_then_default(self, monkeypatch): monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False) From 00ebca5a3c2862dc6f51eb06235f8941349a6d35 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Sat, 16 May 2026 15:37:35 +0200 Subject: [PATCH 10/28] fix: enable GPT-5.5 priority processing fast mode Add gpt-5.5 to the OpenAI Priority Processing whitelist used by /fast and cover GPT-5.5 in gateway fast-command regression tests while retaining GPT-5.4 coverage.\n\nVerified: tests/gateway/test_fast_command.py and tests/gateway/test_running_agent_session_toggles.py pass (9 passed, 1 warning). Independent review passed with no security concerns or logic errors. --- tests/gateway/test_fast_command.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index c100009400700..fc7f1fa65b7b4 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -30,6 +30,7 @@ def run_conversation( task_id=None, persist_user_message=None, persist_user_timestamp=None, + **kwargs, ): type(self).last_run = { "user_message": user_message, @@ -37,6 +38,7 @@ def run_conversation( "task_id": task_id, "persist_user_message": persist_user_message, "persist_user_timestamp": persist_user_timestamp, + "extra_kwargs": kwargs, } return { "final_response": "ok", @@ -91,6 +93,13 @@ def _make_event(text: str) -> MessageEvent: return MessageEvent(text=text, source=_make_source(), message_id="m1") +def test_openai_priority_processing_models_include_current_gpt5_releases(): + from hermes_cli.models import resolve_fast_mode_overrides + + assert resolve_fast_mode_overrides("gpt-5.5") == {"service_tier": "priority"} + assert resolve_fast_mode_overrides("gpt-5.4") == {"service_tier": "priority"} + + def test_turn_route_injects_priority_processing_without_changing_runtime(): runner = _make_runner() runner._service_tier = "priority" @@ -104,7 +113,7 @@ def test_turn_route_injects_priority_processing_without_changing_runtime(): "credential_pool": None, } - route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs) + route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.5", runtime_kwargs) assert route["runtime"]["provider"] == "openrouter" assert route["runtime"]["api_mode"] == "chat_completions" @@ -135,7 +144,7 @@ async def test_handle_fast_command_persists_config(monkeypatch, tmp_path): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) - monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.5") response = await runner._handle_fast_command(_make_event("/fast fast")) @@ -165,7 +174,7 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch "_load_gateway_runtime_config", lambda: {"agent": {"service_tier": "fast"}}, ) - monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.5") monkeypatch.setattr( gateway_run, "_resolve_runtime_agent_kwargs", From 68a16944f26ac131bbb66c38dd92eb1535556c4b Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 18 May 2026 03:51:58 +0200 Subject: [PATCH 11/28] fix(gateway): keep macOS launchd runtime paths logical Consolidates these related Amy fork patches: - 9b6df28be fix(gateway): include Amy bin paths in macOS launchd PATH - c1b50030d fix(gateway): defer macOS launchd reloads from gateway process - 86a326fbe fix(gateway): preserve logical amy home in launchd - 6271302ba fix(gateway): drop macOS launchd legacy root paths - d1616cac4 fix(gateway): keep launchd Hermes paths logical --- RELEASE_amy-patches.md | 220 +++++++++++- hermes_cli/gateway.py | 372 ++++++++++++++------ tests/hermes_cli/test_gateway_service.py | 418 +++++++++++++++++++++-- 3 files changed, 888 insertions(+), 122 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 5064745fc06e1..84b2aadbc9cae 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,4 +1,194 @@ -# Amy's Patches — Changelog (Branch: amy/patches) +# Amy's Patches - Changelog (Branch: amy/patches) + +**Current base:** Hermes Agent v2026.6.19 +**Current patch stack:** Rebased Amy/private patch stack after launchd restart reload-pending fix +**Current reconciliation reviewed through:** v0.17.0 rebase in progress; upstream-absorbed patches dropped, Amy-private patches retained +**Author:** Amy Ravenwolf + +> Current goal: keep only Amy/private patches local, and submit every generally useful feature/fix upstream as an open PR so future upgrades have less custom patch baggage. + +--- + +## 2026-06-21 - Launchd Restart Consumes Deferred Plist Reloads + +**Problem:** During an in-gateway launchd plist refresh, Hermes writes a +`.reload-pending` marker instead of booting itself out from under its own ass. +`hermes gateway start` consumed that marker, but `hermes gateway restart` still +used a plain `launchctl kickstart -k`. That could restart the old loaded launchd +job without re-reading the new plist, leaving ProgramArguments, PATH, HERMES_HOME, +or macOS app-wrapper changes stale after an upgrade. + +**Solution:** `launchd_restart()` now detects pending or stale launchd service +definitions, rewrites stale plist files before any reload, skips self-requested +restart in that case, drains the running gateway, performs `bootout` + `bootstrap` +to make launchd re-read the plist, then kickstarts the refreshed job. +`launchd_status()` also reports the pending marker so the operator sees the exact +fix command. + +**Affected files:** + +- `hermes_cli/gateway.py` +- `tests/hermes_cli/test_gateway_service.py` +- `RELEASE_amy-patches.md` + +**Verification:** `tests/hermes_cli/test_gateway_service.py` covers pending-marker +restart consumption, stale-plist rewrite before bootstrap, self-request suppression +when a plist reload is pending, status visibility, app-wrapper preservation, and +existing launchd recovery paths. + +**Session reference:** 2026-06-21 Hermes v0.17.0 upgrade focused review found the +restart-blocking stale-launchd-state bug before live restart. + +--- + +## 2026-06-21 - Codex Auxiliary Timeout Test Determinism + +**Problem:** `TestCodexAuxiliaryAdapterTimeout` used real `time.sleep(0.03)` +wall-clock timing and asserted the full call completed under 0.14s. On the Mac +mini test environment the first scheduled sleep can exceed that bound even when +the adapter aborts on the first timeout check, producing a false failure during +Hermes upgrade verification. + +**Solution:** Replaced the wall-clock sleep with a deterministic fake monotonic +clock that advances per emitted event. The test now asserts the adapter stops +after the second event when the synthetic deadline is crossed, preserving the +semantic regression coverage without relying on scheduler timing. + +**Affected files:** + +- `tests/agent/test_auxiliary_client.py` +- `RELEASE_amy-patches.md` + +**Verification:** +`tests/agent/test_auxiliary_client.py::TestCodexAuxiliaryAdapterTimeout::test_enforces_total_timeout_while_stream_keeps_emitting_events`. + +**Session reference:** 2026-06-21 Hermes v0.17.0 upgrade verification on Mac mini. + +--- + +## 2026-06-21 - send_message Messaging Toolset Opt-In + +**Problem:** Hermes Agent v0.17.0 deliberately removed the agent-callable +`send_message` registry entry so outbound cross-platform messaging is no longer +part of the broad default/core toolsets. For Amy's trusted single-owner runtime, +Wolfram still wants the capability available when explicitly enabled, while not +silently restoring it to every default toolset. + +**Solution:** Re-registered `send_message` locally under an explicit +`messaging` toolset. This keeps the upstream safety posture for broad/default +Hermes toolsets, but lets Amy opt in via `platform_toolsets` with +`messaging` (for example alongside `hermes-cli`). The shared send engine remains +unchanged for cron delivery, `hermes send`, the gateway kanban notifier, and MCP. + +**Affected files:** + +- `tools/send_message_tool.py` +- `tests/tools/test_send_message_tool.py` +- `RELEASE_amy-patches.md` + +**Verification:** Targeted registry regression in +`tests/tools/test_send_message_tool.py::test_send_message_registered_as_explicit_messaging_toolset`. + +**Session reference:** 2026-06-21 Mattermost Hermes v0.17.0 upgrade/rebase; +Wolfram approved Amy's "best local solution" for `send_message`. + +--- + +## 2026-06-17 - Mattermost Configurable Post-Length Limit + +**Problem:** Long Amy/Hermes replies in Mattermost were split into multiple thread replies at Hermes' hard-coded 4,000-character adapter limit, even though Mattermost 5+ supports up to 16,383 characters per post. This forced Wolfram to click `Show more` once per chunk instead of once per long reply and made Mattermost threads noisier than necessary. + +**Solution:** Added a configurable Mattermost post chunk limit via `mattermost.max_post_length` / `MATTERMOST_MAX_POST_LENGTH`, clamped to Mattermost's 16,383-character hard cap, with too-small values falling back to the legacy 4,000-character default so the generic chunker cannot hang on impossible limits. The adapter now exposes the effective value through `MAX_MESSAGE_LENGTH` so final replies, streaming, and progress sizing use the same limit. Direct `send_message`/cron delivery resolves the configured Mattermost limit before chunking. Deployments can leave a safety margin below the hard cap for Markdown/code-fence handling and part indicators. + +**Affected files:** + +- `plugins/platforms/mattermost/adapter.py` +- `tools/send_message_tool.py` +- `hermes_cli/config.py` +- `tests/gateway/test_mattermost.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: new Mattermost max-post-length tests failed before implementation (`5 failed, 4 passed`). +- GREEN: `HERMES_HOME=/amy .venv/bin/python -m pytest tests/gateway/test_mattermost.py -q -o 'addopts='` -> `70 passed`. +- Config bridge check: `load_gateway_config()` seeded `extra.max_post_length=16000`, `MATTERMOST_MAX_POST_LENGTH=16000`, and `MattermostAdapter.MAX_MESSAGE_LENGTH=16000`. + +**Session reference:** 2026-06-17 Mattermost thread on reducing long-post splitting and repeated `Show more` clicks. + +--- +## Current Upstream PR Reconciliation (2026-06-08) + +Counted with `git describe --tags --abbrev=0 HEAD` and `git rev-list --count v2026.6.5..HEAD`. +Do not count against stale `main`; tag-to-HEAD is the authoritative patch stack. + +| Commit | Subject | Upstream disposition | +|---|---|---| +| `d843d29be` | `fix: WhatsApp voice messages + bridge audio download + npm deps` | Public bugfix - submitted as [PR #41616](https://github.com/NousResearch/hermes-agent/pull/41616). | +| `9df07dd98` | `feat(tool_progress): add 'full' mode - unlimited tool args in gateway chat` | Public UX/config feature - submitted as [PR #41617](https://github.com/NousResearch/hermes-agent/pull/41617). | +| `344e1c0ae` | `feat(prompt): add Amy platform hints and Mattermost Private Assistant default` | Private Amy/persona patch - do not upstream. | +| `2fcdbd133` | `feat(memory): configurable background memory update notifications` | Public feature - existing [PR #4684](https://github.com/NousResearch/hermes-agent/pull/4684) updated. | +| `0edd4de6e` | `feat(display): add independent thinking_progress config option` | Public feature - existing [PR #4512](https://github.com/NousResearch/hermes-agent/pull/4512) updated. | +| `789c20c90` | `docs: add Amy's patches changelog for v0.6.0 fork` | Private fork documentation - do not upstream. | +| `968562f7c` | `feat(display): show delegate_task goals in tool progress notifications` | Public UX feature - submitted as [PR #41618](https://github.com/NousResearch/hermes-agent/pull/41618). | +| `4c7cab0c5` | `feat(display): verbose skill change notifications with content previews` | Public notification feature - folded into updated [PR #4684](https://github.com/NousResearch/hermes-agent/pull/4684). | +| `191c334f0` | `feat(prompt): make context-file truncation limit configurable` | Public config/observability feature - submitted as [PR #41619](https://github.com/NousResearch/hermes-agent/pull/41619); upstream keeps the 20K default while users can raise `context_file_max_chars`. | +| `8e6cd69c5` | `feat(hooks): session:compress event_callback for MemPalace sync` | Public hook/event feature - generalized and submitted as [PR #41624](https://github.com/NousResearch/hermes-agent/pull/41624). | +| `4e89d4a95` | `feat: add tool_progress_style config (accumulate vs separate)` | Public UX/config feature - submitted as [PR #41620](https://github.com/NousResearch/hermes-agent/pull/41620). | +| `76f7f07f3` | `fix: suppress pkg_resources deprecation warning from lark_oapi` | Public startup-noise fix - submitted as [PR #41621](https://github.com/NousResearch/hermes-agent/pull/41621). | +| `bcac7fbd9` | `fix(addon): make dashboard assets work behind HA ingress` | Public reverse-proxy/dashboard bugfix - submitted as [PR #41629](https://github.com/NousResearch/hermes-agent/pull/41629). | +| `e7c573aeb` | `feat(vision): add provider-safe inject_image tool` | Public multimodal/tool feature - submitted as [PR #41632](https://github.com/NousResearch/hermes-agent/pull/41632). | +| `e33e1a30b` | `fix(config): read browser inactivity timeout from config` | Public config bugfix - submitted as [PR #41623](https://github.com/NousResearch/hermes-agent/pull/41623). | +| `d45f99345` | `feat(moa): route experts through provider-aware clients` | Public provider-routing feature/fix - submitted as [PR #41626](https://github.com/NousResearch/hermes-agent/pull/41626). | +| `b071671aa` | `feat(gateway): inject stable human-readable message timestamps` | Public temporal-context feature - made opt-in/default-off and submitted as [PR #41633](https://github.com/NousResearch/hermes-agent/pull/41633). | +| `1b3657a86` | `feat(gateway): add macOS app-wrapper launchd identity` | Public macOS/TCC feature - submitted with logical-path hardening as [PR #41635](https://github.com/NousResearch/hermes-agent/pull/41635). | +| `53a13a742` | `fix: enable GPT-5.5 priority processing fast mode` | Upstream code path was already current; regression coverage submitted as [PR #41628](https://github.com/NousResearch/hermes-agent/pull/41628). | +| `ead67e583` | `fix(gateway): keep macOS launchd runtime paths logical` | Public macOS launchd hardening - generalized and folded into [PR #41635](https://github.com/NousResearch/hermes-agent/pull/41635). | +| `22de314a6` | `fix(state): skip redundant trigram backfill before v11 FTS rebuild` | Public performance/migration fix - submitted as [PR #41622](https://github.com/NousResearch/hermes-agent/pull/41622). | +| `68306dd6d` | `fix(deps): restore CVE-fixed pyproject pins` | Public dependency/security fix - submitted as [PR #41641](https://github.com/NousResearch/hermes-agent/pull/41641). | +| `cc097a495` | `feat(status): restore model and context in gateway status` | Public feature - existing [PR #4678](https://github.com/NousResearch/hermes-agent/pull/4678) updated; overlaps open #8355/#16079. | +| `327edcb58` | `feat(resume): restore cross-platform full session listing` | Public feature - existing [PR #4689](https://github.com/NousResearch/hermes-agent/pull/4689) updated. | +| `2cfb721d6` | `fix(mattermost): keep plugin sends in threads` | Public Mattermost bugfix - submitted with delivery hygiene as [PR #41640](https://github.com/NousResearch/hermes-agent/pull/41640). | +| `81f297b52` | `fix(mattermost): harden delivery hygiene` | Public Mattermost safety/delivery bugfix - submitted with thread routing as [PR #41640](https://github.com/NousResearch/hermes-agent/pull/41640). | +| `5d5ac2f9e` | `docs(patches): update upstream PR reconciliation` | Private fork documentation - do not upstream. | +| `72f882cc2` | `fix(display): preserve generic skill patch notifications` | Public follow-up fix - folded into updated [PR #4684](https://github.com/NousResearch/hermes-agent/pull/4684). | + +### Existing PRs Updated + +| PR | Branch | Local replacement commit(s) | Result | +|---|---|---|---| +| [#4512](https://github.com/NousResearch/hermes-agent/pull/4512) | `feat/thinking-progress` | `0edd4de6e` | Rebuilt on current `upstream/main`, force-pushed, and PR body updated. | +| [#4678](https://github.com/NousResearch/hermes-agent/pull/4678) | `feat/status-model-context` | `cc097a495` | Rebuilt on current `upstream/main`, force-pushed, and PR body updated; overlaps still-open #8355/#16079. | +| [#4684](https://github.com/NousResearch/hermes-agent/pull/4684) | `feat/memory-notifications` | `2fcdbd133`, `4c7cab0c5`, `72f882cc2` | Rebuilt on current `upstream/main`, force-pushed, and PR body updated; skill-change notification follow-up included. | +| [#4689](https://github.com/NousResearch/hermes-agent/pull/4689) | `feat/resume-cross-platform` | `327edcb58` | Rebuilt on current `upstream/main`, force-pushed, and PR body updated around `/resume --all`, `/resume --full`, and session-ID/prefix lookup. | + +### New PRs Submitted + +| PR | Branch | Local commit(s) | +|---|---|---| +| [#41616](https://github.com/NousResearch/hermes-agent/pull/41616) | `fix/whatsapp-voice-messages` | `d843d29be` | +| [#41617](https://github.com/NousResearch/hermes-agent/pull/41617) | `feat/tool-progress-full-mode` | `9df07dd98` | +| [#41618](https://github.com/NousResearch/hermes-agent/pull/41618) | `feat/delegate-task-progress-goals` | `968562f7c` | +| [#41619](https://github.com/NousResearch/hermes-agent/pull/41619) | `feat/context-file-truncation-warnings` | `191c334f0` | +| [#41620](https://github.com/NousResearch/hermes-agent/pull/41620) | `feat/tool-progress-style` | `4e89d4a95` | +| [#41621](https://github.com/NousResearch/hermes-agent/pull/41621) | `fix/lark-oapi-pkg-resources-warning` | `76f7f07f3` | +| [#41622](https://github.com/NousResearch/hermes-agent/pull/41622) | `fix/session-db-trigram-backfill-skip` | `22de314a6` | +| [#41623](https://github.com/NousResearch/hermes-agent/pull/41623) | `fix/browser-inactivity-timeout-config` | `e33e1a30b` | +| [#41624](https://github.com/NousResearch/hermes-agent/pull/41624) | `feat/session-compress-event-callback` | `8e6cd69c5` | +| [#41626](https://github.com/NousResearch/hermes-agent/pull/41626) | `feat/moa-provider-aware-clients` | `d45f99345` | +| [#41628](https://github.com/NousResearch/hermes-agent/pull/41628) | `test/gpt55-fast-mode-coverage` | `53a13a742` test coverage | +| [#41629](https://github.com/NousResearch/hermes-agent/pull/41629) | `fix/dashboard-assets-ingress` | `bcac7fbd9` | +| [#41632](https://github.com/NousResearch/hermes-agent/pull/41632) | `feat/provider-safe-inject-image-tool` | `e7c573aeb` | +| [#41633](https://github.com/NousResearch/hermes-agent/pull/41633) | `feat/gateway-message-timestamps` | `b071671aa` | +| [#41635](https://github.com/NousResearch/hermes-agent/pull/41635) | `feat/macos-launchd-app-wrapper` | `1b3657a86`, `ead67e583` | +| [#41640](https://github.com/NousResearch/hermes-agent/pull/41640) | `fix/mattermost-thread-delivery-hygiene` | `2cfb721d6`, `81f297b52` | +| [#41641](https://github.com/NousResearch/hermes-agent/pull/41641) | `fix/cve-dependency-pins` | `68306dd6d` | + +Private/local-only patches after reconciliation: Amy platform/persona hints (`344e1c0ae`), private fork patch documentation (`789c20c90`, `5d5ac2f9e`). + +--- + +## Historical v0.6.0 Patch Notes **Base:** Hermes Agent v0.6.0 (v2026.3.30) **Patch Period:** March 30–31, 2026 @@ -53,6 +243,34 @@ Platform-aware zipper mode defaults for Amy's persona: ## 🐛 Bug Fixes +### Gateway /status: Provider-Aware Idle Context Window (2026-05-19) + +`/status` shows model, context usage, and cumulative token labels. Its idle +fallback resolves the model context window provider-aware instead of using raw +`DEFAULT_CONTEXT_LENGTHS`, so `gpt-5.5` via `openai-codex` displays the real +272,000-token Codex OAuth window instead of the direct-OpenAI 1,050,000-token +window. The same path respects provider/base URL/custom-provider/context +overrides used by `/model` and compression. + +**Files:** `gateway/run.py`, `tests/gateway/test_status_command.py` + +**Verification:** `tests/gateway/test_status_command.py`, +`tests/hermes_cli/test_model_switch_context_display.py`. + +### macOS LaunchAgent Path Cleanup (2026-05-17) + +The Mac mini migration left the generated launchd `PATH` depending on legacy +root-level `/config` compatibility shims. Removed `/config/amy/bin` and +`/config/.go/bin` from the LaunchAgent path generator so native macOS runtime +startup uses canonical `/amy` / `HERMES_HOME` paths only, and filters inherited +`/config` / `/share` entries so stale shell environments cannot reintroduce the +old shims. + +**Files:** `hermes_cli/gateway.py`, `tests/hermes_cli/test_gateway_service.py` + +**Verification:** focused launchd PATH test, `tests/hermes_cli/test_gateway_service.py`, +and live LaunchAgent refresh after commit. + ### Session Search: Lazy DB Creation (`154785c3`) The `session_search` tool passed `db=None` to the search function when no pre-initialized `SessionDB` existed — silently returning zero results in cron jobs and background agents (memory flush). Direct SQL queries against `state.db` worked fine, confirming the issue was in tool initialization. Fixed by adding a `_session_search_handler` that lazily creates a `SessionDB` instance when none is provided. diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a8531df6785dc..dbe943377f2e0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3601,41 +3601,222 @@ def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True) return False +def _launchd_target() -> str: + return f"{_launchd_domain()}/{get_launchd_label()}" + + +def _launchd_reload_pending_path(plist_path: Path | None = None) -> Path: + """Return the marker path used when a launchd plist reload was deferred.""" + path = plist_path or get_launchd_plist_path() + return path.with_name(f"{path.name}.reload-pending") + + +def _mark_launchd_reload_pending(plist_path: Path | None = None) -> None: + marker = _launchd_reload_pending_path(plist_path) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("launchd plist reload pending\n", encoding="utf-8") + + +def _clear_launchd_reload_pending(plist_path: Path | None = None) -> None: + marker = _launchd_reload_pending_path(plist_path) + try: + marker.unlink() + except FileNotFoundError: + pass + + +def _launchd_reload_is_pending(plist_path: Path | None = None) -> bool: + return _launchd_reload_pending_path(plist_path).exists() + + +def _launchd_loaded_job_pid() -> int | None: + """Return the PID launchd has loaded for this gateway label, if known.""" + try: + result = subprocess.run( + ["launchctl", "print", _launchd_target()], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None + if result.returncode != 0: + return None + for line in (result.stdout or "").splitlines(): + key, sep, value = line.strip().partition("=") + if sep and key.strip().lower() == "pid": + try: + pid = int(value.strip().rstrip(";")) + except ValueError: + return None + return pid if pid > 0 else None + return None + + +def _reload_launchd_plist_now(plist_path: Path, label: str | None = None, *, check_bootstrap: bool = False) -> bool: + """Force launchd to re-read *plist_path* with bootout/bootstrap.""" + target = f"{_launchd_domain()}/{label or get_launchd_label()}" + subprocess.run(["launchctl", "bootout", target], check=False, timeout=90) + result = subprocess.run( + ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], + check=check_bootstrap, + timeout=30, + ) + if result.returncode == 0: + _clear_launchd_reload_pending(plist_path) + return True + print(f"⚠ launchd bootstrap failed with exit {result.returncode}; reload marker preserved") + return False + + +def _is_running_inside_gateway_process_tree() -> bool: + """Return True when this command is executing under the live gateway process. + + launchd ``bootout``/``kickstart -k`` kills the gateway process tree. When a + tool subprocess invokes service management from inside that same tree, doing + the reload inline can kill the command before it has a chance to bootstrap + the updated plist again. + """ + try: + from gateway.status import get_running_pid + + pid = get_running_pid() + except Exception: + return False + if pid is None or not _is_pid_ancestor_of_current_process(pid): + return False + if is_macos(): + launchd_pid = _launchd_loaded_job_pid() + if launchd_pid is not None and launchd_pid != pid: + return False + return True + + +def _launchd_path_entry_exists(path: str) -> bool: + """Return True when a PATH entry exists and can be inherited by launchd.""" + try: + return Path(path).exists() + except (OSError, ValueError): + return False + + +def _launchd_logical_hermes_path(path: str | Path | None) -> Path | None: + """Map physical paths under HERMES_HOME back to the configured logical home.""" + if path is None: + return None + hermes_home = get_hermes_home() + candidate = Path(path).expanduser() + candidate_paths = [candidate] + if not candidate.is_absolute(): + candidate_paths.append(candidate.resolve()) + try: + candidate_paths.append(candidate.resolve()) + except OSError: + pass + home_paths = [hermes_home] + try: + home_paths.append(hermes_home.resolve()) + except OSError: + pass + for candidate_path in candidate_paths: + for home_path in home_paths: + try: + relative = candidate_path.relative_to(home_path) + except ValueError: + continue + return hermes_home / relative + return candidate + + +def _launchd_should_keep_inherited_path_entry(path: str) -> bool: + """Return True for inherited PATH entries worth preserving in launchd.""" + entry = path.strip() + if not entry: + return False + lowered = entry.lower() + stale_markers = ( + "/.codex/tmp/", + "/codex.system/bootstrap/", + "/applications/codex.app/", + "/opt/pkg/env/", + "/opt/pmk/env/", + ) + if any(marker in lowered for marker in stale_markers): + return False + if entry == "/config" or entry.startswith("/config/"): + return False + if entry == "/share" or entry.startswith("/share/"): + return False + return _launchd_path_entry_exists(entry) + + +def _append_launchd_path_entry(entries: list[str], path: str | Path | None) -> None: + """Append an existing launchd PATH entry once, preserving order.""" + if path is None: + return + entry = str(path) + if _launchd_path_entry_exists(entry) and entry not in entries: + entries.append(entry) + + def generate_launchd_plist(app_wrapper: bool = False) -> str: detected_venv = _detect_venv_dir() + launchd_project_root = _launchd_logical_hermes_path(PROJECT_ROOT) or PROJECT_ROOT + launchd_venv = _launchd_logical_hermes_path(detected_venv) python_path = ( str(get_launchd_app_wrapper_executable_path()) if app_wrapper - else get_python_path() + else str(_launchd_logical_hermes_path(get_python_path()) or get_python_path()) ) - # Stable cwd anchor — never the volatile source checkout. See - # _stable_service_working_dir() for the rationale (same rot risk applies - # to launchd's WorkingDirectory as to systemd's). - working_dir = _stable_service_working_dir() - hermes_home = str(get_hermes_home().resolve()) + # Preserve the configured logical home in the launchd environment. On macOS + # `/amy` is a synthetic root link to the physical runtime directory; resolving + # it here would silently turn HERMES_HOME back into the physical user-home path. + hermes_home_path = get_hermes_home() + hermes_home = str(_launchd_logical_hermes_path(hermes_home_path) or hermes_home_path) + # Stable cwd anchor — prefer logical HERMES_HOME over the source checkout so + # launchd does not depend on a volatile worktree path. + working_dir = hermes_home if _launchd_path_entry_exists(hermes_home) else str(launchd_project_root) log_dir = get_hermes_home() / "logs" log_dir.mkdir(parents=True, exist_ok=True) label = get_launchd_label() profile_arg = _profile_arg(hermes_home) - # Build a sane PATH for the launchd plist. launchd provides only a - # minimal default (/usr/bin:/bin:/usr/sbin:/sbin) which misses Homebrew, - # nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching - # the systemd unit), then capture the user's full shell PATH so every - # user-installed tool (node, ffmpeg, …) is reachable. - venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + # Build a sane PATH for the launchd plist. launchd starts with only a + # minimal system PATH, while an interactive/dev environment may contain + # stale Codex/bootstrap entries. Prioritize Hermes' own venv and Amy-owned + # bin directories first, then keep only inherited entries that still exist. + venv_bin = str(launchd_venv / "bin") if launchd_venv else str(launchd_project_root / "venv" / "bin") + venv_dir = str(launchd_venv) if launchd_venv else str(launchd_project_root / "venv") + node_bin = str(launchd_project_root / "node_modules" / ".bin") + priority_dirs: list[str] = [] + for candidate in ( + venv_bin, + hermes_home_path / "bin", + "/amy/bin", + hermes_home_path / ".go" / "bin", + "/amy/.go/bin", + node_bin, + ): + _append_launchd_path_entry(priority_dirs, candidate) + + inherited_dirs = [ + p for p in os.environ.get("PATH", "").split(":") + if _launchd_should_keep_inherited_path_entry(p) + ] + # Resolve the directory containing the node binary (e.g. Homebrew, nvm) # so it's explicitly in PATH even if the user's shell PATH changes later. - priority_dirs = _build_service_path_dirs() + # Validate the resolved parent through the same stale-entry filter used for + # inherited PATH entries; otherwise a stale Codex/bootstrap node can sneak + # back into the launchd plist before the inherited PATH is filtered. resolved_node = shutil.which("node") if resolved_node: resolved_node_dir = str(Path(resolved_node).resolve().parent) - if resolved_node_dir not in priority_dirs: - priority_dirs.append(resolved_node_dir) - sane_path = ":".join( - dict.fromkeys( - priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p] - ) - ) + if _launchd_should_keep_inherited_path_entry(resolved_node_dir): + _append_launchd_path_entry(priority_dirs, resolved_node_dir) + + system_fallback_dirs = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + system_dirs = [p for p in system_fallback_dirs if _launchd_path_entry_exists(p)] + sane_path = ":".join(dict.fromkeys(priority_dirs + inherited_dirs + system_dirs)) environment = { "PATH": sane_path, @@ -3690,12 +3871,7 @@ def launchd_plist_is_current(app_wrapper: bool | None = None) -> bool: def refresh_launchd_plist_if_needed(app_wrapper: bool | None = None) -> bool: - """Rewrite the installed launchd plist when the generated definition has changed. - - Unlike systemd, launchd picks up plist changes on the next ``launchctl kill``/ - ``launchctl kickstart`` cycle — no daemon-reload is needed. We still bootout/ - bootstrap to make launchd re-read the updated plist immediately. - """ + """Rewrite/reload the installed launchd plist when its definition changed.""" plist_path = get_launchd_plist_path() if not plist_path.exists(): return False @@ -3704,75 +3880,31 @@ def refresh_launchd_plist_if_needed(app_wrapper: bool | None = None) -> bool: if target_app_wrapper and not launchd_app_wrapper_is_current(): install_launchd_app_wrapper(force=True) - if launchd_plist_is_current(app_wrapper=target_app_wrapper): - return False - - new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) - if _refuse_temp_home_service_write(new_plist, "launchd plist"): - return False - - plist_path.write_text(new_plist, encoding="utf-8") label = get_launchd_label() - domain = _launchd_domain() - target = f"{domain}/{label}" - - # If this refresh is running INSIDE the gateway's own launchd process tree - # (e.g. the agent triggered a self-update via its terminal tool), a direct - # `launchctl bootout` tears down the service's process group — which - # includes THIS CLI — before the follow-up `bootstrap` can run. The gateway - # then stays unloaded and KeepAlive can't revive it (#43842). Detect that - # case and hand the reload to a detached session that survives the bootout. - gateway_pid = None - try: - from gateway.status import get_running_pid - gateway_pid = get_running_pid() - except Exception: - gateway_pid = None + reload_pending = _launchd_reload_is_pending(plist_path) + plist_current = launchd_plist_is_current(app_wrapper=target_app_wrapper) + if plist_current and not reload_pending: + return False - if ( - gateway_pid is not None - and _is_pid_ancestor_of_current_process(gateway_pid) - and hasattr(os, "setsid") # POSIX-only; launchd is macOS so always true here - ): - # Delegate to a new session: `start_new_session=True` detaches the - # helper from the gateway's process group, so the bootout that kills - # the gateway (and us) does not kill the helper before it bootstraps. - reload_script = ( - f"sleep 2; " - f"launchctl bootout {shlex.quote(target)} 2>/dev/null; " - f"sleep 1; " - f"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null" - ) - try: - subprocess.Popen( - ["/bin/bash", "-c", reload_script], - start_new_session=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - logger.warning("Deferred launchd reload could not be spawned: %s", e) + if not plist_current: + new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) + if _refuse_temp_home_service_write(new_plist, "launchd plist"): return False - print( - "↻ Updated gateway launchd service definition; reload deferred to a " - "detached helper (refresh ran inside the gateway process tree)" - ) + plist_path.write_text(new_plist, encoding="utf-8") + reload_pending = True + + if _is_running_inside_gateway_process_tree(): + if reload_pending: + _mark_launchd_reload_pending(plist_path) + print("↻ Updated gateway launchd service definition to match the current Hermes install") + print("⚠ launchd reload deferred because this command is running inside the gateway process tree") + print(" Run 'hermes gateway start' from an external shell to make launchd re-read the plist") return True - # Bootout/bootstrap so launchd picks up the new definition - subprocess.run( - ["launchctl", "bootout", target], - check=False, - timeout=90, - ) - subprocess.run( - ["launchctl", "bootstrap", domain, str(plist_path)], - check=False, - timeout=30, - ) - print( - "↻ Updated gateway launchd service definition to match the current Hermes install" - ) + # Bootout/bootstrap so launchd picks up the new definition. This also + # consumes reload-pending markers from previous in-gateway refreshes. + _reload_launchd_plist_now(plist_path, label) + print("↻ Updated gateway launchd service definition to match the current Hermes install") return True @@ -3856,12 +3988,13 @@ def launchd_start(): print("↻ launchd plist missing; regenerating service definition") plist_path.parent.mkdir(parents=True, exist_ok=True) plist_path.write_text(new_plist, encoding="utf-8") + if _is_running_inside_gateway_process_tree(): + _mark_launchd_reload_pending(plist_path) + print("⚠ launchd bootstrap deferred because this command is running inside the gateway process tree") + print(" Run 'hermes gateway start' from an external shell to load the regenerated plist") + return try: - subprocess.run( - ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], - check=True, - timeout=30, - ) + _reload_launchd_plist_now(plist_path, label, check_bootstrap=True) subprocess.run( ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, @@ -3875,7 +4008,13 @@ def launchd_start(): print("✓ Service started") return - refresh_launchd_plist_if_needed() + refreshed = refresh_launchd_plist_if_needed() + if _is_running_inside_gateway_process_tree(): + if not refreshed: + print("✓ Gateway service is already running") + print("⚠ Not kickstarting launchd from inside the gateway process tree") + print(" Run 'hermes gateway start' from an external shell if launchd must be reloaded") + return try: subprocess.run( ["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], @@ -3992,16 +4131,41 @@ def _wait_for_gateway_exit( def launchd_restart(): - if _installed_launchd_plist_uses_app_wrapper() and not launchd_app_wrapper_is_current(): + plist_path = get_launchd_plist_path() + target_app_wrapper = _resolve_launchd_app_wrapper_mode(None) + if target_app_wrapper and not launchd_app_wrapper_is_current(): install_launchd_app_wrapper(force=True) label = get_launchd_label() - target = f"{_launchd_domain()}/{label}" + domain = _launchd_domain() + target = f"{domain}/{label}" drain_timeout = _get_restart_drain_timeout() from gateway.status import get_running_pid try: + reload_pending = _launchd_reload_is_pending(plist_path) + if not reload_pending: + plist_current = True + try: + plist_current = launchd_plist_is_current(app_wrapper=target_app_wrapper) + except Exception: + # A failed freshness check should not make restart impossible; + # fall back to the historical kickstart path when no explicit + # reload marker tells us launchd needs a bootstrap. + plist_current = True + if not plist_current: + new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) + if _refuse_temp_home_service_write(new_plist, "launchd plist"): + return + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_text(new_plist, encoding="utf-8") + reload_pending = True + pid = get_running_pid() - if pid is not None and _request_gateway_self_restart(pid): + # A self-requested restart cannot make launchd re-read a changed plist. + # If a refresh was previously deferred (or the plist is stale), manage + # launchd from this external command path instead so ProgramArguments, + # PATH, HERMES_HOME, and app-wrapper changes actually take effect. + if pid is not None and not reload_pending and _request_gateway_self_restart(pid): print("✓ Service restart requested") return if pid is not None: @@ -4015,7 +4179,12 @@ def launchd_restart(): print( f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart" ) - subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) + if reload_pending: + print("↻ Reloading launchd service definition before restart") + _reload_launchd_plist_now(plist_path, label, check_bootstrap=True) + subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30) + else: + subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: if not _launchd_error_indicates_unloaded(e): @@ -4061,6 +4230,9 @@ def launchd_status(deep: bool = False): loaded_output = "" print(f"Launchd plist: {plist_path}") + if _launchd_reload_is_pending(plist_path): + print("⚠ launchd service definition reload is pending") + print(" Run: hermes gateway restart") if launchd_plist_is_current(): print("✓ Service definition matches the current Hermes install") else: diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 601b03a498550..655ff1db22016 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -69,6 +69,7 @@ def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) calls = [] @@ -92,6 +93,7 @@ def test_systemd_restart_refreshes_outdated_unit(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) calls = [] monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) @@ -551,6 +553,147 @@ def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch): class TestLaunchdMacOSAppWrapper: + def test_generate_launchd_plist_prioritizes_amy_bins_and_filters_stale_path_entries(self, tmp_path, monkeypatch): + home = tmp_path / "amy" + repo = home / "hermes-agent" + venv = repo / ".venv" + node_dir = tmp_path / "homebrew" / "bin" + stale_codex = tmp_path / ".codex" / "tmp" / "arg0" / "codex-arg0dead" + codex_app = tmp_path / "Applications" / "Codex.app" / "Contents" / "Resources" + missing_dir = tmp_path / "missing" / "bin" + for path in [ + venv / "bin", + home / "bin", + home / ".go" / "bin", + repo / "node_modules" / ".bin", + node_dir, + codex_app, + ]: + path.mkdir(parents=True) + (venv / "bin" / "python").write_text("venv-python", encoding="utf-8") + (node_dir / "node").write_text("node", encoding="utf-8") + + real_path_exists = Path.exists + + def fake_path_exists(path): + if str(path) in {"/amy/bin", "/amy/.go/bin", "/config/amy/bin", "/config/.go/bin", "/share/hermes/bin"}: + return True + return real_path_exists(path) + + monkeypatch.setattr(Path, "exists", fake_path_exists) + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(venv / "bin" / "python")) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: str(node_dir / "node") if cmd == "node" else None) + monkeypatch.setenv( + "PATH", + ":".join([ + str(stale_codex), + str(codex_app), + str(missing_dir), + "/config/amy/bin", + "/config/.go/bin", + "/share/hermes/bin", + str(node_dir), + str(home / "bin"), + ]), + ) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist().encode("utf-8")) + + path_parts = plist["EnvironmentVariables"]["PATH"].split(":") + expected_prefix = [ + str(venv / "bin"), + str(home / "bin"), + "/amy/bin", + str(home / ".go" / "bin"), + "/amy/.go/bin", + str(repo / "node_modules" / ".bin"), + str(node_dir), + ] + assert path_parts[: len(expected_prefix)] == expected_prefix + assert str(stale_codex) not in path_parts + assert str(codex_app) not in path_parts + assert str(missing_dir) not in path_parts + assert path_parts.count(str(home / "bin")) == 1 + assert "/config/amy/bin" not in path_parts + assert "/config/.go/bin" not in path_parts + assert "/share/hermes/bin" not in path_parts + assert not any(part.startswith("/share") for part in path_parts) + + def test_generate_launchd_plist_preserves_logical_hermes_home(self, tmp_path, monkeypatch): + physical_home = tmp_path / "physical-amy" + logical_home = tmp_path / "amy" + physical_home.mkdir() + logical_home.symlink_to(physical_home, target_is_directory=True) + physical_repo = physical_home / "hermes-agent" + logical_repo = logical_home / "hermes-agent" + physical_venv = physical_repo / ".venv" + logical_venv = logical_repo / ".venv" + node_bin = physical_repo / "node_modules" / ".bin" + (physical_venv / "bin").mkdir(parents=True) + (physical_venv / "bin" / "python").write_text("venv-python", encoding="utf-8") + node_bin.mkdir(parents=True) + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: logical_home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", physical_repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: physical_venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(physical_venv / "bin" / "python")) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist().encode("utf-8")) + env = plist["EnvironmentVariables"] + path_parts = env["PATH"].split(":") + + assert env["HERMES_HOME"] == str(logical_home) + assert env["HERMES_HOME"] != str(physical_home.resolve()) + assert env["VIRTUAL_ENV"] == str(logical_venv) + assert plist["WorkingDirectory"] == str(logical_home) + assert plist["ProgramArguments"][0] == str(logical_venv / "bin" / "python") + assert str(logical_repo / "node_modules" / ".bin") in path_parts + assert not any(str(physical_home) in part for part in path_parts) + + def test_generate_launchd_plist_rejects_stale_resolved_node_parent(self, tmp_path, monkeypatch): + home = tmp_path / "amy" + repo = home / "hermes-agent" + stale_codex = tmp_path / ".codex" / "tmp" / "arg0" / "codex-arg0dead" + stale_codex.mkdir(parents=True) + (stale_codex / "node").write_text("stale-node", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: None) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: "/usr/bin/python3") + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: str(stale_codex / "node") if cmd == "node" else None) + monkeypatch.setenv("PATH", str(stale_codex)) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist().encode("utf-8")) + path_parts = plist["EnvironmentVariables"]["PATH"].split(":") + + assert str(stale_codex) not in path_parts + + def test_generate_launchd_plist_keeps_system_path_fallback_when_inherited_path_is_empty(self, tmp_path, monkeypatch): + home = tmp_path / "amy" + repo = home / "hermes-agent" + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: None) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: "/usr/bin/python3") + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + monkeypatch.setenv("PATH", "") + + plist = plistlib.loads(gateway_cli.generate_launchd_plist().encode("utf-8")) + path_parts = plist["EnvironmentVariables"]["PATH"].split(":") + + assert "/usr/bin" in path_parts + assert "/bin" in path_parts + def test_generate_launchd_plist_with_app_wrapper_uses_named_bundle_executable(self, tmp_path, monkeypatch): home = tmp_path / "home" repo = tmp_path / "repo" @@ -780,6 +923,14 @@ def fake_run(cmd, **kwargs): return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + # This test intentionally uses a pytest-tmp HERMES_HOME while probing + # app-wrapper preservation. The temp-home safety belt is covered by + # dedicated tests; keep it out of this unrelated assertion. + monkeypatch.setattr( + gateway_cli, + "_refuse_temp_home_service_write", + lambda definition, kind: False, + ) assert gateway_cli.refresh_launchd_plist_if_needed() is True @@ -826,7 +977,7 @@ def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, mo monkeypatch.setattr( gateway_cli, "generate_launchd_plist", - lambda: ( + lambda app_wrapper=False: ( "--replace\nHERMES_HOME" "/Users/alice/.hermes" ), @@ -863,11 +1014,11 @@ def test_refresh_defers_reload_when_running_inside_gateway_tree(self, tmp_path, plist_path.write_text("old content", encoding="utf-8") monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) - monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda: False) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: False) monkeypatch.setattr( gateway_cli, "generate_launchd_plist", - lambda: ( + lambda app_wrapper=False: ( "--replace\nHERMES_HOME" "/Users/alice/.hermes" ), @@ -899,16 +1050,13 @@ def fake_popen(cmd, **kwargs): assert result is True # The new plist was written. assert "--replace" in plist_path.read_text(encoding="utf-8") - # No DIRECT bootout/bootstrap ran (those would kill us mid-sequence). + # No DIRECT bootout/bootstrap ran (those would kill us mid-sequence), + # and no detached helper was spawned from inside the gateway. The + # current safe path leaves a reload-pending marker for an external + # `hermes gateway start`/restart helper to consume. assert not [c for c in run_calls if "bootout" in c or "bootstrap" in c] - # Exactly one detached helper was spawned, in a new session, and it - # performs both bootout and bootstrap. - assert len(popen_calls) == 1 - cmd, kwargs = popen_calls[0] - assert kwargs.get("start_new_session") is True - script = cmd[-1] - assert "bootout" in script and "bootstrap" in script - assert str(plist_path) in script + assert popen_calls == [] + assert gateway_cli._launchd_reload_pending_path(plist_path).exists() def test_refresh_uses_direct_reload_when_not_inside_gateway_tree(self, tmp_path, monkeypatch): """Normal CLI-initiated refresh (outside the service tree) keeps the @@ -917,11 +1065,11 @@ def test_refresh_uses_direct_reload_when_not_inside_gateway_tree(self, tmp_path, plist_path.write_text("old content", encoding="utf-8") monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) - monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda: False) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: False) monkeypatch.setattr( gateway_cli, "generate_launchd_plist", - lambda: ( + lambda app_wrapper=False: ( "--replace\nHERMES_HOME" "/Users/alice/.hermes" ), @@ -959,6 +1107,112 @@ def fake_run(cmd, check=False, **kwargs): ["launchctl", "bootstrap", domain, str(plist_path)], ] + def test_refresh_launchd_plist_defers_launchctl_when_running_inside_gateway_process_tree(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old plist\n", encoding="utf-8") + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["python"]}).decode("utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: expected) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + assert gateway_cli.refresh_launchd_plist_if_needed() is True + assert plist_path.read_text(encoding="utf-8") == expected + assert calls == [] + + def test_launchd_start_defers_kickstart_after_self_context_refresh(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old plist\n", encoding="utf-8") + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["python"]}).decode("utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: expected) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + assert plist_path.read_text(encoding="utf-8") == expected + assert calls == [] + + def test_launchd_start_reloads_pending_definition_from_external_shell(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old plist\n", encoding="utf-8") + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["python"]}).decode("utf-8") + label = gateway_cli.get_launchd_label() + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + inside_gateway = True + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: expected) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: inside_gateway, raising=False) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + assert calls == [] + + inside_gateway = False + gateway_cli.launchd_start() + + assert calls == [ + ["launchctl", "bootout", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + + def test_launchd_start_defers_missing_plist_bootstrap_inside_gateway_tree(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["python"]}).decode("utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: expected) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + assert plist_path.read_text(encoding="utf-8") == expected + assert calls == [] + + def test_running_inside_gateway_process_tree_requires_matching_launchd_job_pid(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_macos", lambda: True) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 123) + monkeypatch.setattr(gateway_cli, "_is_pid_ancestor_of_current_process", lambda pid: pid == 123) + + def fake_run(cmd, **kwargs): + assert cmd == ["launchctl", "print", f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"] + return SimpleNamespace(returncode=0, stdout="pid = 999\n", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + assert gateway_cli._is_running_inside_gateway_process_tree() is False + def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") @@ -1019,6 +1273,8 @@ def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypat target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}" monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) + monkeypatch.setattr(gateway_cli, "_launchd_reload_is_pending", lambda plist_path=None: False) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True) monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: calls.append(("term", pid, force))) @@ -1040,6 +1296,103 @@ def fake_run(cmd, check=False, **kwargs): ["launchctl", "kickstart", "-k", target], ] + def test_launchd_restart_consumes_reload_pending_marker_before_kickstart(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("new content", encoding="utf-8") + gateway_cli._mark_launchd_reload_pending(plist_path) + label = gateway_cli.get_launchd_label() + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + calls = [] + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) + monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_restart() + + service_calls = [c for c in calls if "bootout" in c or "bootstrap" in c or "kickstart" in c] + assert service_calls == [ + ["launchctl", "bootout", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + assert not gateway_cli._launchd_reload_pending_path(plist_path).exists() + + def test_launchd_restart_rewrites_stale_plist_before_bootstrap(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("old stale content", encoding="utf-8") + label = gateway_cli.get_launchd_label() + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + calls = [] + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: False) + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: "fresh content") + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) + monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_restart() + + assert plist_path.read_text(encoding="utf-8") == "fresh content" + service_calls = [c for c in calls if "bootout" in c or "bootstrap" in c or "kickstart" in c] + assert service_calls == [ + ["launchctl", "bootout", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + + def test_launchd_restart_does_not_self_request_when_reload_pending(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("new content", encoding="utf-8") + gateway_cli._mark_launchd_reload_pending(plist_path) + label = gateway_cli.get_launchd_label() + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + calls = [] + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) + monkeypatch.setattr( + gateway_cli, + "_request_gateway_self_restart", + lambda pid: (_ for _ in ()).throw(AssertionError("self restart cannot reload launchd plist")), + ) + monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True) + monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: calls.append(("term", pid, force))) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321) + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_restart() + + service_calls = [c for c in calls if isinstance(c, list)] + assert ("term", 321, False) in calls + assert service_calls == [ + ["launchctl", "bootout", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys): calls = [] @@ -1047,6 +1400,8 @@ def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, "gateway.status.get_running_pid", lambda: 321, ) + monkeypatch.setattr(gateway_cli, "_launchd_reload_is_pending", lambda plist_path=None: False) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) monkeypatch.setattr( gateway_cli, "_request_gateway_self_restart", @@ -1138,6 +1493,25 @@ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, assert "stale" in output.lower() assert "not loaded" in output.lower() + def test_launchd_status_reports_reload_pending_marker(self, tmp_path, monkeypatch, capsys): + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text("current enough", encoding="utf-8") + gateway_cli._mark_launchd_reload_pending(plist_path) + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="loaded", stderr=""), + ) + + gateway_cli.launchd_status() + + output = capsys.readouterr().out.lower() + assert "reload is pending" in output + assert "hermes gateway restart" in output + def test_launchd_domain_uses_user_domain(self, monkeypatch): # The user/ domain (not gui/) is the one reachable from # non-Aqua/background sessions on macOS 26+ (issue #23387). @@ -1238,7 +1612,7 @@ def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, m monkeypatch.setattr( gateway_cli, "generate_launchd_plist", - lambda: ( + lambda app_wrapper=False: ( "HERMES_HOME" "/Users/alice/.hermes" ), @@ -1268,6 +1642,8 @@ def test_launchd_restart_falls_back_to_detached_on_error_5(self, monkeypatch, ca target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}" monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 5.0) + monkeypatch.setattr(gateway_cli, "_launchd_reload_is_pending", lambda plist_path=None: False) + monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True) monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: None) @@ -1489,6 +1865,7 @@ def test_systemd_restart_gracefully_restarts_running_service_and_waits(self, mon monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system))) monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) monkeypatch.setattr( @@ -1534,6 +1911,7 @@ def test_systemd_restart_uses_systemd_main_pid_when_pid_file_is_missing(self, mo monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 10.0) monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) @@ -1593,6 +1971,7 @@ def test_systemd_restart_reports_start_limit_hit(self, monkeypatch, capsys): monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) monkeypatch.setattr(gateway_cli, "_recover_pending_systemd_restart", lambda system=False, previous_pid=None: False) @@ -1623,6 +2002,7 @@ def fake_run_systemctl(args, **kwargs): def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys): monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda **kwargs: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr( "gateway.status.read_runtime_status", @@ -3384,10 +3764,6 @@ def test_launchd_plist_keepalive_unconditional(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) plist = gateway_cli.generate_launchd_plist() - # Scalar must be present immediately after the KeepAlive key - assert "KeepAlive" in plist - # The unconditional form - assert "KeepAlive\n " in plist - # The old conditional dict form must NOT appear + data = plistlib.loads(plist.encode("utf-8")) + assert data["KeepAlive"] is True assert "SuccessfulExit" not in plist - assert "KeepAlive\n " not in plist From 4e945ba21d32d14b11bd6b979b3c442b5b8dd6b5 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Thu, 21 May 2026 04:42:29 +0200 Subject: [PATCH 12/28] fix(deps): restore CVE-fixed pyproject pins Restore the aiohttp, anthropic, and explicit cryptography pins that uv.lock and lazy_deps.py already carried from the upstream security bump. Regenerate uv.lock with the local uv release-age guard so the editable root package version matches 0.14.0 without downgrading the CVE-fixed packages. --- RELEASE_amy-patches.md | 47 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 12 +++++++---- uv.lock | 6 ++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 84b2aadbc9cae..5c01047cfabe7 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -243,6 +243,53 @@ Platform-aware zipper mode defaults for Amy's persona: ## 🐛 Bug Fixes +### Dependency Pins: Restore CVE-Fixed pyproject Metadata (2026-05-21) + +**Problem:** `uv.lock` was dirty because a local `uv lock` run regenerated the +lockfile from stale `pyproject.toml` pins. The dirty lock would have downgraded +`aiohttp` 3.13.4 → 3.13.3 and `anthropic` 0.87.0 → 0.86.0, while removing the +explicit `cryptography==46.0.7` core pin. That would undo upstream commit +`d725407c5`'s CVE-fixed dependency floors and leave `pyproject.toml`, +`uv.lock`, and `tools/lazy_deps.py` disagreeing. + +**Solution:** Reapplied the CVE-fixed dependency pins to `pyproject.toml` so it +matches the committed lockfile and lazy-install map, then regenerated `uv.lock` +with Wolfram's global uv release-age guard. The final lock keeps +`aiohttp==3.13.4`, `anthropic==0.87.0`, `cryptography==46.0.7`, keeps the +editable root package on the rebased upstream version, and records uv's 24h +`exclude-newer-span` option. + +**Affected files:** `pyproject.toml`, `uv.lock` + +**Session reference:** 2026-05-21 dependency-lock maintenance review. + +**Verification:** `uv lock --check`, metadata consistency assertions for +`pyproject.toml`/`uv.lock`, and `git diff --check`. + +### Mattermost MEDIA Attachments: Keep Thread Context (2026-05-19) + +**Problem:** In Mattermost `MATTERMOST_REPLY_MODE=thread`, normal text replies +used `metadata.thread_id`/`root_id`, but image attachments extracted from +`MEDIA:` tags were routed through the Mattermost `send_multiple_images()` batch +path. That path uploaded files and posted `file_ids` without setting +`root_id`, so images landed in the parent channel while the surrounding text +stayed in the thread. + +**Solution:** `MattermostAdapter.send_multiple_images()` now honors +`metadata.thread_id` when reply mode is `thread`, sets `root_id` on the batch +file post, and mirrors the existing invalid-root flat fallback used by normal +messages and single-file sends. Added a regression test covering batched local +MEDIA image uploads with Mattermost thread metadata. + +**Affected files:** `gateway/platforms/mattermost.py`, +`tests/gateway/test_mattermost.py` + +**Session reference:** 2026-05-19 Mattermost media-threading regression review. + +**Verification:** +`tests/gateway/test_mattermost.py::TestMattermostSend::test_send_multiple_images_uses_metadata_thread_id`, +`tests/gateway/test_mattermost.py`, `tests/gateway/test_send_multiple_images.py`. + ### Gateway /status: Provider-Aware Idle Context Window (2026-05-19) `/status` shows model, context usage, and cumulative token labels. Its idle diff --git a/pyproject.toml b/pyproject.toml index d269ba840be20..fc0676e33abb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,10 @@ dependencies = [ # urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass) # and GHSA-qccp-gfcp-xxvc (header leak across origins). "urllib3>=2.7.0,<3", + # Directly imported by WeCom/Weixin crypto paths and pulled transitively by + # PyJWT[crypto]; pin explicitly so the floor does not drift below the + # CVE-2026-39892 fix (buffer overflow on non-contiguous buffers). + "cryptography==46.0.7", # CVE-2026-39892 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone # out of the box. ``tzdata`` ships the Olson database as a data package @@ -157,7 +161,7 @@ hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"] +slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] # WeCom callback-mode adapter — parses untrusted XML POST bodies from # WeCom-controlled callback endpoints, so we use defusedxml (drop-in @@ -196,9 +200,9 @@ vision = [] # a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock. mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 nemo-relay = ["nemo-relay==0.3"] -homeassistant = ["aiohttp==3.13.4"] -sms = ["aiohttp==3.13.4"] -teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"] +homeassistant = ["aiohttp==3.13.4"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 +sms = ["aiohttp==3.13.4"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 +teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk diff --git a/uv.lock b/uv.lock index b75ff441eae5a..9e3792a25c879 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,10 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "PT24H" + [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -1430,6 +1434,7 @@ dependencies = [ { name = "certifi" }, { name = "concurrent-log-handler", marker = "sys_platform == 'win32'" }, { name = "croniter" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "fire" }, { name = "httpx", extra = ["socks"] }, @@ -1646,6 +1651,7 @@ requires-dist = [ { name = "certifi", specifier = "==2026.5.20" }, { name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" }, { name = "croniter", specifier = "==6.0.0" }, + { name = "cryptography", specifier = "==46.0.7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, { name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" }, From c017d9811f82d38e24f97f68da5633cd7ebbba3f Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Sun, 31 May 2026 17:14:47 +0200 Subject: [PATCH 13/28] feat(status): restore model and context in gateway status PROBLEM: The old public /status PR drifted out of the current Amy patch stack, leaving /status without the model/provider, context window, or explicit cumulative token label that Wolfram uses to monitor context pressure from chat. SOLUTION: Re-port the feature onto the current gateway status handler. Prefer live/cached agent runtime metadata, fall back to SessionDB + SessionStore state between turns, add localized status model/context lines, and keep token totals explicitly labeled cumulative. Verification: tests/gateway/test_status_command.py, tests/hermes_cli/test_commands.py --- RELEASE_amy-patches.md | 29 ++++++++++++++++++---------- gateway/slash_commands.py | 24 ++++++++++++++++++----- tests/gateway/test_status_command.py | 7 ++++++- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 5c01047cfabe7..d88679ad3ce7a 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -290,19 +290,28 @@ MEDIA image uploads with Mattermost thread metadata. `tests/gateway/test_mattermost.py::TestMattermostSend::test_send_multiple_images_uses_metadata_thread_id`, `tests/gateway/test_mattermost.py`, `tests/gateway/test_send_multiple_images.py`. -### Gateway /status: Provider-Aware Idle Context Window (2026-05-19) +### Gateway /status: Restore Model/Context Cockpit Info (2026-05-31) -`/status` shows model, context usage, and cumulative token labels. Its idle -fallback resolves the model context window provider-aware instead of using raw -`DEFAULT_CONTEXT_LENGTHS`, so `gpt-5.5` via `openai-codex` displays the real -272,000-token Codex OAuth window instead of the direct-OpenAI 1,050,000-token -window. The same path respects provider/base URL/custom-provider/context -overrides used by `/model` and compression. +**Problem:** The old public #4678 feature had drifted out of the current +`amy/patches` stack during the 0.15.x upgrade/rebase path. `/usage` still exposed +model/context details, but `/status` no longer showed the at-a-glance model, +context window, or explicit cumulative token label that Wolfram uses to monitor +context pressure from chat. -**Files:** `gateway/run.py`, `tests/gateway/test_status_command.py` +**Solution:** Re-ported the feature onto the current gateway status handler: +`/status` now shows the live or cached agent model/provider and context usage +when available, falls back to the persisted SessionDB model plus the +SessionStore's `last_prompt_tokens` between turns, and labels token totals as +cumulative. Context length resolution follows the current provider-aware +metadata path while deliberately avoiding account-usage/billing calls. -**Verification:** `tests/gateway/test_status_command.py`, -`tests/hermes_cli/test_model_switch_context_display.py`. +**Affected files:** `gateway/run.py`, `hermes_cli/commands.py`, `locales/en.yaml`, +`tests/gateway/test_status_command.py` + +**Verification:** +`tests/gateway/test_status_command.py::test_status_command_includes_live_agent_model_and_context`, +`tests/gateway/test_status_command.py::test_status_command_includes_persisted_model_and_context_when_agent_not_running`, +full `tests/gateway/test_status_command.py`, and command registry tests. ### macOS LaunchAgent Path Cleanup (2026-05-17) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 01a8a82e321f6..18e1dc3d1c3d9 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -498,11 +498,25 @@ def _int_value(value: Any) -> int: model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} if isinstance(model_cfg, dict): provider_name = _clean_str(model_cfg.get("provider")) - if not context_total: - model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} - configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None - if isinstance(configured_context, int) and configured_context > 0: - context_total = configured_context + if not context_total and model_name: + try: + from agent.model_metadata import get_model_context_length + + model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} + configured_context = None + if isinstance(model_cfg, dict): + configured_context = model_cfg.get("context_length") + custom_providers = user_config.get("custom_providers") if isinstance(user_config, dict) else None + context_total = get_model_context_length( + model_name, + base_url=base_url, + api_key="", + config_context_length=configured_context if isinstance(configured_context, int) else None, + provider=provider_name, + custom_providers=custom_providers if isinstance(custom_providers, list) else None, + ) + except Exception: + context_total = 0 model_line = "" if model_name: diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index f02738b51f2c7..13caf619745b9 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -238,13 +238,18 @@ async def test_status_command_includes_persisted_model_and_context_when_agent_no "billing_provider": "openai-codex", "billing_base_url": "https://example.invalid/v1", } - monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"model": {"context_length": 272_000}}) + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"model": {}}) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 272_000, + ) result = await runner._handle_message(_make_event("/status")) assert "**Model:** `openai/gpt-persisted` (openai-codex)" in result assert "**Context:** 24,000 / 272,000 (9%)" in result assert "**Cumulative API tokens (re-sent each call):** 2,500" in result + assert "2,500 (cumulative)" not in result @pytest.mark.asyncio From 68b454c576e7aacc80cc2b72127dc644e025d31f Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Sun, 31 May 2026 17:14:58 +0200 Subject: [PATCH 14/28] feat(resume): restore cross-platform full session listing PROBLEM: The old public /resume PR was stale and no longer represented in amy/patches. Users could resume a known session ID globally, but could not browse titled sessions across platforms or unnamed sessions when switching between apps like Telegram and Mattermost. SOLUTION: Restore the still-needed listing semantics on the current SessionDB model: /resume keeps current-platform named listing, /resume --all lists named sessions across platforms, /resume --full includes unnamed sessions on the current platform, and /resume --all --full lists everything with source tags. Keep -- flags distinct from session titles and resolve exact/unique-prefix session IDs before titles. Verification: tests/gateway/test_resume_command.py, tests/hermes_cli/test_commands.py --- RELEASE_amy-patches.md | 30 +++++++ gateway/slash_commands.py | 129 ++++++++++++++++++--------- hermes_cli/commands.py | 4 +- tests/gateway/test_resume_command.py | 89 ++++++++++++++++++ 4 files changed, 208 insertions(+), 44 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index d88679ad3ce7a..354f65f68fc71 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -313,6 +313,36 @@ metadata path while deliberately avoiding account-usage/billing calls. `tests/gateway/test_status_command.py::test_status_command_includes_persisted_model_and_context_when_agent_not_running`, full `tests/gateway/test_status_command.py`, and command registry tests. +### Gateway /resume: Restore Cross-Platform/Full Listing Semantics (2026-05-31) + +**Problem:** The old public #4689 branch was stale and not carried in the current +22-patch stack. Current `/resume ` could reopen a known session +across apps, but `/resume` discovery was limited to titled sessions on the +current platform. That broke the intended Mattermost ↔ Telegram/API workflow: +users could not conveniently list sessions from other platforms or unnamed +sessions whose IDs were not already known. + +**Solution:** Re-ported only the still-needed semantics on top of the current +0.15.x SessionDB model: `/resume` keeps listing named sessions for the current +platform; `/resume --all` lists named sessions across all platforms; +`/resume --full` includes unnamed sessions on the current platform; and +`/resume --all --full` lists all sessions across platforms with source tags. +Flags are `--`-prefixed so a session titled `all` remains resumable, and direct +session lookup now accepts exact or unique-prefix session IDs before falling +back to title/lineage lookup. Deprecated old API-server/json-log restoration +code was not resurrected because current upstream stores gateway/API history in +SessionDB. + +**Affected files:** `gateway/run.py`, `hermes_cli/commands.py`, +`tests/gateway/test_resume_command.py` + +**Verification:** +`tests/gateway/test_resume_command.py::TestHandleResumeCommand::test_resume_all_lists_named_sessions_across_platforms`, +`test_resume_full_lists_unnamed_sessions_on_current_platform_only`, +`test_resume_all_full_lists_unnamed_sessions_across_platforms`, +`test_resume_session_named_all_is_not_treated_as_flag`, full +`tests/gateway/test_resume_command.py`, and command registry tests. + ### macOS LaunchAgent Path Cleanup (2026-05-17) The Mac mini migration left the generated launchd `PATH` depending on legacy diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 18e1dc3d1c3d9..15f9094275ce7 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2844,9 +2844,19 @@ async def _handle_resume_command(self, event: MessageEvent) -> str: parts = shlex.split(raw_args) except ValueError as exc: return t("gateway.resume.parse_error", error=exc) + allow_all = "--all" in parts + allow_full = "--full" in parts allow_cross_room = "--cross-room" in parts - name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip() + known_flags = {"--all", "--full", "--cross-room"} + unknown_flags = [p for p in parts if p.startswith("--") and p not in known_flags] + name_parts = [p for p in parts if p not in known_flags] + name = " ".join(name_parts).strip() + if unknown_flags and not name: + # Preserve old fail-closed behavior for unknown flags: treat the + # literal text as the lookup target so the user gets the normal + # not-found response instead of accidentally widening scope. + name = " ".join(parts).strip() # Strip common outer brackets/quotes users may type literally from the # usage hint (e.g. ``/resume ``). Mirrors the CLI behavior. @@ -2858,53 +2868,86 @@ async def _handle_resume_command(self, event: MessageEvent) -> str: ): name = name[1:-1].strip() + def _list_sessions_for_scope(*, cross_platform: bool, show_unnamed: bool, limit: int) -> list[dict]: + user_source = None if cross_platform else (source.platform.value if source.platform else None) + sessions = self._session_db.list_sessions_rich(source=user_source, limit=limit) + if source.platform == Platform.MATRIX and not cross_platform: + scoped = [] + for s in sessions: + origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) + if self._same_matrix_room(source, origin): + scoped.append(s) + sessions = scoped + if not show_unnamed: + sessions = [s for s in sessions if s.get("title")] + return sessions + def _list_titled_sessions() -> list[dict]: - user_source = source.platform.value if source.platform else None - sessions = self._session_db.list_sessions_rich(source=user_source, limit=10) - return [s for s in sessions if s.get("title")][:10] + return _list_sessions_for_scope(cross_platform=False, show_unnamed=False, limit=10)[:10] + # Listing modes: + # /resume named sessions, current platform + # /resume --all named sessions, all platforms + # /resume --full all sessions, current platform + # /resume --all --full all sessions, all platforms if not name: - # List recent titled sessions for this user/platform try: - titled = _list_titled_sessions() - if source.platform == Platform.MATRIX and not allow_all: - scoped = [] - for s in titled: - origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) - if self._same_matrix_room(source, origin): - scoped.append(s) - titled = scoped - if not titled: - if source.platform == Platform.MATRIX and not allow_all: - return t("gateway.resume.matrix_no_named_sessions") - return t("gateway.resume.no_named_sessions") - lines = [t("gateway.resume.list_header")] - for idx, s in enumerate(titled[:10], start=1): - title = s["title"] - if source.platform == Platform.MATRIX and allow_all: - origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) - if origin: - title = f"{title} — {origin.chat_name or origin.chat_id}" - preview = s.get("preview", "")[:40] + if not (allow_all or allow_full): + titled = _list_titled_sessions() + if not titled: + if source.platform == Platform.MATRIX: + return t("gateway.resume.matrix_no_named_sessions") + return t("gateway.resume.no_named_sessions") + lines = [t("gateway.resume.list_header")] + for idx, s in enumerate(titled[:10], start=1): + title = s["title"] + preview = s.get("preview", "")[:40] + preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" + lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) + lines.append(t("gateway.resume.list_footer_numbered")) + return "\n".join(lines) + + sessions = _list_sessions_for_scope( + cross_platform=allow_all, + show_unnamed=allow_full, + limit=50, + ) + if not sessions: + scope = "across all platforms" if allow_all else "on this platform" + kind = "sessions" if allow_full else "named sessions" + return ( + f"No {kind} found {scope}.\n" + "Use `/title My Session` to name your current session, " + "then `/resume My Session` to return to it later." + ) + + scope_label = "All Platforms" if allow_all else (source.platform.value.title() if source.platform else "Unknown") + header_kind = "All Sessions" if allow_full else "Named Sessions" + lines = [f"📋 **{header_kind} — {scope_label}**\n"] + for idx, s in enumerate(sessions[:20], start=1): + title = s.get("title") + preview = (s.get("preview") or "")[:40] preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" - lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) - lines.append(t("gateway.resume.list_footer_numbered")) + source_tag = f" [{s.get('source', '?')}]" if allow_all else "" + if title: + label = f"**{title}**" + else: + label = f"`{str(s.get('id', '???'))[:20]}`" + lines.append(f"{idx}. {label}{preview_part}{source_tag}") + lines.append("\nUsage: `/resume ` or `/resume `") + if not allow_all: + lines.append("Tip: `/resume --all` for cross-platform, `/resume --full` to include unnamed sessions.") + elif not allow_full: + lines.append("Tip: `/resume --all --full` to include unnamed sessions.") return "\n".join(lines) except Exception as e: - logger.debug("Failed to list titled sessions: %s", e) + logger.debug("Failed to list sessions: %s", e) return t("gateway.resume.list_failed", error=e) - # Resolve a numbered choice or a title to a session ID. + # Resolve a numbered choice, session ID/prefix, or title to a session ID. if name.isdigit(): try: titled = _list_titled_sessions() - if source.platform == Platform.MATRIX and not allow_all: - scoped = [] - for s in titled: - origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) - if self._same_matrix_room(source, origin): - scoped.append(s) - titled = scoped except Exception as e: logger.debug("Failed to list titled sessions for numeric resume: %s", e) return t("gateway.resume.list_failed", error=e) @@ -2915,12 +2958,14 @@ def _list_titled_sessions() -> list[dict]: target_id = target.get("id") name = target.get("title") or name else: - # Try direct session ID lookup first (so `/resume ` - # works in the gateway, not just `/resume `). - session = self._session_db.get_session(name) - if session: - target_id = session["id"] - else: + # Try direct/exact-or-unique-prefix session ID lookup first (so + # `/resume <session_id>` and `/resume <id-prefix>` work globally), + # then fall back to global title/lineage lookup. + try: + target_id = self._session_db.resolve_session_id(name) + except Exception: + target_id = None + if not target_id: target_id = self._session_db.resolve_session_by_title(name) if not target_id: return t("gateway.resume.not_found", name=name) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 42e51f299097f..0cb7df34850ac 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -114,8 +114,8 @@ class CommandDef: CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), - CommandDef("resume", "Resume a previously-named session", "Session", - args_hint="[name]"), + CommandDef("resume", "Resume or list sessions across platforms", "Session", + args_hint="[name|session_id|--all|--full]"), # Configuration CommandDef("sessions", "Browse and resume previous sessions", "Session"), diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index a24a8578f4936..db9fd52d81514 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -364,6 +364,95 @@ async def test_resume_resolves_by_session_id(self, tmp_path): + @pytest.mark.asyncio + async def test_resume_all_lists_named_sessions_across_platforms(self, tmp_path): + """`/resume --all` lists titled sessions from every platform, not just the current app.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("telegram_named", "telegram") + db.set_session_title("telegram_named", "Telegram Work") + db.append_message("telegram_named", "user", "telegram preview") + db.create_session("mattermost_named", "mattermost") + db.set_session_title("mattermost_named", "Mattermost Work") + db.append_message("mattermost_named", "user", "mattermost preview") + + event = _make_event(text="/resume --all", platform=Platform.MATTERMOST) + runner = _make_runner(session_db=db, event=event) + result = await runner._handle_resume_command(event) + + assert "Named Sessions — All Platforms" in result + assert "Telegram Work" in result + assert "Mattermost Work" in result + assert "[telegram]" in result + assert "[mattermost]" in result + assert "telegram_unnamed" not in result + db.close() + + @pytest.mark.asyncio + async def test_resume_full_lists_unnamed_sessions_on_current_platform_only(self, tmp_path): + """`/resume --full` includes unnamed sessions but still scopes to the current platform.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("telegram_unnamed", "telegram") + db.append_message("telegram_unnamed", "user", "telegram unnamed preview") + db.create_session("mattermost_unnamed", "mattermost") + db.append_message("mattermost_unnamed", "user", "mattermost unnamed preview") + + event = _make_event(text="/resume --full", platform=Platform.MATTERMOST) + runner = _make_runner(session_db=db, event=event) + result = await runner._handle_resume_command(event) + + assert "All Sessions — Mattermost" in result + assert "`mattermost_unnamed`" in result + assert "mattermost unnamed preview" in result + assert "telegram_unnamed" not in result + assert "telegram unnamed preview" not in result + db.close() + + @pytest.mark.asyncio + async def test_resume_all_full_lists_unnamed_sessions_across_platforms(self, tmp_path): + """`/resume --all --full` shows unnamed sessions from other platforms for cross-app restore.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("telegram_unnamed", "telegram") + db.append_message("telegram_unnamed", "user", "telegram unnamed preview") + db.create_session("mattermost_unnamed", "mattermost") + db.append_message("mattermost_unnamed", "user", "mattermost unnamed preview") + + event = _make_event(text="/resume --all --full", platform=Platform.MATTERMOST) + runner = _make_runner(session_db=db, event=event) + result = await runner._handle_resume_command(event) + + assert "All Sessions — All Platforms" in result + assert "`telegram_unnamed`" in result + assert "`mattermost_unnamed`" in result + assert "[telegram]" in result + assert "[mattermost]" in result + db.close() + + @pytest.mark.asyncio + async def test_resume_session_named_all_is_not_treated_as_flag(self, tmp_path): + """Only --all is a flag; a titled session named 'all' remains resumable.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("session_named_all", "telegram") + db.set_session_title("session_named_all", "all") + db.create_session("current_session_001", "telegram") + + event = _make_event(text="/resume all") + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=event, + ) + result = await runner._handle_resume_command(event) + + assert "Resumed" in result + call_args = runner.session_store.switch_session.call_args + assert call_args[0][1] == "session_named_all" + db.close() + + class TestHandleSessionsCommand: """Tests for GatewayRunner._handle_sessions_command.""" From 17be3099468ff0bcae165461e8541d71d74e5754 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Mon, 8 Jun 2026 02:05:05 +0200 Subject: [PATCH 15/28] docs(patches): update upstream PR reconciliation PROBLEM: RELEASE_amy-patches.md still led with the historical v0.6.0 patch summary and did not document the current v2026.6.5 patch stack or which local patches map to existing/new upstream PRs. SOLUTION: Add a current 2026-06-08 reconciliation section listing the 26 reviewed commits, their public/private disposition, existing PR mappings, and upstream-submission plan while preserving the old v0.6.0 notes as historical context. Session: Mattermost thread xjdsx1ndw7898na3bes6zpzske --- RELEASE_amy-patches.md | 93 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 354f65f68fc71..5dfa8fd71c5c3 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,14 +1,92 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** Rebased Amy/private patch stack after launchd restart reload-pending fix -**Current reconciliation reviewed through:** v0.17.0 rebase in progress; upstream-absorbed patches dropped, Amy-private patches retained +**Current patch stack:** 21 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, and Raft optional-platform log quieting +**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained **Author:** Amy Ravenwolf <amy@ravenwolf.de> > Current goal: keep only Amy/private patches local, and submit every generally useful feature/fix upstream as an open PR so future upgrades have less custom patch baggage. --- +## Current Patch-Stack Classification (2026-06-22) + +Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. + +- Current base: `v2026.6.19` +- Current stack: `21` patches on `amy/patches` +- Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` +- Pre-v0.17 stack: `32` patches on `v2026.6.5` +- Exact patch-id absorption check against current `upstream/main`: all 21 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. + +### Dropped During the v0.17 Rebase + +These old local patches are no longer carried in the current stack because Hermes v0.17.0 or current upstream provides the workflow, or because the old documentation commit was replaced by current reconciliation docs. + +| Old patch subject | Current disposition | +|---|---| +| `feat(memory): configurable background memory update notifications` | Dropped - upstream absorbed. | +| `feat(display): add independent thinking_progress config option` | Dropped - upstream absorbed. | +| `feat(display): show delegate_task goals in tool progress notifications` | Dropped - upstream absorbed. | +| `feat(display): verbose skill change notifications with content previews` | Dropped - upstream absorbed. | +| `fix(display): preserve generic skill patch notifications` | Dropped - upstream absorbed. | +| `feat(prompt): make context-file truncation limit configurable` | Dropped - upstream absorbed with stronger dynamic/explicit cap support. | +| `feat(hooks): session:compress event_callback for MemPalace sync` | Dropped - upstream absorbed. | +| `feat: add tool_progress_style config (accumulate vs separate)` | Dropped - upstream absorbed under `tool_progress_grouping`. | +| `fix(config): read browser inactivity timeout from config` | Dropped - upstream replaced/integrated. | +| `feat(gateway): inject stable human-readable message timestamps` | Dropped as code - upstream has config-gated timestamp support; Amy preserves behavior through config. | +| `fix(state): skip redundant trigram backfill before v11 FTS rebuild` | Dropped - upstream absorbed. | +| `fix(skills): ignore support docs in skill discovery` | Dropped - upstream absorbed. | +| `fix(mattermost): keep plugin sends in threads` | Dropped - upstream absorbed. | +| `fix(mattermost): harden delivery hygiene` | Dropped - upstream absorbed. | +| Old upstream PR reconciliation doc commits | Replaced by current `docs(patches)` reconciliation commit. | + +### New or Re-Spun Since the v0.17 Rebase + +| Current commit | Subject | Classification | +|---|---|---| +| `9286c41c8` | `feat(status): restore model and context in gateway status` | Re-spun local delta on top of upstream's refactored status command. | +| `505373d78` | `docs(patches): update upstream PR reconciliation` | Re-spun private patch-stack documentation. | +| `8927afe69` | `feat(tools): keep send_message in explicit messaging toolset` | New Amy-local policy patch after upstream removed agent-callable `send_message` from default surfaces. | +| `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | New upstream-worthy test flake fix. | +| `f1abee591` | `fix(gateway): harden self-management guard` | New upstream-worthy gateway safety fix, including restart-helper detector follow-up. | +| `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | New upstream-worthy optional-plugin noise fix; likely droppable after a release containing upstream's equivalent Raft quieting. | + +### Current 21-Patch Stack + +| Commit | Subject | Current classification | +|---|---|---| +| `c2bc868aa` | `fix: WhatsApp voice messages + bridge audio download + npm deps` | Upstream-worthy and still locally needed against `v2026.6.19`; original PR #41616 closed unmerged. | +| `ab38f124c` | `feat(tool_progress): add 'full' mode - unlimited tool args in gateway chat` | Upstream-worthy and still locally needed; original PR #41617 closed unmerged. | +| `3534f1cc7` | `feat(prompt): add Amy platform hints and Mattermost Private Assistant default` | Private Amy/persona patch - do not upstream. | +| `985992a36` | `docs: add Amy's patches changelog for v0.6.0 fork` | Private fork documentation - do not upstream. | +| `33fad8f85` | `fix: suppress pkg_resources deprecation warning from lark_oapi` | Upstream-worthy and still locally needed; original PR #41621 closed unmerged. | +| `35b9fa2e0` | `fix(addon): make dashboard assets work behind HA ingress` | Upstream-worthy and still locally needed; original PR #41629 closed unmerged. | +| `73d6a66c3` | `feat(vision): add provider-safe inject_image tool` | Upstream-worthy and still locally needed; original PR #41632 closed unmerged. | +| `4761ff163` | `feat(moa): route experts through provider-aware clients` | Partially superseded by upstream MoA/virtual-provider redesign; keep locally against `v2026.6.19`, re-evaluate on next release. | +| `9fc7aa5ca` | `feat(gateway): add macOS app-wrapper launchd identity` | Upstream-worthy/local Mac runtime patch; original PR #41635 was closed as too broad, but Amy still needs the app-wrapper/TCC workflow. | +| `835926beb` | `fix: enable GPT-5.5 priority processing fast mode` | Likely partially superseded by broader upstream fast-routing work; keep as local regression coverage until next release comparison proves redundant. | +| `a798832f7` | `fix(gateway): keep macOS launchd runtime paths logical` | Upstream-worthy/local launchd hardening; partially related upstream fixes exist, but this exact logical-path/app-wrapper workflow remains local. | +| `850e92d6f` | `fix(deps): restore CVE-fixed pyproject pins` | Partially upstream-covered; keep until upstream fully covers the direct dependency-pin/lock consistency Amy needs. | +| `9286c41c8` | `feat(status): restore model and context in gateway status` | Partially upstream-covered; local provider/context delta remains needed for Amy's `/status` workflow. | +| `c741830f0` | `feat(resume): restore cross-platform full session listing` | Upstream has `/sessions`, but not the exact `/resume --all/--full` compatibility workflow; keep locally, possible compatibility PR. | +| `505373d78` | `docs(patches): update upstream PR reconciliation` | Private fork documentation - do not upstream. | +| `f7bcf08bd` | `fix(mattermost): caption file-only media posts` | Upstream-worthy and still locally needed; upstream PR #48014 open. | +| `538fd6ec6` | `fix(mattermost): make post length configurable` | Upstream-worthy and still locally needed; upstream PR #48015 open. | +| `8927afe69` | `feat(tools): keep send_message in explicit messaging toolset` | Amy-local trusted-runtime policy patch; upstream deliberately removed broad agent-callable `send_message`. | +| `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | Upstream-worthy test flake fix; no upstream PR yet. | +| `f1abee591` | `fix(gateway): harden self-management guard` | Upstream-worthy safety fix; no upstream PR yet. | +| `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | Semantically likely superseded on upstream `main`, but locally needed against `v2026.6.19`; probably droppable next release. | + +### Push/Upgrade Implications + +- Local `amy/patches` is the authoritative validated stack after the v0.17 restart smoke. +- Rebased patch-stack pushes require `--force-with-lease`; after the 2026-06-22 push, `origin/amy/patches` matched local `amy/patches`. +- Future rebase watchpoints: MoA redesign, GPT fast-mode routing, dependency pins, Raft quieting, and macOS launchd/app-wrapper split. +- Private patches to preserve across future upgrades: Amy platform hints, private patch docs, and explicit `send_message` messaging toolset unless Wolfram changes the policy. + +--- + ## 2026-06-21 - Launchd Restart Consumes Deferred Plist Reloads **Problem:** During an in-gateway launchd plist refresh, Hermes writes a @@ -117,10 +195,10 @@ Wolfram approved Amy's "best local solution" for `send_message`. **Session reference:** 2026-06-17 Mattermost thread on reducing long-post splitting and repeated `Show more` clicks. --- -## Current Upstream PR Reconciliation (2026-06-08) +## Historical Upstream PR Reconciliation (2026-06-08) Counted with `git describe --tags --abbrev=0 HEAD` and `git rev-list --count v2026.6.5..HEAD`. -Do not count against stale `main`; tag-to-HEAD is the authoritative patch stack. +Do not count by diffing against a moving upstream branch or an unsynced fork branch; tag-to-HEAD is the authoritative patch stack. Fork `main`, when synced correctly, is the release-base mirror for `amy/patches`. | Commit | Subject | Upstream disposition | |---|---|---| @@ -189,12 +267,11 @@ Private/local-only patches after reconciliation: Amy platform/persona hints (`34 --- ## Historical v0.6.0 Patch Notes - **Base:** Hermes Agent v0.6.0 (v2026.3.30) -**Patch Period:** March 30–31, 2026 +**Patch Period:** March 30-31, 2026 **Author:** Amy Ravenwolf <amy@ravenwolf.de> -> 10 patches on top of upstream v0.6.0 — session management, tool progress relay, platform hints, WhatsApp fixes, and cross-platform /resume. +> 10 patches on top of upstream v0.6.0 - session management, tool progress relay, platform hints, WhatsApp fixes, and cross-platform /resume. --- @@ -423,4 +500,4 @@ aa061c56 fix: WhatsApp voice messages + bridge audio download + npm deps --- **Branch:** `amy/patches` (7 commits ahead of `upstream/main`) -**No merge conflicts with upstream. All patch files verified identical to pre-squash.** +**No merge conflicts with upstream. All patch files verified identical to pre-squash.** \ No newline at end of file From 3e67d698bdd12da4dbbff585e9e5fa32ccaa4149 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sun, 14 Jun 2026 15:46:37 +0200 Subject: [PATCH 16/28] fix(mattermost): caption file-only media posts --- RELEASE_amy-patches.md | 87 +++++++++++++++++++++++++ plugins/platforms/mattermost/adapter.py | 28 +++++++- tests/gateway/test_mattermost.py | 43 ++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 5dfa8fd71c5c3..5a65289619b75 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -343,6 +343,93 @@ editable root package on the rebased upstream version, and records uv's 24h **Verification:** `uv lock --check`, metadata consistency assertions for `pyproject.toml`/`uv.lock`, and `git diff --check`. +### Mattermost Plugin Thread Routing: Keep All Conversation Bubbles in Threads (2026-05-31) + +**Problem:** After the Hermes Agent 0.15.x upgrade, Mattermost delivery was +served by the plugin adapter path. Our earlier thread fixes covered parts of the +old built-in adapter, but the plugin `send()` path ignored `metadata.thread_id`. +Gateway status/commentary/interim bubbles rely on that metadata, so they landed +as flat channel posts while tool-progress bubbles could still appear in the +thread. In other channels the inverse could happen depending on which send path +was used. Conversation context became split across channel and thread. + +**Solution:** Re-ported and expanded the Mattermost thread-routing fix on the +plugin adapter: `send()`, URL/local file uploads, voice/video/document sends, +and batched multi-image MEDIA posts now resolve `reply_to` or +`metadata.thread_id` to a valid Mattermost `root_id`, refuse silent flat-channel +fallback when a threaded post is rejected, and top-level channel posts in +`reply_mode=thread` seed their own post ID as the conversation thread root while +DMs stay stable. Gateway progress routing now treats Mattermost like Slack for +top-level thread roots instead of requiring a pre-existing `source.thread_id`. + +**Affected files:** `gateway/run.py`, +`plugins/platforms/mattermost/adapter.py`, `tests/gateway/test_mattermost.py` + +**Session reference:** 2026-05-31 Mattermost thread-routing regression review. + +**Verification:** `python3 -m py_compile gateway/run.py +plugins/platforms/mattermost/adapter.py tests/gateway/test_mattermost.py`, +`tests/gateway/test_mattermost.py`, `tests/gateway/test_status_command.py`, +`tests/gateway/test_resume_command.py`, `tests/hermes_cli/test_commands.py`. + +### Mattermost Visible Media Posts: Caption Empty Attachments (2026-06-14) + +**Problem:** Mattermost accepted Hermes media delivery posts that contained only +`file_ids` and an empty `message`, and the server-side API showed the uploaded +image with preview metadata. In the live Mattermost thread, however, Wolfram did +not see the new GPT Image 2 attachment even though earlier media posts had used +the same empty-body pattern. Empty file-only thread replies are too easy for +clients to hide, miss, or fail to surface in a busy thread. + +**Solution:** Mattermost file delivery now preserves explicit captions, but when +a local/URL/batched image or file upload has no caption it adds a minimal visible +filename line such as `📎 example.png` to the post body. The attachment remains a +native Mattermost `file_ids` upload in the same thread, but the post is no +longer visually empty. + +**Affected files:** `plugins/platforms/mattermost/adapter.py`, +`tests/gateway/test_mattermost.py` + +**Session reference:** 2026-06-14 Mattermost media-visibility regression review. + +**Verification:** `tests/gateway/test_mattermost.py::TestMattermostSend`, +`tests/gateway/test_mattermost.py::TestMattermostFileUpload`, +`tests/gateway/test_mattermost.py::TestMattermostFormatMessage`, and full +`tests/gateway/test_mattermost.py`. + +### Mattermost Delivery Hygiene: Block Scratch Leaks and Final-Only Flat Fallback (2026-06-01) + +**Problem:** A large/broken Mattermost thread exposed two coupled delivery +hazards. Internal scratch/commentary text such as `_thinking`/reasoning progress +could become visible in persistent Mattermost threads when global display toggles +were enabled, and a rejected `root_id` could make a real final answer appear lost. +At the same time, allowing every failed threaded send to fall back flat would +spray tool/status/progress noise into the parent channel. + +**Solution:** Mattermost now requires an explicit +`display.platforms.mattermost.*` opt-in for scratch displays like +`thinking_progress`, `show_reasoning`, and `interim_assistant_messages`; global +display settings no longer leak those into Mattermost by default. Final +user-visible text/media/file replies use the existing `notify=True` metadata +marker, and the Mattermost plugin adapter uses that marker to retry rejected +threaded user-visible posts flat in the channel with a visible warning prefix +only when the failure looks like a broken thread/root. Tool/status/progress and +other non-notify sends remain thread-strict and do not flat-fallback. + +**Affected files:** `gateway/run.py`, `gateway/platforms/base.py`, +`gateway/stream_consumer.py`, `plugins/platforms/mattermost/adapter.py`, +`tests/gateway/test_mattermost.py`, +`tests/gateway/test_stream_consumer_thread_routing.py` + +**Session reference:** 2026-06-01 Mattermost delivery-hygiene regression review. + +**Verification:** targeted RED/GREEN tests for Mattermost display opt-in, +notify-only broken-thread fallback, and stream `notify=True` metadata; full relevant regression +files `tests/gateway/test_mattermost.py`, `tests/gateway/test_stream_consumer.py`, +`tests/gateway/test_stream_consumer_thread_routing.py`, +`tests/gateway/test_stream_consumer_fresh_final.py`, and +`tests/gateway/test_stream_consumer_draft.py`. + ### Mattermost MEDIA Attachments: Keep Thread Context (2026-05-19) **Problem:** In Mattermost `MATTERMOST_REPLY_MODE=thread`, normal text replies diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bc2280cb6d262..be3f710ab5fa3 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -36,6 +36,26 @@ # practical limit for readable messages — matching OpenClaw's choice). MAX_POST_LENGTH = 4000 + +def _file_post_message(caption: Optional[str], filenames: List[str]) -> str: + """Return a Mattermost post body for file attachments. + + Mattermost accepts posts whose only visible content is ``file_ids`` with an + empty ``message``, but some clients/threads can make those file-only replies + easy to miss. Use the explicit caption when present; otherwise include a + tiny filename line so attachment posts remain visible and discoverable. + """ + body = (caption or "").strip() + if body: + return body + clean = [str(name).strip() for name in filenames if str(name).strip()] + if not clean: + return "📎 Attachment" + if len(clean) == 1: + return f"📎 {clean[0]}" + return "\n".join(f"📎 {name}" for name in clean) + + # Channel type codes returned by the Mattermost API. _CHANNEL_TYPE_MAP = { "D": "dm", @@ -528,7 +548,7 @@ async def _send_url_as_file( payload: Dict[str, Any] = { "channel_id": chat_id, - "message": caption or "", + "message": _file_post_message(caption, [fname]), "file_ids": [file_id], } resolved_root = await self._thread_root_for_send(reply_to, metadata) @@ -569,7 +589,7 @@ async def _send_local_file( payload: Dict[str, Any] = { "channel_id": chat_id, - "message": caption or "", + "message": _file_post_message(caption, [fname]), "file_ids": [file_id], } resolved_root = await self._thread_root_for_send(reply_to, metadata) @@ -611,6 +631,7 @@ async def send_multiple_images( await asyncio.sleep(human_delay) file_ids: List[str] = [] + uploaded_names: List[str] = [] caption_parts: List[str] = [] try: for image_url, alt_text in chunk: @@ -651,13 +672,14 @@ async def send_multiple_images( fid = await self._upload_file(chat_id, file_data, fname, ct) if fid: file_ids.append(fid) + uploaded_names.append(fname) if not file_ids: continue payload: Dict[str, Any] = { "channel_id": chat_id, - "message": "\n".join(caption_parts), + "message": _file_post_message("\n".join(caption_parts), uploaded_names), "file_ids": file_ids, } resolved_root = await self._thread_root_for_send(None, metadata) diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index 1fedb30a019ff..d3b7bf24f291d 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -475,6 +475,49 @@ async def test_send_api_failure(self): assert result.success is False + @pytest.mark.asyncio + async def test_send_image_file_uses_metadata_thread_id(self, tmp_path): + """Local file uploads should keep Mattermost thread context from metadata.""" + self.adapter._reply_mode = "thread" + image_path = tmp_path / "example.png" + image_path.write_bytes(b"png") + self.adapter._upload_file = AsyncMock(return_value="file_123") + self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""}) + self.adapter._api_post = AsyncMock(return_value={"id": "post_with_file"}) + + result = await self.adapter.send_image_file( + "channel_1", + str(image_path), + metadata={"thread_id": "root_post_123"}, + ) + + assert result.success is True + payload = self.adapter._api_post.call_args[0][1] + assert payload["root_id"] == "root_post_123" + assert payload["file_ids"] == ["file_123"] + assert payload["message"] == "📎 example.png" + + @pytest.mark.asyncio + async def test_send_multiple_images_uses_metadata_thread_id(self, tmp_path): + """Batched MEDIA image uploads should stay inside the Mattermost thread.""" + self.adapter._reply_mode = "thread" + image_path = tmp_path / "example.png" + image_path.write_bytes(b"png") + self.adapter._upload_file = AsyncMock(return_value="file_123") + self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""}) + self.adapter._api_post = AsyncMock(return_value={"id": "post_with_file"}) + + await self.adapter.send_multiple_images( + "channel_1", + [(f"file://{image_path}", "")], + metadata={"thread_id": "root_post_123"}, + ) + + payload = self.adapter._api_post.call_args[0][1] + assert payload["root_id"] == "root_post_123" + assert payload["file_ids"] == ["file_123"] + assert payload["message"] == "📎 example.png" + # --------------------------------------------------------------------------- # WebSocket event parsing From 7ef1ea78b1ad55448ba71419dfc04623548fcb1d Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Wed, 17 Jun 2026 05:32:06 +0200 Subject: [PATCH 17/28] fix(mattermost): make post length configurable --- hermes_cli/config.py | 1 + plugins/platforms/mattermost/adapter.py | 74 +++++++++++++++--- tests/gateway/test_mattermost.py | 100 +++++++++++++++++++++++- tools/send_message_tool.py | 12 +++ 4 files changed, 172 insertions(+), 15 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d36f7e8a9c978..c881fd680fb65 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2151,6 +2151,7 @@ def _ensure_hermes_home_managed(home: Path): "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "channel_prompts": {}, # Per-channel ephemeral system prompts + "max_post_length": 4000, # Per-post chunk limit; max 16383 on Mattermost 5+ }, # Matrix platform settings (gateway mode) diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index be3f710ab5fa3..6a2d87904596a 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -9,6 +9,7 @@ MATTERMOST_TOKEN Bot token or personal-access token MATTERMOST_ALLOWED_USERS Comma-separated user IDs MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery + MATTERMOST_MAX_POST_LENGTH Optional outbound chunk limit (max 16383) """ from __future__ import annotations @@ -32,9 +33,37 @@ logger = logging.getLogger(__name__) -# Mattermost post size limit (server default is 16383, but 4000 is the -# practical limit for readable messages — matching OpenClaw's choice). -MAX_POST_LENGTH = 4000 +# Mattermost post size limits. Mattermost's current hard product limit is +# 16,383 characters per post (65,535 bytes / worst-case 4 bytes per rune). +# Keep the legacy 4,000-character default for upstream compatibility, but let +# users raise it when they prefer fewer larger Mattermost posts. +MATTERMOST_SERVER_MAX_POST_LENGTH = 16383 +MIN_MAX_POST_LENGTH = 500 +DEFAULT_MAX_POST_LENGTH = 4000 +MAX_POST_LENGTH = DEFAULT_MAX_POST_LENGTH + + +def _coerce_max_post_length(value: Any, default: int = DEFAULT_MAX_POST_LENGTH) -> int: + """Return a safe Mattermost post length from config/env input.""" + try: + limit = int(value) + except (TypeError, ValueError): + limit = default + if limit < MIN_MAX_POST_LENGTH: + limit = default + return min(limit, MATTERMOST_SERVER_MAX_POST_LENGTH) + + +def _resolve_max_post_length(extra: Optional[Dict[str, Any]] = None) -> int: + """Resolve the effective Mattermost post length. + + Environment wins over config.yaml/PlatformConfig extras so emergency runtime + overrides can take effect without editing config files. + """ + raw = os.getenv("MATTERMOST_MAX_POST_LENGTH") + if raw is None and isinstance(extra, dict): + raw = extra.get("max_post_length") + return _coerce_max_post_length(raw) def _file_post_message(caption: Optional[str], filenames: List[str]) -> str: @@ -110,6 +139,12 @@ def __init__(self, config: PlatformConfig): self._reconnect_task: Optional[asyncio.Task] = None self._closing = False + # Effective outbound post length. Expose the same value through + # MAX_MESSAGE_LENGTH because streaming/progress code probes adapters + # with getattr(adapter, "MAX_MESSAGE_LENGTH", ...). + self.max_post_length: int = _resolve_max_post_length(config.extra) + self.MAX_MESSAGE_LENGTH: int = self.max_post_length + # Reply mode: "thread" to nest replies, "off" for flat messages. self._reply_mode: str = ( config.extra.get("reply_mode", "") @@ -360,7 +395,7 @@ async def send( return SendResult(success=True) formatted = self.format_message(content) - chunks = self.truncate_message(formatted, MAX_POST_LENGTH) + chunks = self.truncate_message(formatted, self.max_post_length) last_id = None for chunk in chunks: @@ -1198,11 +1233,24 @@ def _apply_yaml_config(yaml_cfg: dict, mattermost_cfg: dict) -> dict | None: model and merely owns the YAML→env translation here, next to the adapter that consumes it. - Env vars take precedence over YAML — every assignment is guarded - by ``not os.getenv(...)`` so an explicit env var survives a config.yaml - update. Returns ``None`` because no extras are seeded into - ``PlatformConfig.extra`` directly (everything flows through env). + Env vars take precedence over YAML — assignments are guarded + by ``not os.getenv(...)`` so explicit env vars survive config.yaml + updates. Auth/response settings flow through env; ``max_post_length`` is + also returned as ``PlatformConfig.extra`` so adapter instances and direct + send paths can see the effective limit. """ + seeded: Dict[str, Any] = {} + + if "max_post_length" in mattermost_cfg: + raw_limit = os.getenv("MATTERMOST_MAX_POST_LENGTH") + if raw_limit is None: + raw_limit = str(mattermost_cfg["max_post_length"]) + coerced_limit = _coerce_max_post_length(raw_limit) + os.environ["MATTERMOST_MAX_POST_LENGTH"] = str(coerced_limit) + else: + coerced_limit = _coerce_max_post_length(raw_limit) + seeded["max_post_length"] = coerced_limit + if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"): os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower() frc = mattermost_cfg.get("free_response_channels") @@ -1216,7 +1264,7 @@ def _apply_yaml_config(yaml_cfg: dict, mattermost_cfg: dict) -> dict | None: if isinstance(ac, list): ac = ",".join(str(v) for v in ac) os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac) - return None # all settings flow through env; nothing to merge into extras + return seeded or None # auth/response settings flow through env; limits are seeded into extras # --------------------------------------------------------------------------- @@ -1280,10 +1328,10 @@ def register(ctx) -> None: # adapter" when cron runs separately from the gateway. Mirrors # the Discord / Teams pattern. standalone_sender_fn=_standalone_send, - # Mattermost practical post-length limit (server default is 16383 - # but 4000 is the readable threshold the adapter has used since - # day one). - max_message_length=MAX_POST_LENGTH, + # Mattermost default post-length limit. Runtime adapter instances and + # direct sends can override this via ``mattermost.max_post_length`` or + # ``MATTERMOST_MAX_POST_LENGTH`` up to Mattermost's 16,383-char cap. + max_message_length=_resolve_max_post_length(), # Display emoji="💬", allow_update_command=True, diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index d3b7bf24f291d..8bec3e1713489 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -187,13 +187,16 @@ def test_mattermost_url_warning_without_url(self, monkeypatch): # Adapter format / truncate # --------------------------------------------------------------------------- -def _make_adapter(): +def _make_adapter(extra=None): """Create a MattermostAdapter with mocked config.""" from plugins.platforms.mattermost.adapter import MattermostAdapter + adapter_extra = {"url": "https://mm.example.com"} + if extra: + adapter_extra.update(extra) config = PlatformConfig( enabled=True, token="test-token", - extra={"url": "https://mm.example.com"}, + extra=adapter_extra, ) adapter = MattermostAdapter(config) return adapter @@ -262,6 +265,99 @@ def test_exactly_at_limit(self): chunks = self.adapter.truncate_message(msg, 4000) assert len(chunks) == 1 + def test_configured_max_post_length_exposed_to_streaming(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + adapter = _make_adapter({"max_post_length": 16000}) + assert adapter.max_post_length == 16000 + assert adapter.MAX_MESSAGE_LENGTH == 16000 + + def test_max_post_length_clamped_to_mattermost_server_limit(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + adapter = _make_adapter({"max_post_length": 999999}) + assert adapter.max_post_length == 16383 + assert adapter.MAX_MESSAGE_LENGTH == 16383 + + def test_tiny_max_post_length_falls_back_to_safe_default(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + adapter = _make_adapter({"max_post_length": 1}) + assert adapter.max_post_length == 4000 + assert adapter.MAX_MESSAGE_LENGTH == 4000 + + def test_tiny_env_max_post_length_falls_back_to_safe_default(self, monkeypatch): + monkeypatch.setenv("MATTERMOST_MAX_POST_LENGTH", "1") + adapter = _make_adapter({"max_post_length": 16000}) + assert adapter.max_post_length == 4000 + + def test_env_max_post_length_overrides_config_extra(self, monkeypatch): + monkeypatch.setenv("MATTERMOST_MAX_POST_LENGTH", "16000") + adapter = _make_adapter({"max_post_length": 4000}) + assert adapter.max_post_length == 16000 + + def test_apply_yaml_config_maps_max_post_length(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + from plugins.platforms.mattermost.adapter import _apply_yaml_config + + seeded = _apply_yaml_config({}, {"max_post_length": 16000}) + + assert os.getenv("MATTERMOST_MAX_POST_LENGTH") == "16000" + assert seeded == {"max_post_length": 16000} + + def test_apply_yaml_config_tiny_max_post_length_seeds_safe_default(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + from plugins.platforms.mattermost.adapter import _apply_yaml_config + + seeded = _apply_yaml_config({}, {"max_post_length": 1}) + + assert os.getenv("MATTERMOST_MAX_POST_LENGTH") == "4000" + assert seeded == {"max_post_length": 4000} + + @pytest.mark.asyncio + async def test_send_uses_configured_max_post_length(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + adapter = _make_adapter({"max_post_length": 16000}) + adapter._post_preserving_thread = AsyncMock( + return_value={"id": "post-1"} + ) + + result = await adapter.send("channel-id", "x" * 12000) + + assert result.success is True + assert adapter._post_preserving_thread.call_count == 1 + payload = adapter._post_preserving_thread.call_args.args[1] + assert payload["message"] == "x" * 12000 + + @pytest.mark.asyncio + async def test_send_message_tool_uses_configured_max_post_length(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_MAX_POST_LENGTH", raising=False) + from tools import send_message_tool + + sent_chunks = [] + + async def fake_send_via_adapter( + platform, + pconfig, + chat_id, + message, + thread_id=None, + media_files=None, + force_document=False, + ): + sent_chunks.append(message) + return {"success": True, "message_id": f"post-{len(sent_chunks)}"} + + monkeypatch.setattr(send_message_tool, "_send_via_adapter", fake_send_via_adapter) + pconfig = PlatformConfig(enabled=True, extra={"max_post_length": 16000}) + + result = await send_message_tool._send_to_platform( + Platform.MATTERMOST, + pconfig, + "channel-id", + "x" * 12000, + ) + + assert result == {"success": True, "message_id": "post-1"} + assert sent_chunks == ["x" * 12000] + # --------------------------------------------------------------------------- # Send diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 72311f87c41bb..c50dd4df0c019 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -761,6 +761,18 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, except Exception: pass + # Mattermost's plugin registry exposes the default limit, but the live + # PlatformConfig/env can raise it per deployment. Resolve that here before + # chunking so cron/send_message delivery matches the gateway adapter. + if platform == Platform.MATTERMOST: + try: + from plugins.platforms.mattermost.adapter import ( + _resolve_max_post_length, + ) + _MAX_LENGTHS[platform] = _resolve_max_post_length(getattr(pconfig, "extra", {})) + except Exception: + pass + # Smart-chunk the message to fit within platform limits. # For short messages or platforms without a known limit this is a no-op. # Telegram measures length in UTF-16 code units, not Unicode codepoints. From 852d51d86f596a1ae9c55ede2184a2d27fd0f598 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sun, 21 Jun 2026 14:42:40 +0200 Subject: [PATCH 18/28] feat(tools): keep send_message in explicit messaging toolset --- RELEASE_amy-patches.md | 18 +++++++++++------ hermes_cli/tools_config.py | 3 ++- tests/hermes_cli/test_tools_config.py | 26 ++++++++++++++++++++++++ tests/tools/test_send_message_tool.py | 13 ++++++++++++ tools/send_message_tool.py | 29 +++++++++++++++------------ toolsets.py | 6 ++++++ 6 files changed, 75 insertions(+), 20 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 5a65289619b75..73953cb44290c 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -153,19 +153,25 @@ Wolfram still wants the capability available when explicitly enabled, while not silently restoring it to every default toolset. **Solution:** Re-registered `send_message` locally under an explicit -`messaging` toolset. This keeps the upstream safety posture for broad/default -Hermes toolsets, but lets Amy opt in via `platform_toolsets` with -`messaging` (for example alongside `hermes-cli`). The shared send engine remains -unchanged for cron delivery, `hermes send`, the gateway kanban notifier, and MCP. +`messaging` toolset and exposed that toolset in the non-interactive +`hermes tools` configurator. This keeps the upstream safety posture for +broad/default Hermes toolsets, but lets Amy opt in via `platform_toolsets` with +`messaging` (for example alongside `hermes-cli` or `hermes-mattermost`). The +shared send engine remains unchanged for cron delivery, `hermes send`, the +gateway kanban notifier, and MCP. **Affected files:** - `tools/send_message_tool.py` +- `toolsets.py` +- `hermes_cli/tools_config.py` - `tests/tools/test_send_message_tool.py` +- `tests/hermes_cli/test_tools_config.py` - `RELEASE_amy-patches.md` -**Verification:** Targeted registry regression in -`tests/tools/test_send_message_tool.py::test_send_message_registered_as_explicit_messaging_toolset`. +**Verification:** Targeted registry/configurator regression in +`tests/tools/test_send_message_tool.py::test_send_message_registered_as_explicit_messaging_toolset` +and `tests/hermes_cli/test_tools_config.py::test_messaging_toolset_is_configurable_but_default_off`. **Session reference:** 2026-06-21 Mattermost Hermes v0.17.0 upgrade/rebase; Wolfram approved Amy's "best local solution" for `send_message`. diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5eec978e180ba..af139da9e9469 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -68,6 +68,7 @@ ("skills", "📚 Skills", "list, view, manage"), ("todo", "📋 Task Planning", "todo"), ("memory", "💾 Memory", "persistent memory across sessions"), + ("messaging", "📨 Messaging", "send messages to connected platforms (explicit opt-in)"), ("context_engine", "🧩 Context Engine", "runtime tools from the active context engine"), ("session_search", "🔎 Session Search", "search past conversations"), ("clarify", "❓ Clarifying Questions", "clarify"), @@ -111,7 +112,7 @@ def gui_toolset_label(label: str) -> str: # `hermes tools` → X (Twitter) Search setup walks users through credential # setup. The tool's check_fn means the schema still won't appear to the # model if the credential later goes missing or expires. -_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"} +_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "messaging"} def _xai_credentials_present() -> bool: diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 235c7d99a28d1..481a5fe81431d 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -91,6 +91,32 @@ def test_configurable_toolsets_include_context_engine(): assert any(ts_key == "context_engine" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) +def test_messaging_toolset_is_configurable_but_default_off(): + """send_message must be user-configurable without returning to default core.""" + assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) + assert "messaging" in _DEFAULT_OFF_TOOLSETS + + default_enabled = _get_platform_tools({}, "mattermost", include_default_mcp_servers=False) + assert "messaging" not in default_enabled + + explicit_enabled = _get_platform_tools( + {"platform_toolsets": {"mattermost": ["hermes-mattermost", "messaging"]}}, + "mattermost", + include_default_mcp_servers=False, + ) + assert "messaging" in explicit_enabled + + +def test_save_platform_tools_preserves_messaging_opt_in(monkeypatch): + saved = {} + monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: saved.update(config)) + + config = {"platform_toolsets": {}} + _save_platform_tools(config, "mattermost", {"web", "messaging"}) + + assert "messaging" in saved["platform_toolsets"]["mattermost"] + + def test_get_platform_tools_active_context_engine_is_enabled_for_explicit_config(): config = { "context": {"engine": "lcm"}, diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 81cee1bb1ded4..b98f8de96e308 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -46,6 +46,19 @@ def _reset_signal_scheduler(): ) +def test_send_message_registered_as_explicit_messaging_toolset(): + """Amy keeps send_message opt-in via the messaging toolset, not core.""" + from tools.registry import registry + from toolsets import resolve_toolset + + entry = registry.get_entry("send_message") + assert entry is not None + assert entry.toolset == "messaging" + assert entry.schema["name"] == "send_message" + assert registry.get_tool_names_for_toolset("messaging") == ["send_message"] + assert resolve_toolset("messaging") == ["send_message"] + + async def _send_discord( token, chat_id, diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index c50dd4df0c019..b3a70d6e5653b 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1911,16 +1911,19 @@ async def _send_yuanbao(chat_id, message, media_files=None): # --- Registry --- -from tools.registry import tool_error - -# NOTE: ``send_message`` is intentionally NOT registered as an agent-callable -# model tool. The agent should not decide on its own to fire off cross-platform -# messages or reactions. The send engine in this module (``_send_to_platform``, -# ``_send_via_adapter``, ``_parse_target_ref``, the per-platform ``_send_*`` -# helpers) remains the shared transport used by: -# - cron delivery (cron/scheduler.py) -# - the ``hermes send`` CLI command (hermes_cli/send_cmd.py) -# - the gateway kanban notifier (dashboard-toggled, outside agent control) -# - the standalone MCP server (mcp_serve.py), which is an opt-in surface -# Those callers import the helpers directly; none of them need the registry -# entry. +from tools.registry import registry, tool_error + +# Amy local policy: keep ``send_message`` available only through an explicit +# ``messaging`` toolset. It stays out of the broad Hermes core toolsets, so +# models do not get cross-platform outbound messaging by default, but trusted +# deployments can opt in via platform_toolsets (e.g. ``hermes-cli`` + +# ``messaging``). The send engine in this module remains the shared transport +# used by cron delivery, ``hermes send``, the gateway kanban notifier, and MCP. +registry.register( + name="send_message", + toolset="messaging", + schema=SEND_MESSAGE_SCHEMA, + handler=send_message_tool, + check_fn=_check_send_message, + emoji="📨", +) diff --git a/toolsets.py b/toolsets.py index a930d54ac3168..91c1db0f11791 100644 --- a/toolsets.py +++ b/toolsets.py @@ -211,6 +211,12 @@ "includes": [] }, + "messaging": { + "description": "Opt-in cross-platform outbound messaging toolset", + "tools": ["send_message"], + "includes": [], + }, + "context_engine": { "description": "Runtime tools exposed by the active context engine", "tools": [], From 24225b186c793a878bb4763fbef093a02172446b Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sun, 21 Jun 2026 14:48:50 +0200 Subject: [PATCH 19/28] test(auxiliary): make codex timeout check deterministic --- tests/agent/test_auxiliary_client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 4d84fdf229a4c..53a805bb81bc8 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3218,11 +3218,16 @@ def create(self, **kwargs): assert fake_client.responses.kwargs["stream"] is True assert response.choices[0].message.content == "summary" - def test_enforces_total_timeout_while_stream_keeps_emitting_events(self): + def test_enforces_total_timeout_while_stream_keeps_emitting_events(self, monkeypatch): + clock = {"now": 1000.0} + events = {"count": 0} + monkeypatch.setattr(time, "monotonic", lambda: clock["now"]) + class _SlowAliveCreateStream: def __iter__(self): for _ in range(5): - time.sleep(0.03) + clock["now"] += 0.03 + events["count"] += 1 yield SimpleNamespace(type="response.in_progress") def close(self): pass @@ -3234,14 +3239,13 @@ def create(self, **kwargs): fake_client = SimpleNamespace(responses=FakeResponses(), close=lambda: None) adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5") - started = time.monotonic() with pytest.raises(TimeoutError): adapter.create( messages=[{"role": "user", "content": "summarize this"}], timeout=0.05, ) - assert time.monotonic() - started < 0.14 + assert events["count"] == 2 class TestCodexAuxiliaryToolMessageConversion: From 12b3b445adf062b6ea1d2767427e3f4dd68ae37c Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sun, 21 Jun 2026 21:35:01 +0200 Subject: [PATCH 20/28] fix(gateway): harden self-management guard PROBLEM: Gateway stop/restart only refused the explicit _HERMES_GATEWAY marker. A gateway-hosted tool subprocess could delete that environment marker with env -u _HERMES_GATEWAY while still remaining under the live gateway process tree, letting a restart kill the active gateway turn and leave launchd unloaded/stale/reload-pending. SOLUTION: Add a shared self-management guard for gateway stop/restart that also checks the structural gateway process-tree detector before any service-manager side effect. The operator message now points to an external shell and warns not to bypass the guard; stale/reload-pending/unloaded launchd repair should use gateway start externally. REVIEW: RED regression reproduced the bypass for stop and restart; GREEN targeted regression passed; test_gateway_service.py passed; focused gateway/tools tests passed; ruff passed; independent Athena review found no blockers. Session: 2026-06-21 Mattermost Hermes v0.17 post-mortem --- RELEASE_amy-patches.md | 49 ++++++++++++++++++++++++ hermes_cli/cron.py | 31 ++++++++++++++- hermes_cli/gateway.py | 34 ++++++++-------- tests/hermes_cli/test_cron.py | 37 +++++++++++++++++- tests/hermes_cli/test_gateway_service.py | 35 +++++++++++++++++ tools/terminal_tool.py | 5 ++- 6 files changed, 171 insertions(+), 20 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 73953cb44290c..a887a1f38c678 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -87,6 +87,55 @@ These old local patches are no longer carried in the current stack because Herme --- +## 2026-06-21 - Gateway Self-Management Guard Hardening + +**Problem:** The stop/restart safety guard only checked the `_HERMES_GATEWAY` +environment marker. A gateway-hosted tool subprocess could remove that marker +with `env -u _HERMES_GATEWAY` and still remain inside the live gateway process +tree, allowing `hermes gateway restart` to SIGTERM the very gateway that was +running the active agent turn. During the v0.17.0 cutover this left launchd +unloaded with a stale plist plus reload-pending marker until an external +operator shell recovered it. Follow-up verification exposed a companion false +positive: the terminal hard-block reused the cron gateway-lifecycle detector on +the whole shell input, so Amy's mandatory full-line call-shot comment containing +"gateway restart" could block the canonical `/amy/scripts/restart-gateway.sh` +helper even when the executable command itself was safe. + +**Solution:** Added a shared self-management guard for `hermes gateway stop` and +`hermes gateway restart` that refuses both the explicit `_HERMES_GATEWAY=1` +case and the structural process-tree case detected by +`_is_running_inside_gateway_process_tree()`. The operator guidance now explicitly +says to use an external shell and warns not to bypass the guard with +`env -u _HERMES_GATEWAY`; stale/reload-pending/unloaded launchd repairs should +be handled with `hermes gateway start` from outside the gateway process tree. +The shared gateway-lifecycle detector now supports an explicit shell-command mode +that strips full-line shell comments before matching, so terminal call-shot +comments do not block the canonical restart helper while executable raw lifecycle +commands still match and stay blocked. Cron prompts keep the default literal scan +because they are natural-language task instructions, not shell syntax. + +**Affected files:** + +- `hermes_cli/gateway.py` +- `hermes_cli/cron.py` +- `tools/terminal_tool.py` +- `tests/hermes_cli/test_gateway_service.py` +- `tests/hermes_cli/test_cron.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: `tests/hermes_cli/test_gateway_service.py::TestLaunchdServiceRecovery::test_gateway_stop_restart_refuse_gateway_tree_even_without_env_marker` failed before the code change because `launchd_stop()` / `launchd_restart()` still ran. +- RED follow-up: `tests/hermes_cli/test_cron.py::TestGatewayLifecycleDetection::test_shell_comment_does_not_block_canonical_restart_script` failed because a full-line call-shot comment containing "gateway restart" tripped the lifecycle detector. +- GREEN: the new gateway tree regression passed after the guard change (`2 passed`). +- GREEN follow-up: `tests/hermes_cli/test_cron.py::TestGatewayLifecycleDetection` -> `6 passed`. +- Targeted module: `tests/hermes_cli/test_gateway_service.py` -> `187 passed` before the restart-script detector follow-up. +- Focused regression after follow-up: `tests/hermes_cli/test_cron.py` -> `10 passed`; `tests/hermes_cli/test_gateway_service.py -k 'refuse_gateway_tree or launchd_stop_waits_for_process_exit'` -> `3 passed, 184 deselected`. + +**Session reference:** 2026-06-21 Mattermost Hermes v0.17.0 upgrade post-mortem; Wolfram provided the recovery report after Codex-Amy revived the unloaded gateway. + +--- + ## 2026-06-21 - Launchd Restart Consumes Deferred Plist Reloads **Problem:** During an in-gateway launchd plist refresh, Hermes writes a diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 86f8e6b09e25e..a74a67d067ca7 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -30,8 +30,35 @@ ) -def _contains_gateway_lifecycle_command(text: str) -> bool: - """Return True if *text* contains a gateway lifecycle command pattern.""" +def _strip_full_line_shell_comments(text: str) -> str: + """Drop shell-style full-line comments before lifecycle matching. + + Gateway lifecycle detection is reused for terminal command strings, where + Hermes requires a human-readable call-shot comment before executable shell + input. A comment like ``# run the gateway restart helper`` must not block + a safe command such as ``/amy/scripts/restart-gateway.sh --dry-run``. Only + full-line comments are ignored; inline/executable command text remains + visible to the lifecycle patterns below. + """ + return "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + + +def _contains_gateway_lifecycle_command( + text: str, + *, + ignore_full_line_shell_comments: bool = False, +) -> bool: + """Return True if *text* contains a gateway lifecycle command pattern. + + ``ignore_full_line_shell_comments`` is only for already-shell-shaped command + text (for example terminal() input). Cron prompts are natural-language task + instructions, not shell syntax, so callers must leave the default literal + scan in place there. + """ + if ignore_full_line_shell_comments: + text = _strip_full_line_shell_comments(text) return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text)) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index dbe943377f2e0..8e9ba02159625 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -6890,6 +6890,22 @@ def _dispatch_all_via_service_manager_if_s6(action: str) -> bool: +def _refuse_gateway_self_management(action: str) -> bool: + """Return True after refusing self-targeting gateway stop/restart commands.""" + if os.getenv("_HERMES_GATEWAY") != "1" and not _is_running_inside_gateway_process_tree(): + return False + + print_error( + f"Refusing to {action} the gateway from inside the gateway process tree.\n" + "This command was blocked to prevent restart loops and self-kill dead zones.\n" + "Run this command from an external shell outside the running gateway; do not " + "bypass this guard with `env -u _HERMES_GATEWAY`.\n" + "If launchd is stale, reload-pending, or unloaded, run `hermes gateway start` " + "from that external shell to reload the service definition." + ) + return True + + def gateway_command(args): """Handle gateway subcommands.""" try: @@ -7252,14 +7268,7 @@ def _gateway_command_inner(args): sys.exit(1) elif subcmd == "stop": - # Defense: refuse self-targeting gateway stop from inside the gateway. - # Prevents agent-initiated kill loops when combined with supervisor KeepAlive. - if os.getenv("_HERMES_GATEWAY") == "1": - print_error( - "Refusing to stop the gateway from inside the gateway process.\n" - "This command was blocked to prevent restart loops.\n" - "Use `hermes gateway stop` from a shell outside the running gateway." - ) + if _refuse_gateway_self_management("stop"): sys.exit(1) stop_all = getattr(args, "all", False) @@ -7345,14 +7354,7 @@ def _gateway_command_inner(args): print(f"✓ Stopped {get_service_name()} service") elif subcmd == "restart": - # Defense: refuse self-targeting gateway restart from inside the gateway. - # Prevents agent-initiated kill loops when combined with supervisor KeepAlive. - if os.getenv("_HERMES_GATEWAY") == "1": - print_error( - "Refusing to restart the gateway from inside the gateway process.\n" - "This command was blocked to prevent restart loops.\n" - "Use `hermes gateway restart` from a shell outside the running gateway." - ) + if _refuse_gateway_self_management("restart"): sys.exit(1) # Try service first, fall back to killing and restarting diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 442433f768f47..43d1e9eb5312a 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -5,7 +5,7 @@ import pytest from cron.jobs import create_job, get_job, list_jobs -from hermes_cli.cron import cron_command +from hermes_cli.cron import cron_command, _contains_gateway_lifecycle_command @pytest.fixture() @@ -121,3 +121,38 @@ def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys): out = capsys.readouterr().out assert "Repeat: ∞" in out + + +class TestGatewayLifecycleDetection: + def test_shell_comment_does_not_block_canonical_restart_script(self): + command = "\n".join( + [ + "# Execute the canonical Hermes gateway restart helper, no ad-hoc runner #", + "set -euo pipefail", + "/amy/scripts/restart-gateway.sh --dry-run", + ] + ) + + assert _contains_gateway_lifecycle_command( + command, + ignore_full_line_shell_comments=True, + ) is False + + def test_cron_prompt_hash_heading_still_blocks_gateway_lifecycle(self): + assert _contains_gateway_lifecycle_command("# hermes gateway restart") is True + + @pytest.mark.parametrize( + "command", + [ + "hermes gateway restart", + "launchctl kickstart -k gui/501/ai.hermes.gateway", + "systemctl --user restart hermes-gateway", + "pkill -f hermes.*gateway", + ], + ) + def test_executable_gateway_lifecycle_commands_stay_blocked(self, command): + assert _contains_gateway_lifecycle_command(command) is True + assert _contains_gateway_lifecycle_command( + command, + ignore_full_line_shell_comments=True, + ) is True diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 655ff1db22016..4beb0fd81fe1a 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1475,6 +1475,41 @@ def fake_wait(**kwargs): assert len(wait_called) == 1 assert wait_called[0] == {"timeout": 10.0, "force_after": 5.0} + @pytest.mark.parametrize("subcmd, forbidden", [("stop", "launchd_stop"), ("restart", "launchd_restart")]) + def test_gateway_stop_restart_refuse_gateway_tree_even_without_env_marker( + self, tmp_path, monkeypatch, capsys, subcmd, forbidden + ): + """The self-management guard must survive `env -u _HERMES_GATEWAY`. + + A gateway-hosted tool subprocess can delete `_HERMES_GATEWAY`, but it + still sits below the live gateway process. Stop/restart must refuse the + process-tree case before touching launchd. + """ + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") + + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: True) + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True) + monkeypatch.setattr( + gateway_cli, + forbidden, + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError(f"{forbidden} must not run from the gateway process tree") + ), + ) + + with pytest.raises(SystemExit) as exc: + gateway_cli._gateway_command_inner(SimpleNamespace(gateway_command=subcmd, all=False, system=False)) + + assert exc.value.code == 1 + captured = capsys.readouterr() + output = captured.err + captured.out + assert "inside the gateway process tree" in output + assert "external shell" in output + def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text("<plist>old content</plist>", encoding="utf-8") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 26d0f425c5698..ba3931f773837 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2067,7 +2067,10 @@ def terminal_tool( # but applies unconditionally (force=True cannot help here). if os.environ.get("_HERMES_GATEWAY") == "1": from hermes_cli.cron import _contains_gateway_lifecycle_command - if _contains_gateway_lifecycle_command(command): + if _contains_gateway_lifecycle_command( + command, + ignore_full_line_shell_comments=True, + ): return json.dumps({ "output": "", "exit_code": 1, From e2aa31a4a42f677d3306cc409ced299e908974e8 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sun, 21 Jun 2026 21:55:39 +0200 Subject: [PATCH 21/28] fix(raft): quiet optional dependency checks PROBLEM: Bundled plugin-platform inventory calls Raft's check_fn from config/status paths even when Raft is unused. The check logged a warning for a missing raft CLI during passive discovery, polluting gateway.error.log for an optional feature. SOLUTION: Gate Raft dependency warnings on explicit opt-in via RAFT_PROFILE or platforms.raft.extra.enabled. Without opt-in, missing aiohttp/raft dependencies remain unavailable but log only at debug level. REVIEW: RED regression reproduced the unwanted warning. GREEN Raft tests passed; targeted Raft + gateway service tests passed; hermes status smoke with RAFT_PROFILE unset produced no Raft stderr/stdout lines; ruff passed; independent Hecate review found no blockers. Session: 2026-06-21 Mattermost Raft optional-platform log quieting --- RELEASE_amy-patches.md | 30 +++++++++++++++++++ plugins/platforms/raft/adapter.py | 45 ++++++++++++++++++++++++++-- tests/gateway/test_raft_adapter.py | 48 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index a887a1f38c678..7bdb80db8742f 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -87,6 +87,36 @@ These old local patches are no longer carried in the current stack because Herme --- +## 2026-06-21 - Raft Optional-Platform Log Quieting + +**Problem:** Bundled plugin-platform inventory calls each plugin entry's +`check_fn()` from gateway config/status code. The Raft adapter treated a missing +`raft` CLI as a warning even when Raft was not configured or enabled, so normal +Hermes starts/status checks could write `[raft] raft CLI not found` into gateway +error logs for an unused optional feature. + +**Solution:** `check_raft_requirements()` now distinguishes explicit Raft opt-in +from passive plugin discovery. Missing `aiohttp` or `raft` remains a warning when +`RAFT_PROFILE` is set or `platforms.raft.extra.enabled` is explicitly truthy in +`config.yaml`; otherwise the unavailable optional dependency is logged at debug +level only. + +**Affected files:** + +- `plugins/platforms/raft/adapter.py` +- `tests/gateway/test_raft_adapter.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: `tests/gateway/test_raft_adapter.py::TestRaftConfig::test_check_requirements_keeps_missing_cli_quiet_without_raft_opt_in` failed because the old check logged `raft CLI not found` at warning level. +- GREEN: new Raft check_fn regression tests passed (`3 passed`). +- Targeted module: `tests/gateway/test_raft_adapter.py` -> `19 passed`. + +**Session reference:** 2026-06-21 Mattermost follow-up: optional Raft feature was screaming like a dying goat in `gateway.error.log` despite no Raft use. + +--- + ## 2026-06-21 - Gateway Self-Management Guard Hardening **Problem:** The stop/restart safety guard only checked the `_HERMES_GATEWAY` diff --git a/plugins/platforms/raft/adapter.py b/plugins/platforms/raft/adapter.py index 5623cef0e5efb..c5cbaf0fc3df8 100644 --- a/plugins/platforms/raft/adapter.py +++ b/plugins/platforms/raft/adapter.py @@ -97,13 +97,54 @@ _RAFT_PROMPT_TURN_IDS: set[str] = set() +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return False + + +def _raft_config_explicitly_enabled() -> bool: + """Return True when config.yaml explicitly opts Raft into dependency checks.""" + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + if not isinstance(raw, dict): + return False + platforms = raw.get("platforms") + if not isinstance(platforms, dict): + return False + raft_cfg = platforms.get("raft") + if not isinstance(raft_cfg, dict): + return False + extra = raft_cfg.get("extra") + if not isinstance(extra, dict): + return False + return _truthy(extra.get("enabled")) + + +def _raft_explicitly_opted_in() -> bool: + return bool(os.getenv("RAFT_PROFILE", "").strip()) or _raft_config_explicitly_enabled() + + def check_raft_requirements() -> bool: """Check if Raft channel dependencies are available.""" + opted_in = _raft_explicitly_opted_in() if not AIOHTTP_AVAILABLE: - logger.warning("[raft] aiohttp is not installed — install with: pip install aiohttp") + if opted_in: + logger.warning("[raft] aiohttp is not installed — install with: pip install aiohttp") + else: + logger.debug("[raft] aiohttp unavailable (optional platform not configured)") return False if not shutil.which("raft"): - logger.warning("[raft] raft CLI not found in PATH — install from https://raft.build") + if opted_in: + logger.warning("[raft] raft CLI not found in PATH — install from https://raft.build") + else: + logger.debug("[raft] raft CLI unavailable (optional platform not configured)") return False return True diff --git a/tests/gateway/test_raft_adapter.py b/tests/gateway/test_raft_adapter.py index 174d18d5ffffe..023a445818cb3 100644 --- a/tests/gateway/test_raft_adapter.py +++ b/tests/gateway/test_raft_adapter.py @@ -1,5 +1,6 @@ """Tests for the Raft channel adapter.""" +import logging import os from unittest.mock import AsyncMock, patch @@ -8,6 +9,7 @@ from aiohttp.test_utils import TestClient, TestServer from gateway.config import Platform, PlatformConfig +from plugins.platforms.raft import adapter as raft_adapter from plugins.platforms.raft.adapter import ( ACTIVITY_DRAIN_SCHEMA, ACTIVITY_EVENT_SCHEMA, @@ -407,6 +409,52 @@ def test_interrupted_turn_reports_error_stop(self): class TestRaftConfig: + def test_check_requirements_keeps_missing_cli_quiet_without_raft_opt_in( + self, monkeypatch, caplog + ): + monkeypatch.delenv("RAFT_PROFILE", raising=False) + monkeypatch.setattr(raft_adapter, "AIOHTTP_AVAILABLE", True) + monkeypatch.setattr(raft_adapter.shutil, "which", lambda name: None) + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"platforms": {"raft": {"extra": {"enabled": False}}}}, + ) + caplog.set_level(logging.DEBUG, logger=raft_adapter.logger.name) + + assert check_raft_requirements() is False + + assert "raft CLI not found" not in caplog.text + assert "raft CLI unavailable" in caplog.text + + def test_check_requirements_warns_missing_cli_when_profile_is_set( + self, monkeypatch, caplog + ): + monkeypatch.setenv("RAFT_PROFILE", "dev") + monkeypatch.setattr(raft_adapter, "AIOHTTP_AVAILABLE", True) + monkeypatch.setattr(raft_adapter.shutil, "which", lambda name: None) + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) + caplog.set_level(logging.WARNING, logger=raft_adapter.logger.name) + + assert check_raft_requirements() is False + + assert "raft CLI not found" in caplog.text + + def test_check_requirements_warns_missing_cli_when_config_explicitly_enabled( + self, monkeypatch, caplog + ): + monkeypatch.delenv("RAFT_PROFILE", raising=False) + monkeypatch.setattr(raft_adapter, "AIOHTTP_AVAILABLE", True) + monkeypatch.setattr(raft_adapter.shutil, "which", lambda name: None) + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"platforms": {"raft": {"extra": {"enabled": True}}}}, + ) + caplog.set_level(logging.WARNING, logger=raft_adapter.logger.name) + + assert check_raft_requirements() is False + + assert "raft CLI not found" in caplog.text + def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch): monkeypatch.setenv("RAFT_PROFILE", "my-agent") From 198b4d3b930124d27e0675b214ea63abb2c54d29 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Tue, 23 Jun 2026 17:54:35 +0200 Subject: [PATCH 22/28] fix(auxiliary): preserve auto fallback policy for vision calls --- RELEASE_amy-patches.md | 44 ++++++++++++++-- agent/auxiliary_client.py | 14 ++++- tests/agent/test_auxiliary_client.py | 79 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 7bdb80db8742f..a2b73e3bc0814 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,7 +1,7 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 21 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, and Raft optional-platform log quieting +**Current patch stack:** 22 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, and auxiliary vision fallback parity **Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained **Author:** Amy Ravenwolf <amy@ravenwolf.de> @@ -14,10 +14,10 @@ Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `21` patches on `amy/patches` +- Current stack: `22` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` -- Exact patch-id absorption check against current `upstream/main`: all 21 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. +- Exact patch-id absorption check against current `upstream/main`: all 22 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. ### Dropped During the v0.17 Rebase @@ -51,8 +51,9 @@ These old local patches are no longer carried in the current stack because Herme | `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | New upstream-worthy test flake fix. | | `f1abee591` | `fix(gateway): harden self-management guard` | New upstream-worthy gateway safety fix, including restart-helper detector follow-up. | | `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | New upstream-worthy optional-plugin noise fix; likely droppable after a release containing upstream's equivalent Raft quieting. | +| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | -### Current 21-Patch Stack +### Current 22-Patch Stack | Commit | Subject | Current classification | |---|---|---| @@ -77,6 +78,7 @@ These old local patches are no longer carried in the current stack because Herme | `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | Upstream-worthy test flake fix; no upstream PR yet. | | `f1abee591` | `fix(gateway): harden self-management guard` | Upstream-worthy safety fix; no upstream PR yet. | | `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | Semantically likely superseded on upstream `main`, but locally needed against `v2026.6.19`; probably droppable next release. | +| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | Upstream-worthy and locally needed while Codex Pro quota can exhaust; keeps auto vision tasks on top-level fallback policy. | ### Push/Upgrade Implications @@ -87,6 +89,40 @@ These old local patches are no longer carried in the current stack because Herme --- +## 2026-06-23 - Auxiliary Vision Auto Fallback Parity + +**Problem:** `auxiliary.vision.provider: auto` resolved the concrete vision client +first, then stored that concrete provider (`openai-codex`) as the request's +provider for error handling. When the main provider hit a 429 subscription usage +limit, the fallback gate treated the call as an explicit `openai-codex` aux +provider instead of an `auto` aux task, so it skipped the top-level +`fallback_providers` chain. Text auxiliary tasks such as compression already kept +`resolved_provider == "auto"` through the same fallback layer and could use the +main fallback chain. + +**Solution:** `call_llm()` and `async_call_llm()` now remember whether the user's +auxiliary selection policy was `auto` before vision resolution substitutes a +concrete backend. Capacity/rate-limit fallback decisions use that remembered +policy, while fallback logging and skip logic still receive the actual failed +provider label. Auto vision calls now try task fallback, then top-level +`fallback_providers`, before the built-in aux discovery chain, matching text aux +fallback layering. + +**Affected files:** + +- `agent/auxiliary_client.py` +- `tests/agent/test_auxiliary_client.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: `tests/agent/test_auxiliary_client.py::TestAuxiliaryFallbackLayering::test_auto_vision_failure_uses_top_level_main_fallback_chain` and `::test_async_auto_vision_failure_uses_top_level_main_fallback_chain` failed because the first Codex 429 was re-raised and `_try_main_fallback_chain()` was never called. +- GREEN: the new sync and async vision auto fallback regressions passed (`2 passed`). + +**Session reference:** 2026-06-23 Mattermost thread after Codex Pro quota exhaustion; Wolfram added an OpenAI API fallback and asked that images and context compression use the general fallback instead of permanent aux pinning. + +--- + ## 2026-06-21 - Raft Optional-Platform Log Quieting **Problem:** Bundled plugin-platform inventory calls each plugin entry's diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 572be83d71696..6cccf1cbd89f3 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -5231,6 +5231,11 @@ def call_llm( """ resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model( task, provider, model, base_url, api_key) + fallback_policy_is_auto = resolved_provider in {"auto", "", None} + # Keep the user's selection policy separate from the concrete backend. + # Vision auto-resolution may return the main provider (e.g. openai-codex), + # but failures from that selected backend should still follow the auto + # fallback layers, including top-level fallback_providers. effective_extra_body = _get_task_extra_body(task) effective_extra_body.update(extra_body or {}) @@ -5589,7 +5594,7 @@ def call_llm( # connection failures are capacity problems, not request constraints. # See #26803: daily token quota (429 + "too many tokens per day") must # fall back just like a 402 credit error. - is_auto = resolved_provider in {"auto", "", None} + is_auto = fallback_policy_is_auto # Capacity errors bypass the explicit-provider gate: the provider # literally cannot serve this request regardless of user intent. is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) @@ -5740,6 +5745,11 @@ async def async_call_llm( """ resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model( task, provider, model, base_url, api_key) + fallback_policy_is_auto = resolved_provider in {"auto", "", None} + # Keep the user's selection policy separate from the concrete backend. + # Vision auto-resolution may return the main provider (e.g. openai-codex), + # but failures from that selected backend should still follow the auto + # fallback layers, including top-level fallback_providers. effective_extra_body = _get_task_extra_body(task) effective_extra_body.update(extra_body or {}) @@ -6038,7 +6048,7 @@ async def async_call_llm( # Capacity errors (payment/quota/connection) bypass the explicit-provider # gate — the provider cannot serve the request regardless of user intent. # See #26803: daily token quota must fall back like a 402 credit error. - is_auto = resolved_provider in {"auto", "", None} + is_auto = fallback_policy_is_auto is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 53a805bb81bc8..89d24af00c869 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1851,6 +1851,11 @@ def _make_payment_err(self): exc.status_code = 402 return exc + def _make_codex_usage_limit_err(self): + exc = Exception("Error code: 429 - {'code': 'usage_limit_reached'}") + setattr(exc, "status_code", 429) + return exc + def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monkeypatch): """Auto aux call failures try per-task then top-level fallback before built-ins.""" primary_client = MagicMock() @@ -1882,6 +1887,80 @@ def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monk "title_generation", "auto", reason="payment error") mock_builtin_chain.assert_not_called() + def test_auto_vision_failure_uses_top_level_main_fallback_chain(self): + """Vision auto keeps the user's top-level fallback chain after main provider failure.""" + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_codex_usage_limit_err() + + main_chain_client = MagicMock() + main_chain_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from OpenAI API fallback")) + ]) + + with patch("agent.auxiliary_client.resolve_vision_provider_client", + return_value=("openai-codex", primary_client, "gpt-5.5")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None)), \ + patch("agent.auxiliary_client._recoverable_pool_provider", + return_value=None), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")) as mock_task_chain, \ + patch("agent.auxiliary_client._try_main_fallback_chain", + return_value=(main_chain_client, "gpt-5.5", "openai-api")) as mock_main_chain, \ + patch("agent.auxiliary_client._try_payment_fallback") as mock_builtin_chain: + result = call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert result.choices[0].message.content == "from OpenAI API fallback" + assert main_chain_client.chat.completions.create.called + mock_task_chain.assert_called_once_with( + "vision", "openai-codex", reason="rate limit") + mock_main_chain.assert_called_once_with( + "vision", "openai-codex", reason="rate limit") + mock_builtin_chain.assert_not_called() + + @pytest.mark.asyncio + async def test_async_auto_vision_failure_uses_top_level_main_fallback_chain(self): + """Async vision auto uses top-level fallback_providers after main provider failure.""" + primary_client = MagicMock() + primary_client.chat.completions.create = AsyncMock( + side_effect=self._make_codex_usage_limit_err() + ) + + sync_fallback_client = MagicMock() + async_fallback_client = MagicMock() + async_fallback_client.chat.completions.create = AsyncMock(return_value=MagicMock(choices=[ + MagicMock(message=MagicMock(content="from async OpenAI API fallback")) + ])) + + with patch("agent.auxiliary_client.resolve_vision_provider_client", + return_value=("openai-codex", primary_client, "gpt-5.5")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None)), \ + patch("agent.auxiliary_client._recoverable_pool_provider", + return_value=None), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")) as mock_task_chain, \ + patch("agent.auxiliary_client._try_main_fallback_chain", + return_value=(sync_fallback_client, "gpt-5.5", "openai-api")) as mock_main_chain, \ + patch("agent.auxiliary_client._try_payment_fallback") as mock_builtin_chain, \ + patch("agent.auxiliary_client._to_async_client", + return_value=(async_fallback_client, "gpt-5.5")): + result = await async_call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert result.choices[0].message.content == "from async OpenAI API fallback" + async_fallback_client.chat.completions.create.assert_awaited_once() + mock_task_chain.assert_called_once_with( + "vision", "openai-codex", reason="rate limit") + mock_main_chain.assert_called_once_with( + "vision", "openai-codex", reason="rate limit") + mock_builtin_chain.assert_not_called() + def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog): """When a user has fallback_chain configured, it's tried BEFORE the main agent model.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") From 180b27597cea9881d0c7cef95e7d21c9ad9df5f4 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Thu, 25 Jun 2026 16:56:44 +0200 Subject: [PATCH 23/28] fix(providers): resolve ProviderProfile plugins in CLI identity Teach the shared CLI provider identity helpers to recognize ProviderProfile plugins and aliases, not only the static Hermes overlay and models.dev providers. This lets declarative provider plugins participate in explicit --provider model switching while preserving existing built-in provider IDs such as OpenCode aliases. --- hermes_cli/auth.py | 1 + hermes_cli/model_switch.py | 2 +- hermes_cli/providers.py | 45 ++++++ hermes_cli/runtime_provider.py | 10 +- .../test_model_switch_custom_providers.py | 142 ++++++++++++++++++ 5 files changed, 195 insertions(+), 5 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d0c70a48def74..7f00c4c6f9902 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -473,6 +473,7 @@ class ProviderConfig: inference_base_url=_pp.base_url, api_key_env_vars=_api_key_vars or _pp.env_vars, base_url_env_var=_base_url_var or "", + extra={"api_mode": _pp.api_mode}, ) # Also register aliases so resolve_provider() resolves them for _alias in _pp.aliases: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7f6fe70d90a1b..a93449ac9ae30 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -736,7 +736,7 @@ def switch_model( user_providers, custom_providers, ) - if pdef is None and explicit_provider.strip().lower() == "custom": + if explicit_provider.strip().lower() == "custom" and (pdef is None or not pdef.base_url): pdef = _bare_custom_provider_def(current_base_url) if pdef is None: _switch_err = ( diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index efc3a8576ed14..beb1e55915fd1 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -389,6 +389,10 @@ class ProviderDef: "bedrock_converse": "bedrock_converse", } +API_MODE_TO_TRANSPORT: Dict[str, str] = { + api_mode: transport for transport, api_mode in TRANSPORT_TO_API_MODE.items() +} + # -- Helper functions --------------------------------------------------------- @@ -402,6 +406,35 @@ def normalize_provider(name: str) -> str: return ALIASES.get(key, key) +def _provider_profile_to_def(profile: Any) -> ProviderDef: + """Convert a declarative ProviderProfile into the CLI ProviderDef shape.""" + env_vars = tuple(str(v) for v in (getattr(profile, "env_vars", ()) or ())) + api_key_env_vars = tuple( + v for v in env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL") + ) + base_url_env_var = next( + (v for v in env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), + "", + ) + api_mode = str(getattr(profile, "api_mode", "") or "chat_completions") + transport = API_MODE_TO_TRANSPORT.get(api_mode, "openai_chat") + display_name = str(getattr(profile, "display_name", "") or profile.name) + description = str(getattr(profile, "description", "") or "") + signup_url = str(getattr(profile, "signup_url", "") or "") + return ProviderDef( + id=profile.name, + name=display_name, + transport=transport, + api_key_env_vars=api_key_env_vars or env_vars, + base_url=str(getattr(profile, "base_url", "") or ""), + base_url_env_var=base_url_env_var, + is_aggregator=False, + auth_type=str(getattr(profile, "auth_type", "api_key") or "api_key"), + doc=description or signup_url, + source="provider-profile", + ) + + def get_provider(name: str) -> Optional[ProviderDef]: """Look up a built-in provider by id or alias. @@ -469,6 +502,18 @@ def get_provider(name: str) -> Optional[ProviderDef]: source="hermes", ) + try: + import providers as provider_registry + + if canonical == "custom": + return None + get_profile = getattr(provider_registry, "get_provider_profile", None) + profile = get_profile(canonical) if get_profile else None + if profile is not None: + return _provider_profile_to_def(profile) + except Exception: + pass + return None diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 68919eaac62e2..184f2f6051b16 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1797,7 +1797,8 @@ def resolve_runtime_provider( if cfg_provider == provider: cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") base_url = cfg_base_url or creds.get("base_url", "").rstrip("/") - api_mode = "chat_completions" + profile_api_mode = str((pconfig.extra or {}).get("api_mode") or "").strip() + api_mode = profile_api_mode or "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) elif provider == "xai": @@ -1824,9 +1825,10 @@ def resolve_runtime_provider( # Auto-detect Anthropic-compatible endpoints by URL convention # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) # plus api.openai.com → codex_responses and api.x.ai → codex_responses. - detected = _detect_api_mode_for_url(base_url) - if detected: - api_mode = detected + if not profile_api_mode: + detected = _detect_api_mode_for_url(base_url) + if detected: + api_mode = detected # Strip trailing /v1 for OpenCode Anthropic models (see comment above). if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 388c82bd3e614..144c502c2b40a 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -8,6 +8,7 @@ import hermes_cli.providers as providers_mod from hermes_cli.model_switch import list_authenticated_providers, switch_model from hermes_cli.providers import resolve_provider_full +from providers.base import ProviderProfile _MOCK_VALIDATION = { @@ -18,6 +19,147 @@ } +def _install_unit_test_provider_profile(monkeypatch) -> ProviderProfile: + profile = ProviderProfile( + name="unit-test-profile-provider", + aliases=("unit-test-profile", "utpp"), + display_name="Unit Test Profile Provider", + description="ProviderProfile-only provider used by resolver tests.", + signup_url="https://unit-test-provider.example/signup", + env_vars=("UTPP_API_KEY", "UTPP_BASE_URL"), + base_url="https://unit-test-provider.example/v1", + api_mode="anthropic_messages", + fallback_models=("unit-profile-model",), + ) + + def fake_get_provider_profile(name): + if name in {profile.name, *profile.aliases}: + return profile + return None + + import providers as provider_registry + from hermes_cli import auth as auth_mod + + monkeypatch.setattr(provider_registry, "get_provider_profile", fake_get_provider_profile) + monkeypatch.setitem( + auth_mod.PROVIDER_REGISTRY, + profile.name, + auth_mod.ProviderConfig( + id=profile.name, + name=profile.display_name, + auth_type="api_key", + inference_base_url=profile.base_url, + api_key_env_vars=("UTPP_API_KEY",), + base_url_env_var="UTPP_BASE_URL", + extra={"api_mode": profile.api_mode}, + ), + ) + for alias in profile.aliases: + monkeypatch.setitem(auth_mod.PROVIDER_REGISTRY, alias, auth_mod.PROVIDER_REGISTRY[profile.name]) + return profile + + +def test_provider_profile_alias_resolves_through_cli_provider_identity(monkeypatch): + """ProviderProfile plugins should work in shared --provider resolution.""" + profile = _install_unit_test_provider_profile(monkeypatch) + + resolved = resolve_provider_full("unit-test-profile") + + assert resolved is not None + assert resolved.id == profile.name + assert resolved.name == profile.display_name + assert resolved.transport == "anthropic_messages" + assert resolved.api_key_env_vars == ("UTPP_API_KEY",) + assert resolved.base_url_env_var == "UTPP_BASE_URL" + assert resolved.base_url == profile.base_url + assert resolved.doc == profile.description + assert resolved.source == "provider-profile" + + +def test_normalize_provider_does_not_trigger_profile_discovery(monkeypatch): + """Hot-path normalization should stay cheap; get_provider handles profiles.""" + import agent.models_dev as models_dev + import providers as provider_registry + + monkeypatch.setattr(models_dev, "get_provider_info", lambda key: None) + + def fail_get_provider_profile(name): + raise AssertionError("normalize_provider must not discover provider profiles") + + monkeypatch.setattr(provider_registry, "get_provider_profile", fail_get_provider_profile) + + assert providers_mod.normalize_provider("missing-profile-provider") == "missing-profile-provider" + + +def test_switch_model_accepts_explicit_provider_profile_alias(monkeypatch): + """Explicit /model --provider should accept ProviderProfile aliases.""" + profile = _install_unit_test_provider_profile(monkeypatch) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider: {"api_key": "profile-key", "base_url": profile.base_url, "source": "test"}, + ) + monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION) + monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None) + + result = switch_model( + raw_input="unit-profile-model", + current_provider="openrouter", + current_model="anthropic/claude-sonnet-4.6", + current_base_url="https://openrouter.ai/api/v1", + current_api_key="unused", + explicit_provider="unit-test-profile", + user_providers={}, + custom_providers=[], + ) + + assert result.success is True + assert result.target_provider == profile.name + assert result.provider_label == profile.display_name + assert result.new_model == "unit-profile-model" + assert result.base_url == profile.base_url + assert result.api_mode == "anthropic_messages" + + +def test_provider_profile_aliases_do_not_override_existing_provider_ids(): + """A profile alias must not recanonicalize existing Hermes/models.dev ids.""" + assert providers_mod.normalize_provider("opencode") == "opencode" + assert providers_mod.is_aggregator("opencode-zen") is True + assert providers_mod.is_aggregator("opencode-go") is True + + +def test_builtin_custom_profile_does_not_change_get_provider_semantics(): + """Bare custom endpoint handling remains owned by model_switch fallback.""" + assert providers_mod.get_provider("custom") is None + + +def test_bare_custom_provider_keeps_current_base_url_for_autodetect(monkeypatch): + """The built-in custom ProviderProfile must not bypass bare-custom fallback.""" + monkeypatch.setattr( + "hermes_cli.runtime_provider._auto_detect_local_model", + lambda base_url: "local-autodetected-model" if base_url == "http://127.0.0.1:11434/v1" else "", + ) + monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION) + monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None) + + result = switch_model( + raw_input="", + current_provider="custom", + current_model="old-local-model", + current_base_url="http://127.0.0.1:11434/v1", + current_api_key="unused", + explicit_provider="custom", + user_providers={}, + custom_providers=[], + ) + + assert result.success is True + assert result.target_provider == "custom" + assert result.new_model == "local-autodetected-model" + assert result.base_url == "http://127.0.0.1:11434/v1" + + def test_list_authenticated_providers_includes_custom_providers(monkeypatch): """No-args /model menus should include saved custom_providers entries.""" monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) From bc8b031e97336c55f358ea9334537faeec2d2c7a Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Thu, 25 Jun 2026 16:59:29 +0200 Subject: [PATCH 24/28] feat(providers): support provider-scoped model headers Add model.provider_headers.<provider> as a provider-scoped header layer on top of backward-compatible model.default_headers. Apply the shared merge logic to main OpenAI-compatible clients, auxiliary clients, async auxiliary conversion, named/custom providers, and ProviderProfile live catalog fetches so attribution/billing/proxy headers do not leak to unrelated providers. --- RELEASE_amy-patches.md | 75 ++++- agent/auxiliary_client.py | 205 +++++++++---- hermes_cli/models.py | 237 ++++++++++++-- hermes_cli/providers.py | 109 ++++++- providers/base.py | 4 +- run_agent.py | 15 +- .../test_auxiliary_user_default_headers.py | 288 ++++++++++++++++++ .../test_provider_scoped_model_headers.py | 145 +++++++++ .../test_provider_attribution_headers.py | 33 ++ 9 files changed, 1011 insertions(+), 100 deletions(-) create mode 100644 tests/hermes_cli/test_provider_scoped_model_headers.py diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index a2b73e3bc0814..8307a290a1e5c 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,7 +1,7 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 22 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, and auxiliary vision fallback parity +**Current patch stack:** 24 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, and generic provider-profile/provider-scoped-header plumbing **Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained **Author:** Amy Ravenwolf <amy@ravenwolf.de> @@ -14,10 +14,10 @@ Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `22` patches on `amy/patches` +- Current stack: `24` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` -- Exact patch-id absorption check against current `upstream/main`: all 22 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. +- Exact patch-id absorption check against current `upstream/main`: all 24 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. ### Dropped During the v0.17 Rebase @@ -52,8 +52,10 @@ These old local patches are no longer carried in the current stack because Herme | `f1abee591` | `fix(gateway): harden self-management guard` | New upstream-worthy gateway safety fix, including restart-helper detector follow-up. | | `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | New upstream-worthy optional-plugin noise fix; likely droppable after a release containing upstream's equivalent Raft quieting. | | `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | +| `new` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | +| `new` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | -### Current 22-Patch Stack +### Current 24-Patch Stack | Commit | Subject | Current classification | |---|---|---| @@ -78,7 +80,9 @@ These old local patches are no longer carried in the current stack because Herme | `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | Upstream-worthy test flake fix; no upstream PR yet. | | `f1abee591` | `fix(gateway): harden self-management guard` | Upstream-worthy safety fix; no upstream PR yet. | | `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | Semantically likely superseded on upstream `main`, but locally needed against `v2026.6.19`; probably droppable next release. | -| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | Upstream-worthy and locally needed while Codex Pro quota can exhaust; keeps auto vision tasks on top-level fallback policy. | +| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | +| `new` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | +| `new` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | ### Push/Upgrade Implications @@ -123,6 +127,67 @@ fallback layering. --- +## 2026-06-25 - Generic Provider Profile and Scoped Header Plumbing + +**Problem:** Reviewing Juan-Lee Pang's CoreWeave Serverless Inference PR exposed +two generic Hermes provider-framework gaps. First, declarative `ProviderProfile` +plugins were not fully visible through the central CLI provider identity and +model-switch path, so a provider plugin could exist but `/model --provider ...` +and runtime resolution still lost important identity metadata. Second, provider +attribution/billing/proxy headers such as project selectors needed a safe +provider-scoped config path; putting them in `model.default_headers` would leak +one provider's metadata to unrelated OpenAI-compatible endpoints. + +**Solution:** The local stack now carries two upstream-worthy generic commits: +`fix(providers): resolve ProviderProfile plugins in CLI identity` and +`feat(providers): support provider-scoped model headers`. ProviderProfile-backed +providers are normalized through the central provider identity path, and +`model.provider_headers.<provider>` is merged into main, auxiliary, async +auxiliary, validation, and live catalog fetch paths without leaking to other +providers. Header-dependent model catalog caches include configured model headers +in their fingerprint, and the Ollama Cloud special cache stores the same request +fingerprint so tenant/project header changes force a refresh. + +**Affected files:** + +- `hermes_cli/auth.py` +- `hermes_cli/model_switch.py` +- `hermes_cli/models.py` +- `hermes_cli/providers.py` +- `hermes_cli/runtime_provider.py` +- `agent/auxiliary_client.py` +- `providers/base.py` +- `run_agent.py` +- `tests/hermes_cli/test_model_switch_custom_providers.py` +- `tests/hermes_cli/test_provider_scoped_model_headers.py` +- `tests/agent/test_auxiliary_user_default_headers.py` +- `tests/run_agent/test_provider_attribution_headers.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- ProviderProfile branch: targeted verification after rebase to current + `upstream/main` passed (`217 passed in 39.19s`). +- Provider-scoped headers branch: initial targeted verification passed + (`33 passed, 9 warnings`), Coven review found cache invalidation and direct + sync auxiliary path gaps, fixes were folded, and follow-up verification passed + (`37 passed, 9 warnings`). The GitHub CI failure in the pre-existing Ollama + Cloud stale-cache test was reproduced locally, fixed by tagging helper-created + cache entries with the current request fingerprint, and verified with the full + Ollama Cloud file plus header suite (`80 passed, 9 warnings`). Devin re-review + found additional async conversion paths where the resolved provider was not + propagated; those paths were fixed and verified with new regressions plus the + same targeted suite (`81 passed, 9 warnings`). +- Coven follow-up (Brigid) verified both blocker fixes as LGTM. +- Public author gates for the upstream PR branches and this public patch stack + pass with `Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>`. Amy-authored + commits remain confined to private repositories by policy. + +**Session reference:** 2026-06-25 Mattermost thread for CoreWeave Serverless +Inference PR #44250 support and generic provider-framework extraction. + +--- + ## 2026-06-21 - Raft Optional-Platform Log Quieting **Problem:** Bundled plugin-platform inventory calls each plugin entry's diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6cccf1cbd89f3..baf4be0659aa5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -357,33 +357,30 @@ def _get_aux_model_for_provider(provider_id: str) -> str: _TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"}) -def _apply_user_default_headers(headers: dict | None) -> dict | None: - """Merge user-configured ``model.default_headers`` onto resolved headers. - - User values take precedence over provider/SDK defaults, mirroring the main - agent client (``AIAgent._apply_user_default_headers``). This lets a - ``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the - OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``, - ``X-Stainless-*``) override them for auxiliary calls too — otherwise the - main turn would succeed but title/compression/vision calls to the same - endpoint would still fail. (#40033) - - Returns the merged dict, or the original ``headers`` (possibly ``None``) - when nothing is configured. No allocation when there are no overrides. +def _apply_user_default_headers( + headers: dict | None, + *, + provider: str | None = None, + base_url: str | None = None, +) -> dict | None: + """Merge user-configured ``model`` headers onto resolved headers. + + ``model.default_headers`` remains the backward-compatible global override. + ``model.provider_headers.<provider>`` applies provider-scoped headers (for + attribution/billing/proxy routing) only when the active provider or base URL + matches, so one OpenAI-compatible provider's metadata does not leak to + another. """ try: - from hermes_cli.config import cfg_get, load_config - user_headers = cfg_get(load_config(), "model", "default_headers") + from hermes_cli.providers import merge_configured_provider_headers + + return merge_configured_provider_headers( + headers, + provider=provider, + base_url=base_url, + ) except Exception: return headers - if not isinstance(user_headers, dict) or not user_headers: - return headers - merged = dict(headers or {}) - for key, value in user_headers.items(): - if value is None: - continue - merged[str(key)] = str(value) - return merged or headers def build_or_headers(or_config: dict | None = None) -> dict: @@ -1513,7 +1510,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra["default_headers"] = dict(_ph_aux.default_headers) except Exception: pass - _merged_aux = _apply_user_default_headers(extra.get("default_headers")) + _merged_aux = _apply_user_default_headers( + extra.get("default_headers"), + provider=provider_id, + base_url=base_url, + ) if _merged_aux: extra["default_headers"] = _merged_aux _client = OpenAI(api_key=api_key, base_url=base_url, **extra) @@ -1553,7 +1554,11 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra["default_headers"] = dict(_ph_aux2.default_headers) except Exception: pass - _merged_aux2 = _apply_user_default_headers(extra.get("default_headers")) + _merged_aux2 = _apply_user_default_headers( + extra.get("default_headers"), + provider=provider_id, + base_url=base_url, + ) if _merged_aux2: extra["default_headers"] = _merged_aux2 _client = OpenAI(api_key=api_key, base_url=base_url, **extra) @@ -1575,17 +1580,27 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op _mark_provider_unhealthy("openrouter", ttl=60) return None, None base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL + headers = _apply_user_default_headers( + build_or_headers(), + provider="openrouter", + base_url=base_url, + ) logger.debug("Auxiliary client: OpenRouter via pool") return OpenAI(api_key=or_key, base_url=base_url, - default_headers=build_or_headers()), model or _OPENROUTER_MODEL + default_headers=headers), model or _OPENROUTER_MODEL or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: _mark_provider_unhealthy("openrouter", ttl=60) return None, None logger.debug("Auxiliary client: OpenRouter") + headers = _apply_user_default_headers( + build_or_headers(), + provider="openrouter", + base_url=OPENROUTER_BASE_URL, + ) return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL, - default_headers=build_or_headers()), model or _OPENROUTER_MODEL + default_headers=headers), model or _OPENROUTER_MODEL def _describe_openrouter_unavailable() -> str: @@ -1676,10 +1691,19 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: _mark_provider_unhealthy("nous", ttl=60) return None, None base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/") + headers = _apply_user_default_headers( + None, + provider="nous", + base_url=base_url, + ) + extra = {} + if headers: + extra["default_headers"] = headers return ( OpenAI( api_key=api_key, base_url=base_url, + **extra, ), model, ) @@ -1950,7 +1974,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: # headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom # endpoint's auxiliary calls too — matching the main agent client so the # whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033) - _custom_headers = _apply_user_default_headers(None) + _custom_headers = _apply_user_default_headers(None, provider="custom", base_url=_clean_base) if _custom_headers: _extra["default_headers"] = _custom_headers if custom_mode == "codex_responses": @@ -3244,10 +3268,20 @@ def _resolve_single_provider( ) return client -def _resolve_auto( +def _auto_provider_key(provider: str) -> str: + """Normalize logged fallback labels back to provider keys for header lookup.""" + key = str(provider or "").strip().lower() + if key.endswith(")") and "(" in key: + key = key.rsplit("(", 1)[1][:-1].strip() + if key == "local/custom": + return "custom" + return key + + +def _resolve_auto_with_provider( main_runtime: Optional[Dict[str, Any]] = None, task: Optional[str] = None, -) -> Tuple[Optional[OpenAI], Optional[str]]: +) -> Tuple[Optional[Any], Optional[str], str]: """Full auto-detection chain. Priority: @@ -3343,7 +3377,7 @@ def _resolve_auto( if client is not None: logger.info("Auxiliary auto-detect: using main provider %s (%s)", main_provider, resolved or main_model) - return client, resolved or main_model + return client, resolved or main_model, _auto_provider_key(resolved_provider) # ── Step 2: user-configured fallback policy ───────────────────────── # In auto mode, respect the task-specific fallback chain first, then the @@ -3354,11 +3388,11 @@ def _resolve_auto( fb_client, fb_model, _fb_label = _try_configured_fallback_chain( task, main_provider or "auto", reason="main provider unavailable") if fb_client is not None: - return fb_client, fb_model + return fb_client, fb_model, _auto_provider_key(_fb_label) fb_client, fb_model, _fb_label = _try_main_fallback_chain( task, main_provider or "auto", reason="main provider unavailable") if fb_client is not None: - return fb_client, fb_model + return fb_client, fb_model, _auto_provider_key(_fb_label) # ── Step 3: aggregator / fallback chain ────────────────────────────── tried = [] @@ -3374,13 +3408,22 @@ def _resolve_auto( label, model or "default", ", ".join(tried)) else: logger.info("Auxiliary auto-detect: using %s (%s)", label, model or "default") - return client, model + return client, model, _auto_provider_key(label) tried.append(label) logger.warning("Auxiliary auto-detect: no provider available (tried: %s). " "Compression, summarization, and memory flush will not work. " "Set OPENROUTER_API_KEY or configure a local model in config.yaml.", ", ".join(tried)) - return None, None + return None, None, "" + + +def _resolve_auto( + main_runtime: Optional[Dict[str, Any]] = None, + task: Optional[str] = None, +) -> Tuple[Optional[Any], Optional[str]]: + """Backward-compatible two-value auto resolver.""" + client, model, _provider = _resolve_auto_with_provider(main_runtime=main_runtime, task=task) + return client, model # ── Centralized Provider Router ───────────────────────────────────────────── @@ -3394,7 +3437,12 @@ def _resolve_auto( # below — never look up auth env vars ad-hoc. -def _to_async_client(sync_client, model: str, is_vision: bool = False): +def _to_async_client( + sync_client, + model: str | None, + is_vision: bool = False, + provider: str | None = None, +): """Convert a sync client to its async counterpart, preserving Codex routing. When ``is_vision=True`` and the underlying base URL is Copilot, the @@ -3453,7 +3501,11 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = dict(_ph_async.default_headers) except Exception: pass - _merged_async = _apply_user_default_headers(async_kwargs.get("default_headers")) + _merged_async = _apply_user_default_headers( + async_kwargs.get("default_headers"), + provider=provider, + base_url=sync_base_url, + ) if _merged_async: async_kwargs["default_headers"] = _merged_async return AsyncOpenAI(**async_kwargs), model @@ -3602,7 +3654,10 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # ── Auto: try all providers in priority order ──────────────────── if provider == "auto": - client, resolved = _resolve_auto(main_runtime=main_runtime, task=task) + client, resolved, auto_provider = _resolve_auto_with_provider( + main_runtime=main_runtime, + task=task, + ) if client is None: return None, None # When auto-detection lands on a non-OpenRouter provider (e.g. a @@ -3615,7 +3670,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "auxiliary provider (using %r instead)", model, resolved) model = None final_model = model or resolved - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + auto_provider = auto_provider or provider + return (_to_async_client(client, final_model, is_vision=is_vision, provider=auto_provider) if async_mode else (client, final_model)) # ── OpenRouter ─────────────────────────────────────────── @@ -3628,7 +3684,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", ) return None, None final_model = _normalize_resolved_model(model or default, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # ── Nous Portal (OAuth) ────────────────────────────────────────── @@ -3645,7 +3701,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # ── OpenAI Codex (OAuth → Responses API) ───────────────────────── @@ -3679,7 +3735,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "but no Codex OAuth token found (run: hermes model)") return None, None final_model = _normalize_resolved_model(model or default, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # ── xAI Grok OAuth (loopback PKCE → Responses API) ─────────────── @@ -3699,7 +3755,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", ) return None, None final_model = _normalize_resolved_model(model or default, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ─────────── @@ -3744,12 +3800,16 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", extra["default_headers"] = dict(_ph_custom.default_headers) except Exception: pass - _merged_custom = _apply_user_default_headers(extra.get("default_headers")) + _merged_custom = _apply_user_default_headers( + extra.get("default_headers"), + provider="custom", + base_url=_clean_base, + ) if _merged_custom: extra["default_headers"] = _merged_custom client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) client = _wrap_if_needed(client, final_model, custom_base, custom_key) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # Try custom first, then API-key providers (Codex excluded here: # falling through to Codex with no model is a stale-constant trap). @@ -3764,7 +3824,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", _raw_ckey = getattr(client, "api_key", "") _ckey = "" if (callable(_raw_ckey) and not isinstance(_raw_ckey, str)) else str(_raw_ckey or "") client = _wrap_if_needed(client, final_model, _cbase, _ckey) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) logger.warning("resolve_provider_client: custom/main requested " "but no endpoint credentials found") @@ -3823,7 +3883,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", raw_base_for_wrap = custom_base _clean_base2, _dq2 = _extract_url_query_params(openai_base) _extra2 = {"default_query": _dq2} if _dq2 else {} - _headers2 = _apply_user_default_headers(_extra2.get("default_headers")) + _headers2 = _apply_user_default_headers( + _extra2.get("default_headers"), + provider=provider, + base_url=_clean_base2, + ) if _headers2: _extra2["default_headers"] = _headers2 logger.debug( @@ -3848,11 +3912,15 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", _fallback_base = _to_openai_base_url(custom_base) _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base) _fb_extra = {"default_query": _fb_dq} if _fb_dq else {} - _fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers")) + _fb_headers = _apply_user_default_headers( + _fb_extra.get("default_headers"), + provider=provider, + base_url=_fb_clean, + ) if _fb_headers: _fb_extra["default_headers"] = _fb_headers client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) sync_anthropic = AnthropicAuxiliaryClient( real_client, final_model, custom_key, custom_base, is_oauth=False, @@ -3871,7 +3939,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", client = CodexAuxiliaryClient(client, final_model) else: client = _wrap_if_needed(client, final_model, raw_base_for_wrap, custom_key) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) logger.warning( "resolve_provider_client: named custom provider %r has no base_url", @@ -3911,7 +3979,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", ) return None, None final_model = _normalize_resolved_model(model or default_model, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # ── API-key providers from PROVIDER_REGISTRY ───────────────────── @@ -3937,7 +4005,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found") return None, None final_model = _normalize_resolved_model(model or default_model, provider) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) creds = resolve_api_key_provider_credentials(provider) api_key = str(creds.get("api_key", "")).strip() @@ -3973,7 +4041,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if is_native_gemini_base_url(base_url): client = GeminiNativeClient(api_key=api_key, base_url=base_url) logger.debug("resolve_provider_client: %s (%s)", provider, final_model) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) # Provider-specific headers @@ -3999,7 +4067,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", headers.update(_ph_main.default_headers) except Exception: pass - _merged_main = _apply_user_default_headers(headers) + _merged_main = _apply_user_default_headers( + headers, + provider=provider, + base_url=base_url, + ) if _merged_main: headers = _merged_main client = OpenAI(api_key=api_key, base_url=base_url, @@ -4030,7 +4102,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", client = _wrap_if_needed(client, final_model, raw_base_url, api_key) logger.debug("resolve_provider_client: %s (%s)", provider, final_model) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) if pconfig.auth_type == "external_process": @@ -4067,7 +4139,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", args=args, ) logger.debug("resolve_provider_client: %s (%s)", provider, final_model) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) logger.warning("resolve_provider_client: external-process provider %s not " "directly supported", provider) @@ -4103,7 +4175,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", base_url=f"https://bedrock-runtime.{region}.amazonaws.com", ) logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region) - return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + return (_to_async_client(client, final_model, is_vision=is_vision, provider=provider) if async_mode else (client, final_model)) elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}: @@ -4288,7 +4360,12 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ return resolved_provider, None, None final_model = resolved_model or default_model if async_mode: - async_client, async_model = _to_async_client(sync_client, final_model, is_vision=True) + async_client, async_model = _to_async_client( + sync_client, + final_model, + is_vision=True, + provider=resolved_provider, + ) return resolved_provider, async_client, async_model return resolved_provider, sync_client, final_model @@ -4548,7 +4625,12 @@ def _refresh_nous_auxiliary_client( current_loop = _aio.get_event_loop() except RuntimeError: pass - client, final_model = _to_async_client(sync_client, final_model or "", is_vision=is_vision) + client, final_model = _to_async_client( + sync_client, + final_model or "", + is_vision=is_vision, + provider="nous", + ) else: client = sync_client @@ -6094,7 +6176,10 @@ async def async_call_llm( base_url=str(getattr(fb_client, "base_url", "") or "")) # Convert sync fallback client to async async_fb, async_fb_model = _to_async_client( - fb_client, fb_model or "", is_vision=(task == "vision") + fb_client, + fb_model or "", + is_vision=(task == "vision"), + provider=_auto_provider_key(fb_label or ""), ) if async_fb_model and async_fb_model != fb_kwargs.get("model"): fb_kwargs["model"] = async_fb_model diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f84ac69564e53..13c957dc91748 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -7,6 +7,7 @@ from __future__ import annotations +import inspect import json import os import urllib.parse @@ -2256,7 +2257,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) api_key = str(creds.get("api_key") or "").strip() base_url = str(creds.get("base_url") or "").strip() if api_key and base_url: - live = fetch_api_models(api_key, base_url) + live = _fetch_api_models_with_headers( + api_key, + base_url, + headers=_configured_model_headers(provider="stepfun", base_url=base_url), + ) if live: return live except Exception: @@ -2312,7 +2317,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) "https://api.openai.com", ) try: - live = fetch_api_models(api_key, base) + live = _fetch_api_models_with_headers( + api_key, + base, + headers=_configured_model_headers(provider=normalized, base_url=base), + ) if live: if is_default_openai: live_lower = {m.lower() for m in live} @@ -2337,7 +2346,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) api_key = str(creds.get("api_key") or "").strip() base_url = str(creds.get("base_url") or "").strip() if api_key and base_url: - live = fetch_api_models(api_key, base_url) + live = _fetch_api_models_with_headers( + api_key, + base_url, + headers=_configured_model_headers(provider="gmi", base_url=base_url), + ) if live: return live except Exception: @@ -2354,7 +2367,12 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) or os.getenv("OPENROUTER_API_KEY", "") ) api_mode = "anthropic_messages" if _base_url_looks_like_anthropic_messages(base_url) else None - live = fetch_api_models(api_key, base_url, api_mode=api_mode) + live = _fetch_api_models_with_headers( + api_key, + base_url, + api_mode=api_mode, + headers=_configured_model_headers(provider="custom", base_url=base_url), + ) if live: return live # Bedrock uses live discovery keyed by the resolved AWS region so that @@ -2388,7 +2406,17 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if not base_url: base_url = _p.base_url if api_key: - live = _p.fetch_models(api_key=api_key, base_url=base_url or None) + headers = _configured_model_headers( + dict(_p.default_headers), + provider=normalized, + base_url=base_url or _p.base_url, + ) + live = _fetch_profile_models( + _p, + api_key=api_key, + base_url=base_url or "", + headers=headers, + ) if live: # Merge static curated list with live API results so # models that the live endpoint omits (stale cache, @@ -2430,9 +2458,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # # Cache strategy: # - One JSON file at $HERMES_HOME/provider_models_cache.json -# - Per-provider entries keyed by (provider, credential fingerprint) -# - Credential fingerprint = sha256 of env-var values that the provider -# normally reads. Swap your OPENAI_API_KEY and the entry invalidates. +# - Per-provider entries keyed by (provider, credential/header fingerprint) +# - Credential/header fingerprint = hash of env-var values that the provider +# normally reads plus configured model request headers. Swap your +# OPENAI_API_KEY or a provider-scoped tenant header and the entry invalidates. # - 1h TTL by default. `force_refresh=True` skips the cache entirely # and overwrites it on success. # - Only NON-EMPTY results are cached. An empty/None response from a @@ -2449,17 +2478,17 @@ def _provider_models_cache_path() -> Path: def _credential_fingerprint(provider: str) -> str: - """Return a short hash representing the credentials that + """Return a short hash representing the request context that ``provider_model_ids(provider)`` would see right now. - Rotating any of the relevant env vars invalidates the cached entry - for that provider. We hash AT LEAST the api-key + base-url env vars - declared in ``PROVIDER_REGISTRY``. For OAuth-backed providers - (codex, copilot, anthropic-via-claude-code, nous portal), the - relevant tokens live in ``$HERMES_HOME/auth.json`` and external - credential files. Rather than parse every shape, we additionally - fold the mtime of those files into the fingerprint so refreshes - after re-auth bust the cache. + Rotating any of the relevant env vars or configured request headers + invalidates the cached entry for that provider. We hash AT LEAST the + api-key + base-url env vars declared in ``PROVIDER_REGISTRY``. For + OAuth-backed providers (codex, copilot, anthropic-via-claude-code, + nous portal), the relevant tokens live in ``$HERMES_HOME/auth.json`` + and external credential files. Rather than parse every shape, we + additionally fold the mtime of those files into the fingerprint so + refreshes after re-auth bust the cache. """ import hashlib import os as _os @@ -2479,6 +2508,28 @@ def _credential_fingerprint(provider: str) -> str: except Exception: pass + # User-configured request headers can affect provider model listings + # (project/tenant attribution, billing routes, proxy routing headers). + # They are hashed into the cache key, never persisted in cache entries. + try: + from hermes_cli.config import load_config + + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} + if isinstance(model_cfg, dict): + header_cfg: dict[str, Any] = {} + for key in ("default_headers", "provider_headers"): + value = model_cfg.get(key) + if isinstance(value, dict) and value: + header_cfg[key] = value + if header_cfg: + parts.append( + "model_headers=" + + json.dumps(header_cfg, sort_keys=True, default=str) + ) + except Exception: + pass + # OAuth / external-file mtimes that change on re-auth try: from hermes_constants import get_hermes_home @@ -3379,6 +3430,7 @@ def probe_api_models( base_url: Optional[str], timeout: float = 5.0, api_mode: Optional[str] = None, + request_headers: Optional[dict[str, str]] = None, ) -> dict[str, Any]: """Probe a ``/models`` endpoint with light URL heuristics. @@ -3418,6 +3470,10 @@ def probe_api_models( tried: list[str] = [] headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} + if request_headers: + for key, value in request_headers.items(): + if value is not None: + headers[str(key)] = str(value) if api_key and api_mode == "anthropic_messages": headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" @@ -3457,13 +3513,99 @@ def fetch_api_models( base_url: Optional[str], timeout: float = 5.0, api_mode: Optional[str] = None, + headers: Optional[dict[str, str]] = None, ) -> Optional[list[str]]: """Fetch the list of available model IDs from the provider's ``/models`` endpoint. Returns a list of model ID strings, or ``None`` if the endpoint could not be reached (network error, timeout, auth failure, etc.). """ - return probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode).get("models") + return probe_api_models( + api_key, + base_url, + timeout=timeout, + api_mode=api_mode, + request_headers=headers, + ).get("models") + + +def _configured_model_headers( + headers: Optional[dict[str, str]] = None, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, +) -> Optional[dict[str, str]]: + try: + providers_mod = __import__( + "hermes_cli.providers", + fromlist=["merge_configured_provider_headers"], + ) + merge_configured_provider_headers = getattr( + providers_mod, + "merge_configured_provider_headers", + ) + return merge_configured_provider_headers( + headers, + provider=provider, + base_url=base_url, + ) + except Exception: + return headers + + +def _fetch_api_models_with_headers( + api_key: Optional[str], + base_url: Optional[str], + *, + timeout: Optional[float] = None, + api_mode: Optional[str] = None, + headers: Optional[dict[str, str]] = None, +) -> Optional[list[str]]: + kwargs: dict[str, Any] = {} + optional_kwargs: dict[str, Any] = { + "timeout": timeout, + "api_mode": api_mode, + "headers": headers, + } + try: + signature = inspect.signature(fetch_api_models) + parameters = signature.parameters.values() + accepts_var_kwargs = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters) + for key, value in optional_kwargs.items(): + if value is not None and (key in signature.parameters or accepts_var_kwargs): + kwargs[key] = value + except (TypeError, ValueError): + # Built-in fetch_api_models accepts these keywords. If a replacement + # callable does not expose a signature, keep the historical two-arg + # call rather than risking an unexpected-keyword failure. + pass + return fetch_api_models(api_key, base_url, **kwargs) + + +def _fetch_profile_models( + profile: Any, + *, + api_key: str, + base_url: str, + headers: Optional[dict[str, str]], +) -> Optional[list[str]]: + kwargs: dict[str, Any] = { + "api_key": api_key, + "base_url": base_url or None, + } + try: + signature = inspect.signature(profile.fetch_models) + parameters = signature.parameters.values() + if "headers" in signature.parameters or any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters + ): + kwargs["headers"] = headers + except (TypeError, ValueError): + # Some callables do not expose signatures. Preserve the historical + # ProviderProfile.fetch_models(api_key=..., base_url=...) ABI rather + # than risking an unexpected-keyword failure in out-of-tree plugins. + pass + return profile.fetch_models(**kwargs) # --------------------------------------------------------------------------- @@ -3494,11 +3636,17 @@ def _ollama_cloud_cache_path() -> Path: return get_hermes_home() / "ollama_cloud_models_cache.json" -def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: +def _load_ollama_cloud_cache( + *, + ignore_ttl: bool = False, + fingerprint: Optional[str] = None, +) -> Optional[dict]: """Load cached Ollama Cloud models from disk. Args: ignore_ttl: If True, return data even if the TTL has expired (stale fallback). + fingerprint: Optional request-context fingerprint that must match the + cached entry. """ try: cache_path = _ollama_cloud_cache_path() @@ -3511,6 +3659,8 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: models = data.get("models") if not (isinstance(models, list) and models): return None + if fingerprint is not None and data.get("fp") != fingerprint: + return None if not ignore_ttl: cached_at = data.get("cached_at", 0) if (time.time() - cached_at) > _OLLAMA_CLOUD_CACHE_TTL: @@ -3521,13 +3671,20 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: return None -def _save_ollama_cloud_cache(models: list[str]) -> None: +def _save_ollama_cloud_cache( + models: list[str], + *, + fingerprint: Optional[str] = None, +) -> None: """Persist the merged Ollama Cloud model list to disk.""" try: from utils import atomic_json_write cache_path = _ollama_cloud_cache_path() cache_path.parent.mkdir(parents=True, exist_ok=True) - atomic_json_write(cache_path, {"models": models, "cached_at": time.time()}, indent=None) + if fingerprint is None: + fingerprint = _credential_fingerprint("ollama-cloud") + payload = {"models": models, "cached_at": time.time(), "fp": fingerprint} + atomic_json_write(cache_path, payload, indent=None) except Exception: pass @@ -3548,9 +3705,11 @@ def fetch_ollama_cloud_models( Returns a list of model IDs (never None — empty list on total failure). """ + cache_fp = _credential_fingerprint("ollama-cloud") + # 1. Check disk cache if not force_refresh: - cached = _load_ollama_cloud_cache() + cached = _load_ollama_cloud_cache(fingerprint=cache_fp) if cached is not None: return cached["models"] @@ -3562,7 +3721,12 @@ def fetch_ollama_cloud_models( live_models: list[str] = [] if api_key: - result = fetch_api_models(api_key, base_url, timeout=8.0) + result = _fetch_api_models_with_headers( + api_key, + base_url, + timeout=8.0, + headers=_configured_model_headers(provider="ollama-cloud", base_url=base_url), + ) if result: live_models = result @@ -3588,11 +3752,11 @@ def fetch_ollama_cloud_models( seen.add(normalized) merged.append(normalized) if merged: - _save_ollama_cloud_cache(merged) + _save_ollama_cloud_cache(merged, fingerprint=cache_fp) return merged # Total failure — return stale cache if available (ignore TTL) - stale = _load_ollama_cloud_cache(ignore_ttl=True) + stale = _load_ollama_cloud_cache(ignore_ttl=True, fingerprint=cache_fp) if stale is not None: return stale["models"] @@ -3682,10 +3846,16 @@ def validate_requested_model( if normalized == "custom" or normalized.startswith("custom:"): # Try probing with correct auth for the api_mode. + request_headers = _configured_model_headers(provider=normalized, base_url=base_url) if api_mode == "anthropic_messages": - probe = probe_api_models(api_key, base_url, api_mode=api_mode) + probe = probe_api_models( + api_key, + base_url, + api_mode=api_mode, + request_headers=request_headers, + ) else: - probe = probe_api_models(api_key, base_url) + probe = probe_api_models(api_key, base_url, request_headers=request_headers) api_models = probe.get("models") if api_models is not None: if requested_for_lookup in set(api_models): @@ -3885,7 +4055,12 @@ def validate_requested_model( # Anthropic Messages API: many proxies don't implement /v1/models. # Try probing with correct auth; if it fails, accept with a warning. if api_mode == "anthropic_messages": - api_models = fetch_api_models(api_key, base_url, api_mode=api_mode) + api_models = _fetch_api_models_with_headers( + api_key, + base_url, + api_mode=api_mode, + headers=_configured_model_headers(provider=normalized, base_url=base_url), + ) if api_models is not None: if requested_for_lookup in set(api_models): return { @@ -3918,7 +4093,11 @@ def validate_requested_model( } # Probe the live API to check if the model actually exists - api_models = fetch_api_models(api_key, base_url) + api_models = _fetch_api_models_with_headers( + api_key, + base_url, + headers=_configured_model_headers(provider=normalized, base_url=base_url), + ) if api_models is not None: # Gemini's OpenAI-compat /v1beta/openai/models endpoint returns IDs diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index beb1e55915fd1..c384dd7a35f71 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -22,6 +22,7 @@ import logging from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse from utils import base_url_host_matches, base_url_hostname @@ -406,6 +407,113 @@ def normalize_provider(name: str) -> str: return ALIASES.get(key, key) +def _url_path_segments(value: str) -> tuple[str, ...]: + path = urlparse((value or "").strip()).path.rstrip("/") + return tuple(segment for segment in path.split("/") if segment) + + +def _profile_base_url_match_length(base_url: str, profile_base_url: str) -> int: + """Return a path-specific match score for base_url against profile_base_url.""" + if not base_url or not profile_base_url: + return -1 + if base_url_hostname(base_url) != base_url_hostname(profile_base_url): + return -1 + actual_segments = _url_path_segments(base_url) + profile_segments = _url_path_segments(profile_base_url) + if not profile_segments: + return 0 + if len(actual_segments) < len(profile_segments): + return -1 + if actual_segments[: len(profile_segments)] != profile_segments: + return -1 + return len(profile_segments) + + +def infer_provider_from_base_url(base_url: Optional[str]) -> Optional[str]: + """Infer the best ProviderProfile provider id from an OpenAI-compatible base URL.""" + if not base_url: + return None + try: + import providers as provider_registry + + list_profiles = getattr(provider_registry, "list_providers", None) + if list_profiles: + best_name = "" + best_score = -1 + for profile in list_profiles(): + profile_base = str(getattr(profile, "base_url", "") or "") + score = _profile_base_url_match_length(str(base_url), profile_base) + if score > best_score: + best_name = str(getattr(profile, "name", "") or "") + best_score = score + if best_score >= 0 and best_name: + return best_name + except Exception: + pass + return None + + +def merge_configured_provider_headers( + headers: Optional[Dict[str, str]] = None, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, +) -> Optional[Dict[str, str]]: + """Merge global and provider-scoped ``model`` headers from config.yaml. + + ``model.default_headers`` remains the backward-compatible global override. + ``model.provider_headers.<provider>`` adds scoped headers for one provider + without leaking them to unrelated OpenAI-compatible providers. + """ + try: + config_mod = __import__("hermes_cli.config", fromlist=["cfg_get", "load_config"]) + cfg_get = getattr(config_mod, "cfg_get") + load_config = getattr(config_mod, "load_config") + + cfg = config if isinstance(config, dict) else load_config() + global_headers = cfg_get(cfg, "model", "default_headers") + provider_headers = cfg_get(cfg, "model", "provider_headers") + except Exception: + return headers + + layers: List[Dict[str, Any]] = [] + if isinstance(global_headers, dict) and global_headers: + layers.append(global_headers) + + if isinstance(provider_headers, dict) and provider_headers: + candidates: List[str] = [] + raw_provider = str(provider or "").strip().lower() + if raw_provider and raw_provider != "auto": + candidates.append(raw_provider) + try: + normalized = normalize_provider(raw_provider) + if normalized and normalized != "auto" and normalized not in candidates: + candidates.append(normalized) + except Exception: + pass + if base_url: + inferred = infer_provider_from_base_url(base_url) + if inferred and inferred not in candidates: + candidates.append(inferred) + for candidate in candidates: + scoped_headers = provider_headers.get(candidate) + if isinstance(scoped_headers, dict) and scoped_headers: + layers.append(scoped_headers) + break + + if not layers: + return headers + + merged = dict(headers or {}) + for layer in layers: + for key, value in layer.items(): + if value is None: + continue + merged[str(key)] = str(value) + return merged or headers + + def _provider_profile_to_def(profile: Any) -> ProviderDef: """Convert a declarative ProviderProfile into the CLI ProviderDef shape.""" env_vars = tuple(str(v) for v in (getattr(profile, "env_vars", ()) or ())) @@ -434,7 +542,6 @@ def _provider_profile_to_def(profile: Any) -> ProviderDef: source="provider-profile", ) - def get_provider(name: str) -> Optional[ProviderDef]: """Look up a built-in provider by id or alias. diff --git a/providers/base.py b/providers/base.py index 4a045a6765d8e..d38199bb4c6fd 100644 --- a/providers/base.py +++ b/providers/base.py @@ -165,6 +165,7 @@ def fetch_models( api_key: str | None = None, base_url: str | None = None, timeout: float = 8.0, + headers: dict[str, str] | None = None, ) -> list[str] | None: """Fetch the live model list from the provider's models endpoint. @@ -204,7 +205,8 @@ def fetch_models( # the default ``Python-urllib/<ver>`` User-Agent. Set a generic # hermes-cli UA so the catalog endpoint is reachable. req.add_header("User-Agent", _profile_user_agent()) - for k, v in self.default_headers.items(): + effective_headers = headers if headers is not None else self.default_headers + for k, v in effective_headers.items(): req.add_header(k, v) try: diff --git a/run_agent.py b/run_agent.py index 7c195b35ca844..af691022b8539 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3986,8 +3986,9 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None: def _apply_user_default_headers(self) -> None: """Merge user-configured request headers onto the OpenAI client. - Reads ``model.default_headers`` from config.yaml and merges it onto - ``self._client_kwargs["default_headers"]``, with user values taking + Reads ``model.default_headers`` plus provider-scoped + ``model.provider_headers.<provider>`` from config.yaml and merges them + onto ``self._client_kwargs["default_headers"]``, with user values taking precedence over provider- and SDK-supplied defaults. This exists for ``custom`` OpenAI-compatible endpoints sitting behind @@ -4009,9 +4010,15 @@ def _apply_user_default_headers(self) -> None: from agent.auxiliary_client import ( _apply_user_default_headers as _merge_user_headers, ) - merged = _merge_user_headers(self._client_kwargs.get("default_headers")) + client_kwargs = getattr(self, "_client_kwargs", {}) + merged = _merge_user_headers( + client_kwargs.get("default_headers"), + provider=getattr(self, "provider", None), + base_url=str(getattr(self, "base_url", "") or ""), + ) if merged: - self._client_kwargs["default_headers"] = merged + client_kwargs["default_headers"] = merged + setattr(self, "_client_kwargs", client_kwargs) def _swap_credential(self, entry) -> None: runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") diff --git a/tests/agent/test_auxiliary_user_default_headers.py b/tests/agent/test_auxiliary_user_default_headers.py index c2038e5476f76..acd8f2e6e05d2 100644 --- a/tests/agent/test_auxiliary_user_default_headers.py +++ b/tests/agent/test_auxiliary_user_default_headers.py @@ -10,6 +10,7 @@ """ from unittest.mock import patch, MagicMock +from types import SimpleNamespace import pytest @@ -69,6 +70,87 @@ def test_none_values_skipped(self, tmp_path): assert merged == {"User-Agent": "curl/8.7.1"} assert "X-Drop" not in merged + def test_provider_headers_apply_only_to_matching_provider(self, tmp_path): + _write_config(tmp_path, { + "model": { + "default": "m", + "default_headers": {"X-Global": "global"}, + "provider_headers": { + "gmi": {"X-Provider": "gmi", "X-Global": "gmi"}, + }, + }, + }) + from agent.auxiliary_client import _apply_user_default_headers + + matched = _apply_user_default_headers({"User-Agent": "HermesAgent/test"}, provider="gmi") + assert matched is not None + assert matched["User-Agent"] == "HermesAgent/test" + assert matched["X-Global"] == "gmi" + assert matched["X-Provider"] == "gmi" + + other = _apply_user_default_headers({}, provider="openrouter") + assert other == {"X-Global": "global"} + + def test_provider_headers_can_match_by_base_url_for_auto_async(self, tmp_path): + _write_config(tmp_path, { + "model": { + "default": "m", + "provider_headers": { + "gmi": {"X-Provider": "gmi"}, + }, + }, + }) + from agent.auxiliary_client import _apply_user_default_headers + + merged = _apply_user_default_headers( + {}, + provider="auto", + base_url="https://api.gmi-serving.com/v1", + ) + assert merged == {"X-Provider": "gmi"} + + def test_provider_headers_can_match_custom_provider_by_base_url(self, tmp_path): + _write_config(tmp_path, { + "model": { + "default": "m", + "provider_headers": { + "gmi": {"X-Provider": "gmi"}, + }, + }, + }) + from agent.auxiliary_client import _apply_user_default_headers + + merged = _apply_user_default_headers( + {}, + provider="custom", + base_url="https://api.gmi-serving.com/v1", + ) + assert merged == {"X-Provider": "gmi"} + + def test_base_url_inference_prefers_most_specific_profile_path(self, tmp_path, monkeypatch): + _write_config(tmp_path, { + "model": { + "default": "m", + "provider_headers": { + "wide": {"X-Billing": "wide"}, + "specific": {"X-Billing": "specific"}, + }, + }, + }) + profiles = [ + SimpleNamespace(name="wide", base_url="https://example.test/root/v1"), + SimpleNamespace(name="specific", base_url="https://example.test/root/specific/v1"), + ] + monkeypatch.setattr("providers.list_providers", lambda: profiles) + from agent.auxiliary_client import _apply_user_default_headers + + merged = _apply_user_default_headers( + {}, + provider="auto", + base_url="https://example.test/root/specific/v1", + ) + assert merged == {"X-Billing": "specific"} + class TestAuxClientHonorsUserDefaultHeaders: """Integration: resolve_provider_client must pass overridden headers to OpenAI.""" @@ -135,3 +217,209 @@ def test_named_custom_provider_honors_override(self, tmp_path): assert client is not None headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} assert headers.get("User-Agent") == "curl/8.7.1" + + def test_provider_scoped_header_reaches_api_key_aux_client(self, tmp_path): + """Provider-scoped headers apply to matching API-key auxiliary clients only.""" + _write_config(tmp_path, { + "model": { + "default": "gmi-test-model", + "provider_headers": { + "gmi": {"X-Provider": "gmi"}, + }, + }, + }) + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client( + "gmi", + "gmi-test-model", + explicit_api_key="gmi-test-key", + explicit_base_url="https://api.gmi-serving.com/v1", + ) + + assert client is not None + assert model == "gmi-test-model" + headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "gmi" + + def test_auto_resolution_remembers_concrete_main_provider(self, tmp_path, monkeypatch): + """_resolve_auto keeps the provider identity without changing its two-tuple API.""" + from agent import auxiliary_client as aux + + sync_client = SimpleNamespace( + api_key="gmi-test-key", + base_url="https://unregistered-gmi-gateway.example/v1", + ) + + def fake_resolve_provider_client(provider, model, **_kwargs): + assert provider == "gmi" + assert model == "gmi-test-model" + return sync_client, model + + monkeypatch.setattr(aux, "resolve_provider_client", fake_resolve_provider_client) + client, model, provider = aux._resolve_auto_with_provider( + main_runtime={ + "provider": "gmi", + "model": "gmi-test-model", + "base_url": "https://unregistered-gmi-gateway.example/v1", + "api_key": "gmi-test-key", + }, + ) + + assert client is sync_client + assert model == "gmi-test-model" + assert provider == "gmi" + + def test_auto_async_infers_provider_for_scoped_headers(self, tmp_path): + """Auto-routed async clients must keep the resolved provider's scoped headers.""" + _write_config(tmp_path, { + "model": { + "default": "gmi-test-model", + "provider_headers": { + "gmi": {"X-Provider": "gmi"}, + }, + }, + }) + sync_client = SimpleNamespace( + api_key="gmi-test-key", + base_url="https://unregistered-gmi-gateway.example/v1", + ) + + def fake_resolve_auto_with_provider(*_args, **_kwargs): + return sync_client, "gmi-test-model", "gmi" + + with patch("agent.auxiliary_client._resolve_auto_with_provider", side_effect=fake_resolve_auto_with_provider), \ + patch("openai.AsyncOpenAI") as mock_async_openai: + mock_async_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + client, model = resolve_provider_client("auto", async_mode=True) + + assert client is not None + assert model == "gmi-test-model" + headers = mock_async_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "gmi" + + def test_openrouter_aux_client_honors_provider_scoped_headers(self, tmp_path, monkeypatch): + """Direct OpenRouter aux fallback clients should merge scoped headers too.""" + _write_config(tmp_path, { + "model": { + "provider_headers": { + "openrouter": {"X-Provider": "openrouter"}, + }, + }, + }) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") + + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import _try_openrouter + client, model = _try_openrouter() + + assert client is not None + headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "openrouter" + + def test_nous_aux_client_honors_provider_scoped_headers(self, tmp_path): + """Direct Nous aux fallback clients should merge scoped headers too.""" + _write_config(tmp_path, { + "model": { + "provider_headers": { + "nous": {"X-Provider": "nous"}, + }, + }, + }) + + with patch("agent.auxiliary_client.OpenAI") as mock_openai, \ + patch("agent.auxiliary_client._read_nous_auth", return_value={}), \ + patch( + "agent.auxiliary_client._resolve_nous_runtime_api", + return_value=("nous-test-key", "https://inference-api.nousresearch.com/v1"), + ), \ + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value="nous-test-model"): + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import _try_nous + client, model = _try_nous() + + assert client is not None + assert model == "nous-test-model" + headers = mock_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "nous" + + def test_auto_vision_async_finalize_keeps_resolved_provider_headers(self, tmp_path, monkeypatch): + """Vision auto async conversion must preserve the provider selected by auto.""" + _write_config(tmp_path, { + "model": { + "provider_headers": { + "gmi": {"X-Provider": "gmi"}, + }, + }, + }) + import agent.auxiliary_client as aux + + sync_client = SimpleNamespace( + api_key="gmi-key", + base_url="https://unregistered-gmi-gateway.example/v1", + ) + monkeypatch.setattr( + aux, + "_resolve_task_provider_model", + lambda *_args, **_kwargs: ("auto", "gmi-vision", None, None, None), + ) + monkeypatch.setattr(aux, "_read_main_provider", lambda: "gmi") + monkeypatch.setattr(aux, "_read_main_model", lambda: "gmi-vision") + monkeypatch.setattr(aux, "_main_model_supports_vision", lambda provider, model: True) + monkeypatch.setattr( + aux, + "resolve_provider_client", + lambda provider, model, **_kwargs: (sync_client, model), + ) + + with patch("openai.AsyncOpenAI") as mock_async_openai: + mock_async_openai.return_value = MagicMock() + provider, client, model = aux.resolve_vision_provider_client( + "auto", + "gmi-vision", + async_mode=True, + ) + + assert provider == "gmi" + assert client is not None + assert model == "gmi-vision" + headers = mock_async_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "gmi" + + def test_nous_runtime_refresh_async_keeps_provider_scoped_headers(self, tmp_path, monkeypatch): + """Refreshing Nous async clients must preserve Nous-scoped headers.""" + _write_config(tmp_path, { + "model": { + "provider_headers": { + "nous": {"X-Provider": "nous"}, + }, + }, + }) + import agent.auxiliary_client as aux + + sync_client = SimpleNamespace( + api_key="nous-key", + base_url="https://inference-api.nousresearch.com/v1", + ) + monkeypatch.setattr( + aux, + "_resolve_nous_runtime_api", + lambda force_refresh=False: ("nous-key", "https://inference-api.nousresearch.com/v1"), + ) + + with patch("agent.auxiliary_client.OpenAI", return_value=sync_client), \ + patch("openai.AsyncOpenAI") as mock_async_openai: + mock_async_openai.return_value = MagicMock() + client, model = aux._refresh_nous_auxiliary_client( + cache_provider="nous", + model="nous-model", + async_mode=True, + ) + + assert client is not None + assert model == "nous-model" + headers = mock_async_openai.call_args.kwargs.get("default_headers", {}) or {} + assert headers.get("X-Provider") == "nous" diff --git a/tests/hermes_cli/test_provider_scoped_model_headers.py b/tests/hermes_cli/test_provider_scoped_model_headers.py new file mode 100644 index 0000000000000..afbb1b76db707 --- /dev/null +++ b/tests/hermes_cli/test_provider_scoped_model_headers.py @@ -0,0 +1,145 @@ +"""Provider-scoped header regression tests for OpenAI-compatible providers.""" + +from providers.base import ProviderProfile + + +def _install_unit_test_provider_profile(monkeypatch) -> ProviderProfile: + profile = ProviderProfile( + name="unit-test-profile-provider", + aliases=("unit-test-profile", "utpp"), + display_name="Unit Test Profile Provider", + description="ProviderProfile-only provider used by header tests.", + signup_url="https://unit-test-provider.example/signup", + env_vars=("UTPP_API_KEY", "UTPP_BASE_URL"), + base_url="https://unit-test-provider.example/v1", + api_mode="anthropic_messages", + fallback_models=("unit-profile-model",), + ) + + def fake_get_provider_profile(name): + if name in {profile.name, *profile.aliases}: + return profile + return None + + import providers as provider_registry + + monkeypatch.setattr(provider_registry, "get_provider_profile", fake_get_provider_profile) + return profile + + +def test_provider_profile_catalog_fetch_receives_provider_scoped_headers(monkeypatch, tmp_path): + """Live profile catalog fetches should receive matching provider headers.""" + profile = _install_unit_test_provider_profile(monkeypatch) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider: {"api_key": "profile-key", "base_url": profile.base_url}, + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider_headers": {profile.name: {"X-Provider": "profile"}}}}, + ) + captured = {} + + def fake_fetch_models(*, api_key=None, base_url=None, timeout=8.0, headers=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + captured["headers"] = headers + return ["unit-profile-model"] + + monkeypatch.setattr(profile, "fetch_models", fake_fetch_models) + + from hermes_cli.models import provider_model_ids + + assert provider_model_ids(profile.name, force_refresh=True) == ["unit-profile-model"] + assert captured["api_key"] == "profile-key" + assert captured["base_url"] == profile.base_url + assert captured["headers"]["X-Provider"] == "profile" + + +def test_provider_profile_catalog_fetch_keeps_legacy_fetch_models_signature(monkeypatch, tmp_path): + """Out-of-tree ProviderProfile.fetch_models overrides need not accept headers.""" + profile = _install_unit_test_provider_profile(monkeypatch) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider: {"api_key": "profile-key", "base_url": profile.base_url}, + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider_headers": {profile.name: {"X-Provider": "profile"}}}}, + ) + captured = {} + + def legacy_fetch_models(*, api_key=None, base_url=None, timeout=8.0): + captured["api_key"] = api_key + captured["base_url"] = base_url + return ["legacy-live-model"] + + monkeypatch.setattr(profile, "fetch_models", legacy_fetch_models) + + from hermes_cli.models import provider_model_ids + + assert provider_model_ids(profile.name, force_refresh=True) == ["legacy-live-model"] + assert captured == {"api_key": "profile-key", "base_url": profile.base_url} + + +def test_custom_validation_probe_receives_base_url_inferred_scoped_headers(monkeypatch): + """Custom endpoint validation should pass provider-scoped headers to /models probes.""" + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider_headers": {"gmi": {"X-Provider": "gmi"}}}}, + ) + captured = {} + + def fake_probe(api_key, base_url, timeout=5.0, api_mode=None, request_headers=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + captured["api_mode"] = api_mode + captured["request_headers"] = request_headers + return {"models": ["demo-model"], "probed_url": base_url.rstrip("/") + "/models"} + + monkeypatch.setattr("hermes_cli.models.probe_api_models", fake_probe) + from hermes_cli.models import validate_requested_model + + result = validate_requested_model( + "demo-model", + "custom", + api_key="test-key", + base_url="https://api.gmi-serving.com/v1", + ) + + assert result["accepted"] is True + assert captured["request_headers"] == {"X-Provider": "gmi"} + + +def test_provider_model_cache_invalidates_when_configured_headers_change(monkeypatch, tmp_path): + """Header-dependent /models caches must refresh when tenant headers change.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = {"model": {"provider_headers": {"gmi": {"X-Tenant": "tenant-a"}}}} + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider: {"api_key": "test-key", "base_url": "https://api.gmi-serving.com/v1"}, + ) + captured_headers = [] + + def fake_probe(api_key, base_url, timeout=5.0, api_mode=None, request_headers=None): + headers = dict(request_headers or {}) + captured_headers.append(headers) + return { + "models": [f"{headers.get('X-Tenant')}-model"], + "probed_url": base_url.rstrip("/") + "/models", + } + + monkeypatch.setattr("hermes_cli.models.probe_api_models", fake_probe) + from hermes_cli.models import cached_provider_model_ids + + assert cached_provider_model_ids("gmi") == ["tenant-a-model"] + + config["model"]["provider_headers"]["gmi"]["X-Tenant"] = "tenant-b" + assert cached_provider_model_ids("gmi") == ["tenant-b-model"] + assert captured_headers == [ + {"X-Tenant": "tenant-a"}, + {"X-Tenant": "tenant-b"}, + ] diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index 2784ba178d287..e76a584a8a0cc 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -249,6 +249,39 @@ def test_no_user_default_headers_leaves_provider_defaults_untouched(mock_openai) assert "User-Agent" not in headers # nothing injected when unconfigured +@patch("run_agent.OpenAI") +def test_provider_scoped_headers_apply_to_matching_main_client_only(mock_openai): + """Provider-scoped headers should not leak between OpenAI-compatible providers.""" + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://api.gmi-serving.com/v1", + model="gmi-test-model", + provider="gmi", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + setattr(agent, "_client_kwargs", {}) + + with patch("hermes_cli.config.load_config", return_value={ + "model": {"provider_headers": {"gmi": {"X-Provider": "gmi"}}}, + }): + agent._apply_user_default_headers() + + assert getattr(agent, "_client_kwargs")["default_headers"]["X-Provider"] == "gmi" + + setattr(agent, "_client_kwargs", {}) + setattr(agent, "provider", "openrouter") + agent.base_url = "https://openrouter.ai/api/v1" + with patch("hermes_cli.config.load_config", return_value={ + "model": {"provider_headers": {"gmi": {"X-Provider": "gmi"}}}, + }): + agent._apply_user_default_headers() + + assert "default_headers" not in getattr(agent, "_client_kwargs") + + @patch("run_agent.OpenAI") def test_user_default_headers_skipped_for_anthropic_mode(mock_openai): """Anthropic/Bedrock modes don't use the OpenAI client — never touched.""" From 54c14d995a56ddb107909102a792922455166845 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Sat, 4 Jul 2026 14:02:02 +0200 Subject: [PATCH 25/28] feat(gateway): configure macOS app-wrapper identity --- RELEASE_amy-patches.md | 159 +++++++++++++++++------ hermes_cli/gateway.py | 82 ++++++++++-- hermes_cli/subcommands/gateway.py | 5 +- tests/hermes_cli/test_gateway_service.py | 152 ++++++++++++++++++++++ 4 files changed, 347 insertions(+), 51 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index 8307a290a1e5c..f66285d60484f 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,7 +1,7 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 24 local Amy patches on Hermes Agent v2026.6.19 after v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, and generic provider-profile/provider-scoped-header plumbing +**Current patch stack:** 25 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, and configurable macOS app-wrapper identity for Amy/TCC **Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained **Author:** Amy Ravenwolf <amy@ravenwolf.de> @@ -9,15 +9,96 @@ --- +## 2026-07-07 - WhatsApp Bridge Audio Patch Cleanup + +**Problem:** The local bridge-audio patch left two identical `send_voice()` +methods in `gateway/platforms/whatsapp.py`, while its commit subject also +claimed npm and lockfile changes that were not present. Python kept only the +later method, so runtime behavior was unaffected, but the patch history was +misleading and contained dead code. + +**Solution:** Folded the dead-method removal into the original patch before the +next public stack revision and renamed that patch to describe its real net +behavior. The final code delta is limited to bridge-side audio download +handling: it reads the native audio message after media download, maps known +MIME types to stable extensions, and uses an explicit `audio_` filename prefix. +Wrapper-aware bounded unwrapping preserves audio metadata for ephemeral, +view-once, and nested messages, and the bridge health hash now covers the helper +module so a helper update invalidates stale long-running bridge processes. The +already-effective Python `send_voice()` implementation remains unchanged. + +**Affected files:** + +- `scripts/whatsapp-bridge/bridge.js` +- `scripts/whatsapp-bridge/media-utils.js` +- `scripts/whatsapp-bridge/media-utils.test.js` +- `scripts/whatsapp-bridge/package.json` +- `RELEASE_amy-patches.md` + +**Verification:** + +- `ruff check gateway/platforms/whatsapp.py` +- `scripts/run_tests.sh` across the two WhatsApp CLI test files and seven WhatsApp gateway test files -> `244 passed` +- `node --check scripts/whatsapp-bridge/bridge.js` +- `npm test` in `scripts/whatsapp-bridge` -> `4 passed` + +**Session reference:** 2026-07-07 Mattermost follow-up after closing stale +fork-internal `WolframRavenwolf/hermes-agent` PRs exposed the duplicate +`send_voice()` method in the active `amy/patches` stack. + +--- + +## 2026-07-04 - Configurable macOS App Wrapper Identity for Amy/TCC + +**Problem:** The macOS gateway launchd service still started the uv-managed +CPython executable directly, so Privacy & Security / TCC prompts appeared as +generic `python3.13`. The wrapper needs a stable, recognizable app identity, +and routine rebuilds must not unnecessarily invalidate TCC grants. + +**Solution:** The optional macOS launchd app-wrapper now reads +`gateway.macos_app_wrapper.display_name` and +`gateway.macos_app_wrapper.signing_identity` from `config.yaml`. The configured +display name controls the app bundle name, executable name, and Info.plist +display fields, while the configured signing identity replaces the previous +hardcoded ad-hoc `codesign -s -` identity. Wrapper source metadata records the +display name and signing identity so config changes force a wrapper rebuild. +Invalid display names containing path separators, control characters, legacy +colon separators, excessive character count, or more than 200 UTF-8 bytes safely +fall back to the default `Hermes Agent` name. The byte bound leaves room for the +`.app` and staging-directory suffixes under macOS' per-component limit. +Restart-ordering tests explicitly pin app-wrapper mode so the machine's +installed launchd configuration cannot trigger signing side effects inside +otherwise unrelated unit tests. + +**Affected files:** + +- `hermes_cli/gateway.py` +- `hermes_cli/subcommands/gateway.py` +- `tests/hermes_cli/test_gateway_service.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- `ruff check hermes_cli/gateway.py hermes_cli/subcommands/gateway.py tests/hermes_cli/test_gateway_service.py` +- `scripts/run_tests.sh tests/hermes_cli/test_gateway_service.py -- -q` -> `198 passed` +- Combined app-wrapper and WhatsApp Python regression matrix -> `442 passed`, `0 failed` +- Combined Python and Node targeted evidence -> `446 passed`, `0 failed` +- Full canonical suite attempted in a detached worktree; every reproducible failure in the 18-file problem set also occurred on the isolated `origin/amy/patches` baseline, with no new final-branch failure + +**Session reference:** 2026-07-04 macOS app-identity and TCC configuration +review. + +--- + ## Current Patch-Stack Classification (2026-06-22) Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `24` patches on `amy/patches` +- Current stack: `25` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` -- Exact patch-id absorption check against current `upstream/main`: all 24 current patches show `+`, so none are exact patch-id matches on upstream `main`; semantic absorption still has to be judged by workflow/code inspection. +- Exact patch-id absorption check against current `upstream/main`: all 24 pre-2026-07-04 patches showed `+`; the new configurable macOS app-wrapper identity patch still needs a future upstream-overlap check before any PR/rebase decision. Semantic absorption still has to be judged by workflow/code inspection. ### Dropped During the v0.17 Rebase @@ -45,44 +126,46 @@ These old local patches are no longer carried in the current stack because Herme | Current commit | Subject | Classification | |---|---|---| -| `9286c41c8` | `feat(status): restore model and context in gateway status` | Re-spun local delta on top of upstream's refactored status command. | -| `505373d78` | `docs(patches): update upstream PR reconciliation` | Re-spun private patch-stack documentation. | -| `8927afe69` | `feat(tools): keep send_message in explicit messaging toolset` | New Amy-local policy patch after upstream removed agent-callable `send_message` from default surfaces. | -| `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | New upstream-worthy test flake fix. | -| `f1abee591` | `fix(gateway): harden self-management guard` | New upstream-worthy gateway safety fix, including restart-helper detector follow-up. | -| `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | New upstream-worthy optional-plugin noise fix; likely droppable after a release containing upstream's equivalent Raft quieting. | -| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | -| `new` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | -| `new` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | - -### Current 24-Patch Stack +| `c017d9811` | `feat(status): restore model and context in gateway status` | Re-spun local delta on top of upstream's refactored status command. | +| `17be30994` | `docs(patches): update upstream PR reconciliation` | Re-spun private patch-stack documentation. | +| `852d51d86` | `feat(tools): keep send_message in explicit messaging toolset` | New Amy-local policy patch after upstream removed agent-callable `send_message` from default surfaces. | +| `24225b186` | `test(auxiliary): make codex timeout check deterministic` | New upstream-worthy test flake fix. | +| `12b3b445a` | `fix(gateway): harden self-management guard` | New upstream-worthy gateway safety fix, including restart-helper detector follow-up. | +| `e2aa31a4a` | `fix(raft): quiet optional dependency checks` | New upstream-worthy optional-plugin noise fix; likely droppable after a release containing upstream's equivalent Raft quieting. | +| `198b4d3b9` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | +| `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | +| `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | +| `this commit` | `feat(gateway): configure macOS app-wrapper identity` | New mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | + +### Current 25-Patch Stack | Commit | Subject | Current classification | |---|---|---| -| `c2bc868aa` | `fix: WhatsApp voice messages + bridge audio download + npm deps` | Upstream-worthy and still locally needed against `v2026.6.19`; original PR #41616 closed unmerged. | -| `ab38f124c` | `feat(tool_progress): add 'full' mode - unlimited tool args in gateway chat` | Upstream-worthy and still locally needed; original PR #41617 closed unmerged. | -| `3534f1cc7` | `feat(prompt): add Amy platform hints and Mattermost Private Assistant default` | Private Amy/persona patch - do not upstream. | -| `985992a36` | `docs: add Amy's patches changelog for v0.6.0 fork` | Private fork documentation - do not upstream. | -| `33fad8f85` | `fix: suppress pkg_resources deprecation warning from lark_oapi` | Upstream-worthy and still locally needed; original PR #41621 closed unmerged. | -| `35b9fa2e0` | `fix(addon): make dashboard assets work behind HA ingress` | Upstream-worthy and still locally needed; original PR #41629 closed unmerged. | -| `73d6a66c3` | `feat(vision): add provider-safe inject_image tool` | Upstream-worthy and still locally needed; original PR #41632 closed unmerged. | -| `4761ff163` | `feat(moa): route experts through provider-aware clients` | Partially superseded by upstream MoA/virtual-provider redesign; keep locally against `v2026.6.19`, re-evaluate on next release. | -| `9fc7aa5ca` | `feat(gateway): add macOS app-wrapper launchd identity` | Upstream-worthy/local Mac runtime patch; original PR #41635 was closed as too broad, but Amy still needs the app-wrapper/TCC workflow. | -| `835926beb` | `fix: enable GPT-5.5 priority processing fast mode` | Likely partially superseded by broader upstream fast-routing work; keep as local regression coverage until next release comparison proves redundant. | -| `a798832f7` | `fix(gateway): keep macOS launchd runtime paths logical` | Upstream-worthy/local launchd hardening; partially related upstream fixes exist, but this exact logical-path/app-wrapper workflow remains local. | -| `850e92d6f` | `fix(deps): restore CVE-fixed pyproject pins` | Partially upstream-covered; keep until upstream fully covers the direct dependency-pin/lock consistency Amy needs. | -| `9286c41c8` | `feat(status): restore model and context in gateway status` | Partially upstream-covered; local provider/context delta remains needed for Amy's `/status` workflow. | -| `c741830f0` | `feat(resume): restore cross-platform full session listing` | Upstream has `/sessions`, but not the exact `/resume --all/--full` compatibility workflow; keep locally, possible compatibility PR. | -| `505373d78` | `docs(patches): update upstream PR reconciliation` | Private fork documentation - do not upstream. | -| `f7bcf08bd` | `fix(mattermost): caption file-only media posts` | Upstream-worthy and still locally needed; upstream PR #48014 open. | -| `538fd6ec6` | `fix(mattermost): make post length configurable` | Upstream-worthy and still locally needed; upstream PR #48015 open. | -| `8927afe69` | `feat(tools): keep send_message in explicit messaging toolset` | Amy-local trusted-runtime policy patch; upstream deliberately removed broad agent-callable `send_message`. | -| `2e4db11d1` | `test(auxiliary): make codex timeout check deterministic` | Upstream-worthy test flake fix; no upstream PR yet. | -| `f1abee591` | `fix(gateway): harden self-management guard` | Upstream-worthy safety fix; no upstream PR yet. | -| `ee2c36ff3` | `fix(raft): quiet optional dependency checks` | Semantically likely superseded on upstream `main`, but locally needed against `v2026.6.19`; probably droppable next release. | -| `this commit` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | -| `new` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | -| `new` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | +| `84db61d73` | `fix(whatsapp): preserve native bridge audio metadata` | Upstream-worthy and still locally needed against `v2026.6.19`; original PR #41616 closed unmerged. | +| `b6d479d9d` | `feat(tool_progress): add 'full' mode — unlimited tool args in gateway chat` | Upstream-worthy and still locally needed; original PR #41617 closed unmerged. | +| `06c9e12a1` | `feat(prompt): add Amy platform hints and Mattermost Private Assistant default` | Private Amy/persona patch - do not upstream. | +| `1a8006368` | `docs: add Amy's patches changelog for v0.6.0 fork` | Private fork documentation - do not upstream. | +| `d4710f4b6` | `fix: suppress pkg_resources deprecation warning from lark_oapi` | Upstream-worthy and still locally needed; original PR #41621 closed unmerged. | +| `e6faede1e` | `fix(addon): make dashboard assets work behind HA ingress` | Upstream-worthy and still locally needed; original PR #41629 closed unmerged. | +| `f4c001c69` | `feat(vision): add provider-safe inject_image tool` | Upstream-worthy and still locally needed; original PR #41632 closed unmerged. | +| `0790976ef` | `feat(moa): route experts through provider-aware clients` | Partially superseded by upstream MoA/virtual-provider redesign; keep locally against `v2026.6.19`, re-evaluate on next release. | +| `53c4a77ad` | `feat(gateway): add macOS app-wrapper launchd identity` | Upstream-worthy/local Mac runtime patch; original PR #41635 was closed as too broad, but Amy still needs the app-wrapper/TCC workflow. | +| `00ebca5a3` | `fix: enable GPT-5.5 priority processing fast mode` | Likely partially superseded by broader upstream fast-routing work; keep as local regression coverage until next release comparison proves redundant. | +| `68a16944f` | `fix(gateway): keep macOS launchd runtime paths logical` | Upstream-worthy/local launchd hardening; partially related upstream fixes exist, but this exact logical-path/app-wrapper workflow remains local. | +| `4e945ba21` | `fix(deps): restore CVE-fixed pyproject pins` | Partially upstream-covered; keep until upstream fully covers the direct dependency-pin/lock consistency Amy needs. | +| `c017d9811` | `feat(status): restore model and context in gateway status` | Partially upstream-covered; local provider/context delta remains needed for Amy's `/status` workflow. | +| `68b454c57` | `feat(resume): restore cross-platform full session listing` | Upstream has `/sessions`, but not the exact `/resume --all/--full` compatibility workflow; keep locally, possible compatibility PR. | +| `17be30994` | `docs(patches): update upstream PR reconciliation` | Private fork documentation - do not upstream. | +| `3e67d698b` | `fix(mattermost): caption file-only media posts` | Upstream-worthy and still locally needed; upstream PR #48014 open. | +| `7ef1ea78b` | `fix(mattermost): make post length configurable` | Upstream-worthy and still locally needed; upstream PR #48015 open. | +| `852d51d86` | `feat(tools): keep send_message in explicit messaging toolset` | Amy-local trusted-runtime policy patch; upstream deliberately removed broad agent-callable `send_message`. | +| `24225b186` | `test(auxiliary): make codex timeout check deterministic` | Upstream-worthy test flake fix; no upstream PR yet. | +| `12b3b445a` | `fix(gateway): harden self-management guard` | Upstream-worthy safety fix; no upstream PR yet. | +| `e2aa31a4a` | `fix(raft): quiet optional dependency checks` | Semantically likely superseded on upstream `main`, but locally needed against `v2026.6.19`; probably droppable next release. | +| `198b4d3b9` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | +| `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | +| `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | +| `this commit` | `feat(gateway): configure macOS app-wrapper identity` | Mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | ### Push/Upgrade Implications diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 8e9ba02159625..8dbdde84b6e0e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2158,11 +2158,56 @@ def get_launchd_plist_path() -> Path: return _launchd_user_home() / "Library" / "LaunchAgents" / f"{name}.plist" -MACOS_APP_WRAPPER_DISPLAY_NAME = "Hermes Agent" +MACOS_APP_WRAPPER_DEFAULT_DISPLAY_NAME = "Hermes Agent" +MACOS_APP_WRAPPER_DEFAULT_SIGNING_IDENTITY = "-" +MACOS_APP_WRAPPER_MAX_NAME_BYTES = 200 MACOS_APP_WRAPPER_ENV_KEY = "HERMES_LAUNCHD_APP_WRAPPER" MACOS_APP_WRAPPER_SOURCE_INFO = "HermesPythonSource.plist" +def _launchd_app_wrapper_config() -> dict: + """Return raw macOS launchd app-wrapper config, if present.""" + try: + config = read_raw_config() + except Exception: + return {} + gateway_config = config.get("gateway") if isinstance(config, dict) else None + if not isinstance(gateway_config, dict): + return {} + wrapper_config = gateway_config.get("macos_app_wrapper") + return wrapper_config if isinstance(wrapper_config, dict) else {} + + +def _launchd_app_wrapper_display_name() -> str: + """Return the display/executable name for the optional launchd wrapper.""" + raw = _launchd_app_wrapper_config().get("display_name") + name = str(raw).strip() if raw is not None else "" + if not name: + return MACOS_APP_WRAPPER_DEFAULT_DISPLAY_NAME + try: + name_bytes = len(name.encode("utf-8")) + except UnicodeEncodeError: + return MACOS_APP_WRAPPER_DEFAULT_DISPLAY_NAME + if ( + "/" in name + or "\\" in name + or ":" in name + or any(ord(char) < 32 for char in name) + or len(name) > 128 + or name_bytes > MACOS_APP_WRAPPER_MAX_NAME_BYTES + or name in {".", ".."} + ): + return MACOS_APP_WRAPPER_DEFAULT_DISPLAY_NAME + return name + + +def _launchd_app_wrapper_signing_identity() -> str: + """Return the codesign identity used for the optional launchd wrapper.""" + raw = _launchd_app_wrapper_config().get("signing_identity") + identity = str(raw).strip() if raw is not None else "" + return identity or MACOS_APP_WRAPPER_DEFAULT_SIGNING_IDENTITY + + def get_launchd_bundle_identifier() -> str: """Return the bundle identifier associated with the launchd gateway job.""" return get_launchd_label() @@ -2171,10 +2216,11 @@ def get_launchd_bundle_identifier() -> str: def get_launchd_app_wrapper_path() -> Path: """Return the macOS app bundle path used for the optional launchd wrapper.""" suffix = _profile_suffix() + display_name = _launchd_app_wrapper_display_name() bundle_name = ( - f"{MACOS_APP_WRAPPER_DISPLAY_NAME} ({suffix}).app" + f"{display_name} ({suffix}).app" if suffix - else f"{MACOS_APP_WRAPPER_DISPLAY_NAME}.app" + else f"{display_name}.app" ) return get_hermes_home() / "macos" / bundle_name @@ -2185,7 +2231,7 @@ def get_launchd_app_wrapper_executable_path() -> Path: get_launchd_app_wrapper_path() / "Contents" / "MacOS" - / MACOS_APP_WRAPPER_DISPLAY_NAME + / _launchd_app_wrapper_display_name() ) @@ -2240,11 +2286,12 @@ def _launchd_app_wrapper_info() -> dict: """Return Info.plist metadata for the optional macOS launchd app wrapper.""" from hermes_cli import __version__ as hermes_version + display_name = _launchd_app_wrapper_display_name() return { "CFBundleIdentifier": get_launchd_bundle_identifier(), - "CFBundleName": MACOS_APP_WRAPPER_DISPLAY_NAME, - "CFBundleDisplayName": MACOS_APP_WRAPPER_DISPLAY_NAME, - "CFBundleExecutable": MACOS_APP_WRAPPER_DISPLAY_NAME, + "CFBundleName": display_name, + "CFBundleDisplayName": display_name, + "CFBundleExecutable": display_name, "CFBundlePackageType": "APPL", "CFBundleVersion": hermes_version, "CFBundleShortVersionString": hermes_version, @@ -2262,6 +2309,8 @@ def _launchd_app_wrapper_source_info(source_python: Path | None = None) -> dict: "SourcePython": str(source), "SourceSize": stat.st_size, "SourceMTimeNs": stat.st_mtime_ns, + "DisplayName": _launchd_app_wrapper_display_name(), + "SigningIdentity": _launchd_app_wrapper_signing_identity(), } @@ -2311,10 +2360,11 @@ def launchd_app_wrapper_is_current() -> bool: def install_launchd_app_wrapper(force: bool = False) -> Path: """Install/update the optional macOS app bundle used as launchd executable. - The bundle contains a *copy* of the active Python executable named - ``Hermes Agent``. launchd then starts that bundle executable instead of the - generic ``python3.13`` binary, giving macOS/TCC a Hermes-specific path and - bundle identity while still running Hermes through the existing venv. + The bundle contains a *copy* of the active Python executable named after + the configured app-wrapper display name (default: ``Hermes Agent``). + launchd then starts that bundle executable instead of the generic + ``python3.13`` binary, giving macOS/TCC a Hermes-specific path and bundle + identity while still running Hermes through the existing venv. """ app_path = get_launchd_app_wrapper_path() if app_path.exists() and not force and launchd_app_wrapper_is_current(): @@ -2345,8 +2395,16 @@ def install_launchd_app_wrapper(force: bool = False) -> Path: plistlib.dumps(_launchd_app_wrapper_source_info(source_python), sort_keys=False) ) + signing_identity = _launchd_app_wrapper_signing_identity() subprocess.run( - ["codesign", "--force", "--deep", "--sign", "-", str(staging_app_path)], + [ + "codesign", + "--force", + "--deep", + "--sign", + signing_identity, + str(staging_app_path), + ], check=True, timeout=30, ) diff --git a/hermes_cli/subcommands/gateway.py b/hermes_cli/subcommands/gateway.py index 00e29341ccdc3..5da068629fb2e 100644 --- a/hermes_cli/subcommands/gateway.py +++ b/hermes_cli/subcommands/gateway.py @@ -201,7 +201,10 @@ def build_gateway_parser( "--macos-app-wrapper", dest="macos_app_wrapper", action="store_true", - help="macOS only: run launchd through a Hermes Agent.app wrapper so privacy prompts show Hermes instead of python", + help=( + "macOS only: run launchd through a configurable app wrapper " + "so privacy prompts show Hermes/Amy instead of python" + ), ) # gateway uninstall diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 4beb0fd81fe1a..e8ce493061b83 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -553,6 +553,10 @@ def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch): class TestLaunchdMacOSAppWrapper: + @pytest.fixture(autouse=True) + def _default_app_wrapper_config(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: {}) + def test_generate_launchd_plist_prioritizes_amy_bins_and_filters_stale_path_entries(self, tmp_path, monkeypatch): home = tmp_path / "amy" repo = home / "hermes-agent" @@ -777,6 +781,144 @@ def fake_run(cmd, **kwargs): assert any(cmd[:4] == ["codesign", "--verify", "--deep", "--strict"] for cmd in calls) assert any(cmd[1:2] == ["-c"] and Path(cmd[0]).name == "Hermes Agent" for cmd in calls) + def test_install_launchd_app_wrapper_uses_configured_name_and_signing_identity(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr( + gateway_cli, + "read_raw_config", + lambda: { + "gateway": { + "macos_app_wrapper": { + "display_name": "Amy", + "signing_identity": "Amy Local Code Signing", + } + } + }, + ) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + app_path = gateway_cli.install_launchd_app_wrapper(force=True) + + app_exe = app_path / "Contents" / "MacOS" / "Amy" + info = plistlib.loads((app_path / "Contents" / "Info.plist").read_bytes()) + source_info = plistlib.loads( + ( + app_path + / "Contents" + / "Resources" + / gateway_cli.MACOS_APP_WRAPPER_SOURCE_INFO + ).read_bytes() + ) + assert app_path == home / "macos" / "Amy.app" + assert app_exe.read_bytes() == b"fake-macho-python" + assert info["CFBundleDisplayName"] == "Amy" + assert source_info["DisplayName"] == "Amy" + assert source_info["SigningIdentity"] == "Amy Local Code Signing" + assert any( + cmd[:5] == ["codesign", "--force", "--deep", "--sign", "Amy Local Code Signing"] + for cmd in calls + ) + + @pytest.mark.parametrize( + "display_name", + [ + "../Amy", + r"Amy\\Helper", + "Amy:Helper", + "Amy\nHelper", + "A" * 129, + "🧠" * 51, + ".", + "..", + ], + ) + def test_launchd_app_wrapper_rejects_invalid_configured_name(self, monkeypatch, display_name): + monkeypatch.setattr( + gateway_cli, + "read_raw_config", + lambda: {"gateway": {"macos_app_wrapper": {"display_name": display_name}}}, + ) + + assert gateway_cli._launchd_app_wrapper_display_name() == "Hermes Agent" + + def test_launchd_app_wrapper_current_tracks_configured_identity_metadata(self, tmp_path, monkeypatch): + home = tmp_path / "home" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_bytes(b"fake-macho-python") + + config = { + "gateway": { + "macos_app_wrapper": { + "display_name": "Amy", + "signing_identity": "Amy Local Code Signing", + } + } + } + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: config) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + gateway_cli.install_launchd_app_wrapper(force=True) + assert gateway_cli.launchd_app_wrapper_is_current() is True + + config["gateway"]["macos_app_wrapper"]["signing_identity"] = "Other Signing Identity" + assert gateway_cli.launchd_app_wrapper_is_current() is False + + config["gateway"]["macos_app_wrapper"]["signing_identity"] = "Amy Local Code Signing" + config["gateway"]["macos_app_wrapper"]["display_name"] = "Amy New" + assert gateway_cli.launchd_app_wrapper_is_current() is False + + def test_generate_launchd_plist_uses_configured_app_wrapper_name(self, tmp_path, monkeypatch): + home = tmp_path / "home" + repo = tmp_path / "repo" + venv = repo / ".venv" + python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" + source_python = python_home / "bin" / "python3.13" + source_python.parent.mkdir(parents=True) + source_python.write_text("python", encoding="utf-8") + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "python").write_text("venv-python", encoding="utf-8") + (venv / "lib" / "python3.13" / "site-packages").mkdir(parents=True) + + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + monkeypatch.setattr(gateway_cli, "_profile_suffix", lambda: "") + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", repo) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(source_python)) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + monkeypatch.setattr( + gateway_cli, + "read_raw_config", + lambda: {"gateway": {"macos_app_wrapper": {"display_name": "Amy"}}}, + ) + + plist = plistlib.loads(gateway_cli.generate_launchd_plist(app_wrapper=True).encode("utf-8")) + + app_exe = home / "macos" / "Amy.app" / "Contents" / "MacOS" / "Amy" + assert plist["ProgramArguments"][:3] == [str(app_exe), "-m", "hermes_cli.main"] + def test_install_launchd_app_wrapper_keeps_existing_bundle_when_validation_fails(self, tmp_path, monkeypatch): home = tmp_path / "home" python_home = tmp_path / "cpython-3.13.13-macos-aarch64-none" @@ -1273,6 +1415,11 @@ def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypat target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}" monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) + monkeypatch.setattr( + gateway_cli, + "_resolve_launchd_app_wrapper_mode", + lambda app_wrapper=None: False, + ) monkeypatch.setattr(gateway_cli, "_launchd_reload_is_pending", lambda plist_path=None: False) monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) @@ -1400,6 +1547,11 @@ def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, "gateway.status.get_running_pid", lambda: 321, ) + monkeypatch.setattr( + gateway_cli, + "_resolve_launchd_app_wrapper_mode", + lambda app_wrapper=None: False, + ) monkeypatch.setattr(gateway_cli, "_launchd_reload_is_pending", lambda plist_path=None: False) monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda app_wrapper=None: True) monkeypatch.setattr( From 8447438c97cb75a31aba6da3bb0e794cbe4b907f Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Tue, 14 Jul 2026 18:31:45 +0200 Subject: [PATCH 26/28] fix(redact): preserve replayable tool arguments Keep canonical tool-call arguments byte-exact so replay and resume cannot reuse redaction placeholders. Narrow operational metadata false positives, preserve quote/backslash syntax, and align Anthropic interleaved replay with the raw canonical argument contract. Adapted from merged upstream PRs #54061/#54136 and a hardened subset of open PR #47348. --- RELEASE_amy-patches.md | 65 ++++++- agent/anthropic_adapter.py | 32 ++-- agent/chat_completion_helpers.py | 21 +-- agent/redact.py | 43 ++++- .../test_anthropic_thinking_block_order.py | 49 ++--- .../agent/test_redactor_replay_and_syntax.py | 171 ++++++++++++++++++ 6 files changed, 307 insertions(+), 74 deletions(-) create mode 100644 tests/agent/test_redactor_replay_and_syntax.py diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index f66285d60484f..b66a5dc672231 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,14 +1,65 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 25 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, and configurable macOS app-wrapper identity for Amy/TCC -**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained +**Current patch stack:** 26 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, configurable macOS app-wrapper identity for Amy/TCC, and non-destructive secret-redactor replay handling +**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained; redactor upstream overlap rechecked 2026-07-14 **Author:** Amy Ravenwolf <amy@ravenwolf.de> > Current goal: keep only Amy/private patches local, and submit every generally useful feature/fix upstream as an open PR so future upgrades have less custom patch baggage. --- +## 2026-07-14 - Non-Destructive Secret Redaction and Replay + +**Problem:** The generic redactor modified replayable tool-call arguments before +they were stored in assistant history. Masked placeholders then looked like the +model's own prior arguments on the next turn and could be copied into later tool +calls, turning a display/privacy feature into broken executable input. The same +matcher also treated operational metadata names such as Git author fields, +auth-state before/after snapshots, and the SSH agent socket as credentials. +Short quoted bearer values and escaped closing quotes could lose syntax, while a +database-connection matcher could cross line boundaries in source snippets. + +**Solution:** Replayable tool arguments now remain byte-exact in the canonical +assistant message, matching merged upstream PR #54136. The same canonical values +are intentionally persisted in session state and replayed after resume; at-rest +protection therefore belongs to state-file permissions and encryption rather +than destructive placeholder substitution. Anthropic interleaved replay uses +that same canonical map instead of maintaining a contradictory redacted-copy +invariant. The redactor retains its broad credential-name matcher but exempts a +small exact allowlist of operational metadata names, an approach inspired by but +narrower than open upstream PR #47348. Bearer matching now stops before quote and +escape syntax, and the database matcher preserves line and f-string-template +boundaries, adapted from merged upstream PR #54061. + +**Affected files:** + +- `agent/redact.py` +- `agent/chat_completion_helpers.py` +- `agent/anthropic_adapter.py` +- `tests/agent/test_redactor_replay_and_syntax.py` +- `tests/agent/test_anthropic_thinking_block_order.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: the new regression suite failed on the release base for operational + metadata false positives, replay argument mutation, and escaped-quote syntax + corruption. +- Focused Redactor/replay verification -> `102 passed` before the final + Anthropic-invariant update; final Redactor spot-check -> `99 passed`. +- Combined Redactor, Anthropic, and terminal regression matrix -> `274 passed`. +- Final Bitchpack review: Redactor GO after escaped-quote AST validation and + explicit raw-persistence documentation. +- `ruff check`, `py_compile`, and `git diff --check` passed for every affected + Python file. + +**Session reference:** 2026-07-14 Mattermost cleanup follow-up; Wolfram asked for +an upstream-first Amy patch because redactor workarounds cost time and tokens +without reliably protecting executable workflows. + +--- + ## 2026-07-07 - WhatsApp Bridge Audio Patch Cleanup **Problem:** The local bridge-audio patch left two identical `send_voice()` @@ -95,7 +146,7 @@ review. Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `25` patches on `amy/patches` +- Current stack: `26` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` - Exact patch-id absorption check against current `upstream/main`: all 24 pre-2026-07-04 patches showed `+`; the new configurable macOS app-wrapper identity patch still needs a future upstream-overlap check before any PR/rebase decision. Semantic absorption still has to be judged by workflow/code inspection. @@ -135,9 +186,10 @@ These old local patches are no longer carried in the current stack because Herme | `198b4d3b9` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | | `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | -| `this commit` | `feat(gateway): configure macOS app-wrapper identity` | New mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | +| `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | New mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | +| `this commit` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; adapted from merged #54061/#54136 plus a narrower version of open #47348. | -### Current 25-Patch Stack +### Current 26-Patch Stack | Commit | Subject | Current classification | |---|---|---| @@ -165,7 +217,8 @@ These old local patches are no longer carried in the current stack because Herme | `198b4d3b9` | `fix(auxiliary): preserve auto fallback policy for vision calls` | New upstream-worthy auxiliary fallback parity fix; keeps image analysis on `fallback_providers` when the auto-selected main provider is exhausted. | | `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | -| `this commit` | `feat(gateway): configure macOS app-wrapper identity` | Mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | +| `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | Mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | +| `this commit` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; retain until a release contains equivalent merged fixes. | ### Push/Upgrade Implications diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 34e023a99117b..8d74cb42ccec3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1767,18 +1767,15 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: # dropped, leaving thinking signatures and tool_use id/name/input intact. ordered_blocks = m.get("anthropic_content_blocks") if isinstance(ordered_blocks, list) and ordered_blocks: - # Re-source each tool_use input from the stored tool_calls map rather - # than the captured block. The ordered-blocks list captures tool_use - # input from the RAW API response (normalize_response), which is NOT - # credential-redacted; tool_calls[].function.arguments IS redacted at - # storage time (build_assistant_message, #19798). Replaying the raw - # block input would resurrect a secret the model inlined into a tool - # call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'") - # onto the wire, even though the same value is redacted everywhere else - # in history. Keying by sanitized tool id preserves interleave order - # (the reason this channel exists) while swapping in the redacted - # input. Adapted from #36071 (replay-time tool-input re-sourcing). - redacted_input_by_id: Dict[str, Any] = {} + # Re-source each tool_use input from the canonical replayable + # tool_calls map rather than the separately captured ordered block. + # build_assistant_message preserves these arguments byte-exactly because + # masking them poisons subsequent turns and resumed sessions. This means + # credentials in tool arguments are also persisted raw; at-rest security + # belongs to state-file permissions/encryption, not destructive replay + # mutation. Keying by sanitized tool id preserves interleave order while + # keeping one canonical argument copy (upstream #43083 / PR #54136). + replay_input_by_id: Dict[str, Any] = {} for tc in m.get("tool_calls", []) or []: if not isinstance(tc, dict): continue @@ -1788,19 +1785,16 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args except (json.JSONDecodeError, ValueError): parsed_args = {} - redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args + replay_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args replayed: List[Dict[str, Any]] = [] for b in ordered_blocks: clean = _sanitize_replay_block(b) if clean is None: continue if clean.get("type") == "tool_use": - # Override raw (un-redacted) input with the redacted copy when - # we have one for this id; fall back to the sanitized block - # input only if the tool_call is missing (shape mismatch). - redacted = redacted_input_by_id.get(clean.get("id", "")) - if redacted is not None: - clean["input"] = redacted + canonical_input = replay_input_by_id.get(clean.get("id", "")) + if canonical_input is not None: + clean["input"] = canonical_input replayed.append(clean) if replayed: return {"role": "assistant", "content": replayed} diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1ee1702b45e82..dcc4184df0d8b 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1015,18 +1015,15 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic "arguments": tool_call.function.arguments }, } - # Defence-in-depth: redact credentials from tool call arguments - # before they enter conversation history. Tool execution uses the - # raw API response object, not this dict, so redacting the - # persisted shape is safe and only affects storage. Catches the - # case where a model accidentally inlines a secret into a tool - # call (e.g. `terminal(command="curl -H 'Authorization: Bearer - # sk-...'")`). (#19798) - if isinstance(tc_dict["function"]["arguments"], str): - from agent.redact import redact_sensitive_text - tc_dict["function"]["arguments"] = redact_sensitive_text( - tc_dict["function"]["arguments"] - ) + # Keep tool-call arguments byte-exact in replayable conversation + # history. Redacting this copy poisons the next model turn: it sees + # masked placeholders as its own prior arguments and may reuse them + # in subsequent calls. This same canonical dict is deliberately + # persisted to state.db/session snapshots and replayed on resume; + # at-rest protection must come from file permissions/encryption, + # not destructive placeholder substitution. Output/display + # boundaries continue to redact actual leaks (upstream #43083 / + # PR #54136). # Preserve extra_content (e.g. Gemini thought_signature) so it # is sent back on subsequent API calls. Without this, Gemini 3 # thinking models reject the request with a 400 error. diff --git a/agent/redact.py b/agent/redact.py index de247ec0ad2d4..3622ee0684d96 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -107,12 +107,25 @@ r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token ] -# ENV assignment patterns: KEY=value where KEY contains a secret-like name +# ENV assignment patterns: KEY=value where KEY contains a secret-like name. _SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)" _ENV_ASSIGN_RE = re.compile( rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2", ) +# Exact operational metadata names that contain a secret-like substring but +# never hold the secret itself. Keep the broad matcher for compound credential +# fields such as AUTH_KEY, ACCESS_TOKEN_VALUE, and CREDENTIAL_VALUE; a narrow +# allowlist avoids turning that security coverage into a substring free-for-all. +_SAFE_ENV_ASSIGNMENT_NAMES = frozenset({ + "AUTH_BEFORE", + "AUTH_AFTER", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_DATE", + "SSH_AUTH_SOCK", +}) + # JSON field patterns: "apiKey": "value", "token": "value", etc. _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" _JSON_FIELD_RE = re.compile( @@ -122,7 +135,7 @@ # Authorization headers _AUTH_HEADER_RE = re.compile( - r"(Authorization:\s*Bearer\s+)(\S+)", + r"(Authorization:\s*Bearer\s+)([^\s\\\"']+)", re.IGNORECASE, ) @@ -137,10 +150,11 @@ r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----" ) -# Database connection strings: protocol://user:PASSWORD@host -# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password +# Database connection strings: protocol://user:PASSWORD@host. Whitespace is +# forbidden in the userinfo groups so a missing @ cannot consume later source +# lines and corrupt displayed code (upstream #33801 / PR #54061). _DB_CONNSTR_RE = re.compile( - r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)", + r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:)([^@\s]+)(@)", re.IGNORECASE, ) @@ -364,6 +378,8 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F if "=" in text: def _redact_env(m): name, quote, value = m.group(1), m.group(2), m.group(3) + if name in _SAFE_ENV_ASSIGNMENT_NAMES: + return m.group(0) return f"{name}={quote}{_mask_token(value)}{quote}" text = _ENV_ASSIGN_RE.sub(_redact_env, text) @@ -395,9 +411,22 @@ def _redact_telegram(m): if "BEGIN" in text and "-----" in text: text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text) - # Database connection string passwords + # Database connection string passwords. In code-file mode, a pure brace + # expression is an f-string template reference rather than a credential; + # preserve that while still masking literal passwords (upstream #33801). if "://" in text: - text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) + if code_file: + def _redact_db(m): + password = m.group(2) + if password.startswith("{") and password.endswith("}"): + return m.group(0) + return f"{m.group(1)}***{m.group(3)}" + text = _DB_CONNSTR_RE.sub(_redact_db, text) + else: + text = _DB_CONNSTR_RE.sub( + lambda m: f"{m.group(1)}***{m.group(3)}", + text, + ) # JWT tokens (eyJ... — base64-encoded JSON headers) if "eyJ" in text: diff --git a/tests/agent/test_anthropic_thinking_block_order.py b/tests/agent/test_anthropic_thinking_block_order.py index 5455b3339a76c..26f7e36baca35 100644 --- a/tests/agent/test_anthropic_thinking_block_order.py +++ b/tests/agent/test_anthropic_thinking_block_order.py @@ -211,29 +211,26 @@ def test_replay_falls_back_gracefully_without_ordered_blocks(self): assert set(tool_ids) == {"toolu_1", "toolu_2"} -class TestInterleavedReplayCredentialRedaction: - """The verbatim-replay fast path must not leak un-redacted secrets. - - anthropic_content_blocks captures each tool_use ``input`` from the RAW API - response (normalize_response), which is NOT credential-redacted. The - parallel tool_calls[].function.arguments IS redacted at storage time - (build_assistant_message, #19798). If the fast path replays the block's raw - input verbatim, a secret the model inlined into a tool call rides back onto - the wire — even though it is redacted everywhere else in history. The fix - re-sources tool_use input from the redacted tool_calls map by id. +class TestInterleavedReplayCanonicalToolArguments: + """Ordered Anthropic blocks use the canonical byte-exact tool arguments. + + ``anthropic_content_blocks`` and ``tool_calls`` are captured through + separate normalization paths. The latter is the replayable canonical copy + persisted to session state and must not be destructively masked, or resumed + turns can reuse placeholders as real arguments. At-rest protection belongs + to state-file permissions/encryption rather than replay mutation. """ - def test_tool_use_input_resourced_from_redacted_tool_calls(self): - REDACTED = "[REDACTED_SECRET]" - # Ordered channel: raw input carries the live secret (as captured from - # the unredacted API response). + def test_tool_use_input_resourced_from_canonical_tool_calls(self): + stale_value = "stale-ordered-value" + canonical_value = "canonical-runtime-value" ordered = [ - {"type": "thinking", "thinking": "Call the API.", "signature": "sig-AAA"}, + {"type": "thinking", "thinking": "Call the tool.", "signature": "sig-AAA"}, { "type": "tool_use", "id": "toolu_1", "name": "terminal", - "input": {"command": "curl -H 'Authorization: Bearer sk-LIVE-SECRET-123'"}, + "input": {"command": f"echo {stale_value}"}, }, {"type": "thinking", "thinking": "Now the second call.", "signature": "sig-BBB"}, { @@ -243,7 +240,6 @@ def test_tool_use_input_resourced_from_redacted_tool_calls(self): "input": {"command": "echo done"}, }, ] - # Stored tool_calls: arguments already redacted (the #19798 path). assistant_msg = { "role": "assistant", "content": "", @@ -255,7 +251,7 @@ def test_tool_use_input_resourced_from_redacted_tool_calls(self): "function": { "name": "terminal", "arguments": json.dumps( - {"command": f"curl -H 'Authorization: Bearer {REDACTED}'"} + {"command": f"echo {canonical_value}"} ), }, }, @@ -271,9 +267,9 @@ def test_tool_use_input_resourced_from_redacted_tool_calls(self): "anthropic_content_blocks": ordered, } messages = [ - {"role": "user", "content": "Hit the API twice."}, + {"role": "user", "content": "Run both tools."}, assistant_msg, - {"role": "tool", "tool_call_id": "toolu_1", "content": "200 OK"}, + {"role": "tool", "tool_call_id": "toolu_1", "content": "ok"}, {"role": "tool", "tool_call_id": "toolu_2", "content": "done"}, ] @@ -285,18 +281,11 @@ def test_tool_use_input_resourced_from_redacted_tool_calls(self): blocks = assistant_out[-1]["content"] tool_uses = {b["id"]: b for b in blocks if b.get("type") == "tool_use"} - assert set(tool_uses) == {"toolu_1", "toolu_2"}, "tool_use blocks missing/renamed" - - # The replayed input must be the REDACTED value, not the live secret. + assert set(tool_uses) == {"toolu_1", "toolu_2"} replayed_cmd = tool_uses["toolu_1"]["input"]["command"] - assert "sk-LIVE-SECRET-123" not in replayed_cmd, ( - "Un-redacted secret leaked onto the wire via the verbatim-replay " - "fast path. tool_use input must be re-sourced from the redacted " - "tool_calls map, not the raw captured block." - ) - assert REDACTED in replayed_cmd + assert canonical_value in replayed_cmd + assert stale_value not in replayed_cmd - # Interleave order is still preserved (the reason the channel exists). order = [ ("thinking", b.get("signature")) if b.get("type") == "thinking" else ("tool_use", b.get("id")) diff --git a/tests/agent/test_redactor_replay_and_syntax.py b/tests/agent/test_redactor_replay_and_syntax.py new file mode 100644 index 0000000000000..c7d1f830ecac0 --- /dev/null +++ b/tests/agent/test_redactor_replay_and_syntax.py @@ -0,0 +1,171 @@ +"""Regression coverage for non-destructive secret redaction. + +Backports and adapts the behavior proven upstream by #54061 and #54136, +plus a security-preserving narrow exception inspired by open PR #47348. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from agent.chat_completion_helpers import build_assistant_message +from agent.redact import redact_sensitive_text + + +class _FakeToolCall: + def __init__(self, tc_id: str, name: str, arguments: str): + self.id = tc_id + self.type = "function" + self.function = MagicMock() + self.function.name = name + self.function.arguments = arguments + self.extra_content = None + + def __getattr__(self, _name): + return None + + +class _FakeAssistantMessage: + def __init__(self, content: str, tool_calls: list[_FakeToolCall]): + self.content = content + self.tool_calls = tool_calls + self.function_call = None + self.reasoning_content = None + self.model_extra = None + self.reasoning_details = None + + def __getattr__(self, _name): + return None + + +class _FakeAgent: + stream_delta_callback = None + _stream_callback = None + reasoning_callback = None + verbose_logging = False + + def _extract_reasoning(self, _msg): + return None + + def _strip_think_blocks(self, text): + return text + + def _needs_thinking_reasoning_pad(self): + return False + + def _split_responses_tool_id(self, _raw): + return (None, None) + + def _derive_responses_function_call_id(self, _call_id, _response_item_id): + return None + + def _deterministic_call_id(self, _name, _args, index): + return f"det_{index}" + + +def _build_tool_arguments(arguments: str) -> str: + tool_call = _FakeToolCall("call_1", "terminal", arguments) + message = _FakeAssistantMessage("ok", [tool_call]) + built = build_assistant_message(_FakeAgent(), message, "tool_calls") + return built["tool_calls"][0]["function"]["arguments"] + + +@pytest.mark.parametrize( + "name", + [ + "GIT_" + "AUTHOR_NAME", + "GIT_" + "AUTHOR_EMAIL", + "GIT_" + "AUTHOR_DATE", + "AU" + "TH_BEFORE", + "AU" + "TH_AFTER", + "SSH_" + "AUTH_SOCK", + ], +) +def test_operational_metadata_assignments_are_not_secret_fields(name): + text = f"{name}='snapshot-value'" + assert redact_sensitive_text(text, force=True) == text + + +@pytest.mark.parametrize( + "name", + [ + "BASIC_" + "AUTH", + "AUTH_" + "KEY", + "AUTH_" + "TOKEN", + "ACCESS_" + "TOKEN_VALUE", + "MY_" + "CREDENTIAL", + "CREDENTIAL_" + "VALUE", + "SECRET_" + "KEY", + ], +) +def test_actual_secret_field_names_remain_redacted(name): + value = "opaquevalue1234567890" + result = redact_sensitive_text(f"{name}='{value}'", force=True) + assert value not in result + + +def test_auth_header_masking_preserves_closing_quotes(): + header = "Author" + "ization: Bearer " + for quote in ("'", '"'): + text = f"curl -H {quote}{header}shortvalue{quote}" + result = redact_sensitive_text(text, force=True) + assert "shortvalue" not in result + assert result.count(quote) == 2 + assert result.endswith(quote) + + +def test_auth_header_masking_preserves_escaped_closing_quote_syntax(): + import ast + + header = "Author" + "ization: Bearer " + source = f'payload = "{{\\"header\\":\\"{header}shortvalue\\"}}"' + ast.parse(source) + result = redact_sensitive_text(source, force=True) + assert "shortvalue" not in result + ast.parse(result) + + +def test_replayable_tool_arguments_remain_byte_exact(): + password_name = "PG" + "PASSWORD" + command = f"{password_name}='opaquevalue1234567890' psql -h 127.0.0.1" + arguments = json.dumps({"command": command}) + assert _build_tool_arguments(arguments) == arguments + + +def test_replayable_auth_header_arguments_remain_byte_exact(): + header = "Author" + "ization: Bearer " + arguments = json.dumps({"command": f"curl -H '{header}shortvalue' https://example.invalid"}) + assert _build_tool_arguments(arguments) == arguments + + +def test_multiline_connection_template_does_not_consume_following_code(): + scheme = "postgres" + "ql://" + text = ( + f'return f"{scheme}{{user}}:{{password}}@{{host}}"\n' + "@decorator\n" + "def validate(): ..." + ) + result = redact_sensitive_text(text, force=True, code_file=True) + assert result == text + + +def test_multiline_connection_template_keeps_line_boundaries_in_default_mode(): + scheme = "postgres" + "ql://" + text = ( + f'return f"{scheme}{{user}}:{{password}}@{{host}}"\n' + "@decorator\n" + "def validate(): ..." + ) + result = redact_sensitive_text(text, force=True) + assert "@decorator" in result + assert "def validate(): ..." in result + assert result.count("\n") == text.count("\n") + + +def test_literal_connection_password_remains_redacted_in_code_mode(): + scheme = "postgres" + "ql://" + password = "literalpassword123456" + text = f"{scheme}admin:{password}@db.internal/app" + result = redact_sensitive_text(text, force=True, code_file=True) + assert password not in result From 69c7663c6de6b6cb05bf99203fa39673efe01ccf Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Tue, 14 Jul 2026 18:33:50 +0200 Subject: [PATCH 27/28] fix(terminal): ignore inert heredoc background markers Parse heredoc and shell quoting conservatively so Python and AppleScript payload ampersands do not trigger foreground backgrounding guidance. Keep unquoted, shell-consumed, compound, nested, and ambiguous syntax visible; harden ampersand and wrapper detection. Security-hardened adaptation of open upstream PR #63788. --- RELEASE_amy-patches.md | 67 ++- .../test_terminal_heredoc_background_guard.py | 262 ++++++++++++ tools/terminal_tool.py | 400 +++++++++++++++++- 3 files changed, 705 insertions(+), 24 deletions(-) create mode 100644 tests/tools/test_terminal_heredoc_background_guard.py diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index b66a5dc672231..f013ac34ed37a 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,8 +1,8 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 26 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, configurable macOS app-wrapper identity for Amy/TCC, and non-destructive secret-redactor replay handling -**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained; redactor upstream overlap rechecked 2026-07-14 +**Current patch stack:** 27 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, configurable macOS app-wrapper identity for Amy/TCC, non-destructive secret-redactor replay handling, and conservative heredoc-aware shell-background detection +**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained; redactor and shell-guard upstream overlap rechecked 2026-07-14 **Author:** Amy Ravenwolf <amy@ravenwolf.de> > Current goal: keep only Amy/private patches local, and submit every generally useful feature/fix upstream as an open PR so future upgrades have less custom patch baggage. @@ -60,6 +60,59 @@ without reliably protecting executable workflows. --- +## 2026-07-14 - Conservative Heredoc-Aware Shell Background Guard + +**Problem:** The foreground terminal guard scanned quoted heredoc payload as if +it were shell syntax. Python bitwise operators and AppleScript concatenation +therefore triggered fake shell-background warnings and forced pointless command +rewrites. Open upstream PR #63788 addressed the basic false positive by removing +all heredoc bodies, but review found that broad stripping could hide real +background commands through fake markers in quotes/comments/here-strings, +unquoted expansion, shell-interpreter consumers, mismatched delimiters, +compound commands, nested substitutions, and inaccurate quote/backslash rules. +The existing guard also missed unspaced background operators and let help flags +short-circuit explicit background detection. + +**Solution:** The guard now uses a conservative shell-state scanner rather than +blind regular-expression deletion. It strips a body only when every heredoc +delimiter is quoted, exact, terminated, attached to a single uncomplicated +Python/Python3/osascript command, and outside nested shell scopes. Unquoted, +unknown, compound, piped, shell-consumed, nested, or unterminated bodies remain +visible. Ampersand detection now distinguishes real background operators from +escapes, comments, redirections, logical AND, and pure arithmetic while keeping +nested command substitutions and backticks executable to the scanner. Help and +version flags suppress only long-lived-command heuristics, never explicit +background operators. This is a security-hardened adaptation of open upstream +PR #63788 rather than a direct backport. + +**Affected files:** + +- `tools/terminal_tool.py` +- `tests/tools/test_terminal_heredoc_background_guard.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: successive regression gates reproduced `13`, `10`, and `4` failures for + naive stripping, consumer misassociation, unspaced operators, escaped + literals, comments/arithmetic, and nested shell scopes. +- GREEN: heredoc/background suite plus existing foreground timeout-cap tests -> + `48 passed`. +- Combined Redactor, Anthropic, and terminal matrix -> `274 passed`. +- Canonical full suite in a detached clean-env worktree reported `15` failing + files / `29` failing tests plus `4` no-run files; rerunning that exact + 19-file problem set on the pre-patch `54c14d995` baseline produced the same + `15` / `29` / `4` outcome, so the two patches introduced no new full-suite + failure. +- Final Bitchpack spot-check: Shell Guard GO on all previously blocking nested + substitution, quoting, arithmetic, and backtick cases. +- `ruff check`, `py_compile`, and `git diff --check` passed. + +**Session reference:** 2026-07-14 Mattermost cleanup follow-up after a legitimate +Python heredoc containing a bitwise ampersand was rejected as shell backgrounding. + +--- + ## 2026-07-07 - WhatsApp Bridge Audio Patch Cleanup **Problem:** The local bridge-audio patch left two identical `send_voice()` @@ -146,7 +199,7 @@ review. Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `26` patches on `amy/patches` +- Current stack: `27` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` - Exact patch-id absorption check against current `upstream/main`: all 24 pre-2026-07-04 patches showed `+`; the new configurable macOS app-wrapper identity patch still needs a future upstream-overlap check before any PR/rebase decision. Semantic absorption still has to be judged by workflow/code inspection. @@ -187,9 +240,10 @@ These old local patches are no longer carried in the current stack because Herme | `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | | `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | New mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | -| `this commit` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; adapted from merged #54061/#54136 plus a narrower version of open #47348. | +| `8447438c9` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; adapted from merged #54061/#54136 plus a narrower version of open #47348. | +| `this commit` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy conservative replacement for open #63788; removes quoted interpreter-payload false positives without hiding active shell syntax. | -### Current 26-Patch Stack +### Current 27-Patch Stack | Commit | Subject | Current classification | |---|---|---| @@ -218,7 +272,8 @@ These old local patches are no longer carried in the current stack because Herme | `180b27597` | `fix(providers): resolve ProviderProfile plugins in CLI identity` | New upstream-worthy generic provider identity fix extracted while reviewing CoreWeave PR #44250. | | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | | `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | Mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | -| `this commit` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; retain until a release contains equivalent merged fixes. | +| `8447438c9` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; retain until a release contains equivalent merged fixes. | +| `this commit` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy shell-guard fix; retain until #63788 or a hardened equivalent is merged and released. | ### Push/Upgrade Implications diff --git a/tests/tools/test_terminal_heredoc_background_guard.py b/tests/tools/test_terminal_heredoc_background_guard.py new file mode 100644 index 0000000000000..5bfed38997a99 --- /dev/null +++ b/tests/tools/test_terminal_heredoc_background_guard.py @@ -0,0 +1,262 @@ +"""Regression tests for conservative heredoc-aware background detection. + +The guard may ignore ampersands only in quoted heredoc bodies sent to known +non-shell interpreters. Unknown, expandable, or shell-consumed bodies stay +visible so process-management guidance cannot be bypassed. +""" + +from tools.terminal_tool import ( + _foreground_background_guidance as guidance, + _strip_quotes, +) + +AMP = chr(38) +NL = chr(10) + + +class TestInertQuotedHeredocPayloadAllowed: + def test_python_bitwise_and(self): + command = ( + "python3 - <<'PY'" + NL + + "mode = current " + AMP + " 0o777" + NL + + "print(mode)" + NL + + "PY" + ) + assert guidance(command) is None + + def test_applescript_string_concatenation(self): + command = ( + "osascript <<'APPLESCRIPT'" + NL + + 'set output to "count " ' + AMP + " (count of items)" + NL + + "APPLESCRIPT" + ) + assert guidance(command) is None + + def test_double_quoted_numeric_delimiter(self): + command = ( + 'python3 - <<"123"' + NL + + "mode = current " + AMP + " mask" + NL + + "123" + ) + assert guidance(command) is None + + def test_quoted_delimiter_with_punctuation(self): + command = ( + "python3 - <<'END.X'" + NL + + "mode = current " + AMP + " mask" + NL + + "END.X" + ) + assert guidance(command) is None + + def test_dash_delimiter_with_tab_indented_close(self): + command = ( + "python3 - <<-'PY'" + NL + + "\tmode = current " + AMP + " mask" + NL + + "\tPY" + ) + assert guidance(command) is None + + def test_multiple_quoted_heredocs_on_one_opener(self): + command = ( + "python3 - <<'A' 3<<'B'" + NL + + "one " + AMP + " two" + NL + + "A" + NL + + "three " + AMP + " four" + NL + + "B" + ) + assert guidance(command) is None + + +class TestUnsafeHeredocPayloadRemainsVisible: + def test_unquoted_payload_is_still_scanned(self): + command = "cat <<EOF" + NL + "FaceTime " + AMP + " Privacy" + NL + "EOF" + assert guidance(command) is not None + + def test_unquoted_command_substitution_is_still_scanned(self): + command = ( + "cat <<EOF" + NL + + "$(nohup sleep 10 >/dev/null 2>" + AMP + "1 " + AMP + ")" + NL + + "EOF" + ) + assert guidance(command) is not None + + def test_shell_interpreter_payload_is_still_scanned(self): + command = ( + "bash <<'EOF'" + NL + + "nohup sleep 10 >/dev/null 2>" + AMP + "1 " + AMP + NL + + "EOF" + ) + assert guidance(command) is not None + + def test_python_elsewhere_does_not_authorize_bash_heredoc(self): + command = ( + "python3 -c 'pass'; bash <<'EOF'" + NL + + "nohup sleep 10 " + AMP + NL + + "EOF" + ) + assert guidance(command) is not None + + def test_pipeline_python_does_not_authorize_bash_heredoc(self): + command = ( + "bash <<'EOF' | python3" + NL + + "nohup sleep 10 " + AMP + NL + + "EOF" + ) + assert guidance(command) is not None + + def test_line_continuation_does_not_authorize_later_bash_heredoc(self): + command = ( + "python3 -c 'pass'; \\" + NL + + "bash <<'EOF'" + NL + + "nohup sleep 10 " + AMP + NL + + "EOF" + ) + assert guidance(command) is not None + + def test_nested_substitution_does_not_authorize_bash_heredoc(self): + command = ( + "python3 -c $(bash <<'SH'" + NL + + "nohup sleep 100 >/dev/null 2>" + AMP + "1 " + AMP + NL + + "printf pass" + NL + + "SH" + NL + + ")" + ) + assert guidance(command) is not None + + +class TestInactiveMarkersCannotHideShellTail: + def test_marker_in_comment_does_not_hide_background_command(self): + command = ": # <<EOF" + NL + "nohup sleep 10 " + AMP + assert guidance(command) is not None + + def test_marker_in_multiline_quote_does_not_hide_background_command(self): + command = "printf '<<EOF" + NL + "literal'" + NL + "sleep 100 " + AMP + assert guidance(command) is not None + + def test_here_string_does_not_hide_background_command(self): + command = "cat <<<EOF" + NL + "nohup sleep 10 " + AMP + assert guidance(command) is not None + + def test_delimiter_prefix_does_not_hide_background_command(self): + command = ( + "cat <<EOF.txt" + NL + + "payload" + NL + + "EOF.txt" + NL + + "nohup sleep 10 " + AMP + ) + assert guidance(command) is not None + + def test_line_continuation_keeps_opener_background_visible(self): + command = ( + "python3 - <<'PY' \\" + NL + + " >/dev/null " + AMP + NL + + "print('ok')" + NL + + "PY" + ) + assert guidance(command) is not None + + def test_double_quoted_backslash_delimiter_preserves_real_tail(self): + command = ( + 'python3 - <<"E\\OF"' + NL + + 'print("ok")' + NL + + "E\\OF" + NL + + "nohup sleep 10 " + AMP + NL + + "EOF" + ) + assert guidance(command) is not None + + +class TestRealBackgroundingStillBlocked: + def test_trailing_background(self): + assert guidance("python3 server.py " + AMP) is not None + + def test_unspaced_trailing_background(self): + assert guidance("sleep 10" + AMP) is not None + + def test_unspaced_inline_background(self): + assert guidance("sleep 10" + AMP + "echo done") is not None + + def test_unspaced_background_before_newline(self): + assert guidance("sleep 10" + AMP + NL + "echo done") is not None + + def test_inline_background(self): + assert guidance("sleep 100 " + AMP + " echo done") is not None + + def test_help_flag_does_not_bypass_background_detection(self): + assert guidance("sleep 100 " + AMP + " echo --help") is not None + + def test_active_substitution_inside_double_quotes_is_scanned(self): + command = 'echo "$(nohup sleep 10 >/dev/null 2>' + AMP + '1 ' + AMP + ')"' + assert guidance(command) is not None + + def test_apostrophes_inside_double_quotes_do_not_hide_substitution(self): + command = ( + 'echo "it\'s $(nohup sleep 100 >/dev/null 2>' + + AMP + '1 ' + AMP + ') that\'s all"' + ) + assert guidance(command) is not None + + def test_active_substitution_inside_arithmetic_is_scanned(self): + command = ( + "echo $(( $(sleep 100 >/dev/null 2>" + + AMP + "1 " + AMP + " echo 1) + 1 ))" + ) + assert guidance(command) is not None + + def test_active_backtick_wrapper_is_scanned(self): + assert guidance('echo "`nohup sleep 100`"') is not None + + def test_escaped_literal_ampersand_is_allowed(self): + assert guidance("printf foo\\" + AMP) is None + + def test_comment_ampersands_are_allowed(self): + assert guidance("echo ok # R" + AMP + "D" + AMP) is None + + def test_arithmetic_ampersand_is_allowed(self): + assert guidance("echo $((1 " + AMP + " 1))") is None + + def test_background_on_heredoc_opener(self): + command = "python3 - <<'PY' " + AMP + NL + "print('ok')" + NL + "PY" + assert guidance(command) is not None + + def test_background_after_heredoc(self): + command = ( + "python3 - <<'PY'" + NL + + "print('ok')" + NL + + "PY" + NL + + "long_running " + AMP + ) + assert guidance(command) is not None + + +class TestStripQuotesHeredoc: + def test_inert_body_is_removed_but_shell_tail_is_preserved(self): + command = ( + "python3 - <<'PY'" + NL + + "x = left " + AMP + " right" + NL + + "PY" + NL + + "sleep 10 " + AMP + ) + stripped = _strip_quotes(command) + assert "x = left " + AMP + " right" not in stripped + assert "sleep 10 " + AMP in stripped + + def test_normal_heredoc_requires_unindented_terminator(self): + command = ( + "python3 - <<'PY'" + NL + + "payload" + NL + + "\tPY" + NL + + "still payload " + AMP + " text" + NL + + "PY" + ) + assert guidance(command) is None + + def test_dash_heredoc_does_not_accept_space_indented_terminator(self): + command = ( + "python3 - <<-'PY'" + NL + + "payload" + NL + + " PY" + NL + + "still payload " + AMP + " text" + NL + + "PY" + ) + assert guidance(command) is None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index ba3931f773837..162dcaba91924 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1707,28 +1707,389 @@ def _command_requires_pipe_stdin(command: str) -> bool: _SHELL_LEVEL_BACKGROUND_RE = re.compile( - r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE + r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*|`\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE +) +_INERT_HEREDOC_CONSUMER_RE = re.compile( + r"^\s*" + r"(?:[A-Z_][A-Z0-9_]*=\S+\s+)*" + r"(?:env\s+)?" + r"(?:[A-Za-z0-9_./-]+/)?" + r"(?:python(?:3(?:\.\d+)*)?|osascript)(?=\s|$)", + re.IGNORECASE, ) -_INLINE_BACKGROUND_AMP_RE = re.compile(r"\s&\s") -_TRAILING_BACKGROUND_AMP_RE = re.compile(r"\s&\s*(?:#.*)?$") -def _strip_quotes(command: str) -> str: - """Remove single- and double-quoted content so regex checks don't match inside strings. +def _strip_simple_quotes(command: str) -> str: + """Remove inert quotes without erasing shell-active substitutions.""" + result = [] + cursor = 0 + while cursor < len(command): + char = command[cursor] + if char == "'": + closing = command.find("'", cursor + 1) + if closing == -1: + result.append(command[cursor:]) + break + result.append("''") + cursor = closing + 1 + continue + if char == '"': + end = cursor + 1 + while end < len(command): + if command[end] == "\\" and end + 1 < len(command): + end += 2 + continue + if command[end] == '"': + end += 1 + break + end += 1 + if end > len(command) or not command[cursor:end].endswith('"'): + result.append(command[cursor:]) + break + segment = command[cursor:end] + result.append(segment if "$(" in segment or "`" in segment else '""') + cursor = end + continue + if char == "`": + end = cursor + 1 + while end < len(command): + if command[end] == "\\" and end + 1 < len(command): + end += 2 + continue + if command[end] == "`": + end += 1 + break + end += 1 + result.append(command[cursor:end]) + cursor = end + continue + result.append(char) + cursor += 1 + return "".join(result) + + +def _contains_nested_shell_scope(command: str) -> bool: + """Return whether an opener contains nested executable shell syntax.""" + visible = _strip_simple_quotes(command) + return any(marker in visible for marker in ("$(", "`", "<(", ">(")) + + +def _skip_shell_arithmetic(command: str, start: int) -> int: + """Return the position after a balanced shell arithmetic expression.""" + cursor = start + (3 if command.startswith("$((", start) else 2) + depth = 1 + while cursor < len(command): + if command.startswith("((", cursor): + depth += 1 + cursor += 2 + continue + if command.startswith("))", cursor): + depth -= 1 + cursor += 2 + if depth == 0: + return cursor + continue + if command[cursor] == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + cursor += 1 + return len(command) + + +def _contains_background_ampersand(command: str) -> bool: + """Detect a shell background operator outside comments/arithmetic.""" + cursor = 0 + comment = False + while cursor < len(command): + char = command[cursor] + if comment: + if char == "\n": + comment = False + cursor += 1 + continue + if char == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + if char == "#": + previous = command[cursor - 1] if cursor else "" + if cursor == 0 or previous.isspace() or previous in ";&|()": + comment = True + cursor += 1 + continue + if command.startswith("$((", cursor) or command.startswith("((", cursor): + end = _skip_shell_arithmetic(command, cursor) + prefix_len = 3 if command.startswith("$((", cursor) else 2 + arithmetic = command[cursor + prefix_len:end] + if "$(" in arithmetic or "`" in arithmetic: + # Nested command substitutions remain executable. Continue + # scanning their body; pure arithmetic bitwise '&' stays inert. + cursor += prefix_len + continue + cursor = end + continue + if char != "&": + cursor += 1 + continue + + previous = command[cursor - 1] if cursor else "" + following = command[cursor + 1] if cursor + 1 < len(command) else "" + if previous == "&" or following == "&": + cursor += 1 + continue + if previous in "<>" or following == ">": + cursor += 1 + continue + return True + return False + + +def _contains_active_list_or_pipeline_operator(command: str) -> bool: + """Return whether an opener composes multiple shell commands.""" + cursor = 0 + quote = None + comment = False + while cursor < len(command): + char = command[cursor] + if comment: + if char == "\n": + comment = False + cursor += 1 + continue + if quote is not None: + if quote in {'"', '`'} and char == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + if char == quote: + quote = None + cursor += 1 + continue + if char == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + if char in "'\"`": + quote = char + cursor += 1 + continue + if char == "#": + previous = command[cursor - 1] if cursor else "" + if cursor == 0 or previous.isspace() or previous in ";&|()": + comment = True + cursor += 1 + continue + if char in ";|&": + return True + cursor += 1 + return False + + +def _parse_heredoc_operator(command: str, index: int): + """Parse one active ``<<`` redirection and return its shell delimiter.""" + if not command.startswith("<<", index) or command.startswith("<<<", index): + return None + + cursor = index + 2 + strip_tabs = False + if cursor < len(command) and command[cursor] == "-": + strip_tabs = True + cursor += 1 + while cursor < len(command) and command[cursor] in " \t": + cursor += 1 + if cursor >= len(command) or command[cursor] in "\r\n": + return None - This prevents false positives when keywords like 'nohup' or 'setsid' appear - in commit messages, Python -c code, echo arguments, or PR body text. - Also strips backtick-quoted content and heredoc-style inline text. + delimiter = [] + quoted = False + while cursor < len(command): + char = command[cursor] + if char.isspace() or char in ";&|<>()": + break + if char == "\\": + if cursor + 1 >= len(command) or command[cursor + 1] in "\r\n": + return None + quoted = True + delimiter.append(command[cursor + 1]) + cursor += 2 + continue + if char in "'\"": + quoted = True + quote = char + cursor += 1 + while cursor < len(command) and command[cursor] != quote: + if quote == '"' and command[cursor] == "\\": + if cursor + 1 >= len(command): + return None + following = command[cursor + 1] + if following in {'$', '`', '"', "\\", "\n"}: + delimiter.append(following) + cursor += 2 + continue + # In double quotes, backslash is literal before all other + # characters. Preserve it so the terminator stays exact. + delimiter.append("\\") + cursor += 1 + continue + if command[cursor] in "\r\n": + return None + delimiter.append(command[cursor]) + cursor += 1 + if cursor >= len(command): + return None + cursor += 1 + continue + delimiter.append(char) + cursor += 1 + + if not delimiter and not quoted: + return None + return cursor, "".join(delimiter), strip_tabs, quoted + + +def _scan_heredoc_command_unit(command: str, start: int): + """Scan one logical shell command, ignoring markers in quotes/comments.""" + cursor = start + quote = None + comment = False + specs = [] + unknown_operator = False + + while cursor < len(command): + char = command[cursor] + if comment: + if char == "\n": + return cursor, specs, unknown_operator + cursor += 1 + continue + + if quote is not None: + if quote in {'"', '`'} and char == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + if char == quote: + quote = None + cursor += 1 + continue + + if char == "\\" and cursor + 1 < len(command): + cursor += 2 + continue + if char in "'\"`": + quote = char + cursor += 1 + continue + if char == "#": + previous = command[cursor - 1] if cursor > start else "" + if cursor == start or previous.isspace() or previous in ";&|()": + comment = True + cursor += 1 + continue + if char == "\n": + return cursor, specs, unknown_operator + if command.startswith("<<<", cursor): + cursor += 3 + continue + if command.startswith("<<", cursor): + parsed = _parse_heredoc_operator(command, cursor) + if parsed is None: + unknown_operator = True + cursor += 2 + continue + cursor, delimiter, strip_tabs, quoted = parsed + specs.append((delimiter, strip_tabs, quoted)) + continue + cursor += 1 + + return len(command), specs, unknown_operator + + +def _find_heredoc_close( + command: str, + body_start: int, + delimiter: str, + strip_tabs: bool, +) -> int | None: + """Return the position after an exact shell heredoc terminator line.""" + cursor = body_start + while cursor <= len(command): + newline = command.find("\n", cursor) + if newline == -1: + line = command[cursor:] + after = len(command) + else: + line = command[cursor:newline] + after = newline + 1 + if line.endswith("\r"): + line = line[:-1] + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == delimiter: + return after + if newline == -1: + return None + cursor = after + return None + + +def _strip_inert_quoted_heredocs(command: str) -> str: + """Strip only quoted heredoc bodies sent to known non-shell interpreters. + + Unquoted bodies can execute shell expansions, shell-interpreter bodies are + executable, and unknown syntax must stay visible. Conservative retention + may cause a false positive, but can never hide a real background operator. """ - # Remove single-quoted strings (no escaping inside single quotes in shell) - result = re.sub(r"'[^']*'", "''", command) - # Remove double-quoted strings (handle escaped quotes) - result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result) - # Remove backtick-quoted strings - result = re.sub(r"`[^`]*`", "``", result) + ranges = [] + command_start = 0 + + while command_start < len(command): + command_end, specs, unknown_operator = _scan_heredoc_command_unit( + command, + command_start, + ) + if unknown_operator: + return command + if not specs: + if command_end >= len(command): + break + command_start = command_end + 1 + continue + if command_end >= len(command): + return command + + body_cursor = command_end + 1 + body_ranges = [] + for delimiter, strip_tabs, _quoted in specs: + close_end = _find_heredoc_close( + command, + body_cursor, + delimiter, + strip_tabs, + ) + if close_end is None: + return command + body_ranges.append((body_cursor, close_end)) + body_cursor = close_end + + raw_opener = command[command_start:command_end] + opener = _strip_simple_quotes(raw_opener) + if ( + all(quoted for _delimiter, _strip_tabs, quoted in specs) + and not _contains_active_list_or_pipeline_operator(raw_opener) + and not _contains_nested_shell_scope(raw_opener) + and _INERT_HEREDOC_CONSUMER_RE.search(opener) + ): + ranges.extend(body_ranges) + command_start = body_cursor + + result = command + for start, end in reversed(ranges): + replacement = "\n" * result[start:end].count("\n") + result = result[:start] + replacement + result[end:] return result +def _strip_quotes(command: str) -> str: + """Remove inert quoted content while preserving shell-visible syntax.""" + return _strip_simple_quotes(_strip_inert_quoted_heredocs(command)) + + _LONG_LIVED_FOREGROUND_PATTERNS = ( re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)\b", re.IGNORECASE), re.compile(r"\bdocker\s+compose\s+up\b", re.IGNORECASE), @@ -1758,9 +2119,6 @@ def _foreground_background_guidance(command: str) -> str | None: Prevents workflows that start a server/watch process and then stall before follow-up checks or test commands run. """ - if _looks_like_help_or_version_command(command): - return None - # Strip quoted content so keywords inside strings/arguments don't trigger # false positives (e.g., git commit -m "... setsid ...", python3 -c "os.setsid"). unquoted = _strip_quotes(command) @@ -1772,12 +2130,18 @@ def _foreground_background_guidance(command: str) -> str | None: "readiness checks and tests in separate commands." ) - if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted): + if _contains_background_ampersand(unquoted): return ( "Foreground command uses '&' backgrounding. Use terminal(background=true) for long-lived " "processes, then run health checks and tests in follow-up terminal calls." ) + # Help/version flags suppress only long-lived-command heuristics. They must + # never bypass explicit shell wrappers or background operators elsewhere in + # a compound command. + if _looks_like_help_or_version_command(command): + return None + for pattern in _LONG_LIVED_FOREGROUND_PATTERNS: if pattern.search(unquoted): return ( From 50ea1a900fdd2c72ce8099ee3beccaeb813b3d4a Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de> Date: Wed, 15 Jul 2026 01:20:52 +0200 Subject: [PATCH 28/28] fix(gateway): preserve app wrapper during plist recovery --- RELEASE_amy-patches.md | 51 ++++++++++++-- hermes_cli/gateway.py | 8 ++- tests/hermes_cli/test_gateway_service.py | 86 +++++++++++++++++++++++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/RELEASE_amy-patches.md b/RELEASE_amy-patches.md index f013ac34ed37a..38a96575496ec 100644 --- a/RELEASE_amy-patches.md +++ b/RELEASE_amy-patches.md @@ -1,14 +1,51 @@ # Amy's Patches - Changelog (Branch: amy/patches) **Current base:** Hermes Agent v2026.6.19 -**Current patch stack:** 27 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, configurable macOS app-wrapper identity for Amy/TCC, non-destructive secret-redactor replay handling, and conservative heredoc-aware shell-background detection -**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained; redactor and shell-guard upstream overlap rechecked 2026-07-14 +**Current patch stack:** 28 local Amy patches on Hermes Agent v2026.6.19 after the v0.17.0 rebase, gateway self-management hardening, restart-script detector comment fix, Raft optional-platform log quieting, auxiliary vision fallback parity, generic provider-profile/provider-scoped-header plumbing, configurable macOS app-wrapper identity for Amy/TCC, non-destructive secret-redactor replay handling, conservative heredoc-aware shell-background detection, and app-wrapper-preserving missing-plist self-healing +**Current reconciliation reviewed through:** v0.17.0 rebase/verification on 2026-06-21 plus post-restart stack classification on 2026-06-22; upstream-absorbed patches dropped, Amy-private patches retained; redactor and shell-guard upstream overlap plus Devin review-only PR #9 checked 2026-07-14 **Author:** Amy Ravenwolf <amy@ravenwolf.de> > Current goal: keep only Amy/private patches local, and submit every generally useful feature/fix upstream as an open PR so future upgrades have less custom patch baggage. --- +## 2026-07-14 - Preserve App Wrapper During Missing-Plist Self-Heal + +**Problem:** Devin review-only PR #9 found that `hermes gateway start` regenerated +a missing macOS launchd plist with the default raw-Python mode. If the optional +app wrapper was already installed, deleting or losing only the plist therefore +silently discarded the configured Hermes/Amy TCC identity and brought privacy +prompts back under `python3.13`. + +**Solution:** Missing-plist recovery now treats an existing configured app bundle +as the durable wrapper-mode marker. It refreshes that bundle when stale, then +generates the replacement plist with `app_wrapper=True`. Raw-Python installs +remain unchanged when no wrapper bundle exists. The recovery path still defers +launchctl bootstrap when called from inside the gateway process tree. + +**Affected files:** + +- `hermes_cli/gateway.py` +- `tests/hermes_cli/test_gateway_service.py` +- `RELEASE_amy-patches.md` + +**Verification:** + +- RED: the focused regression observed `generated_modes == [False]` instead of + the required `[True]` when the plist was absent but the wrapper existed. +- GREEN: focused current/stale-wrapper recovery matrix -> `4 passed`. +- Canonical complete `test_gateway_service.py` suite with isolated runtime home + -> `200 passed`. +- Devin's remaining seven flags were classified independently: six describe + deliberate/tested behavior or invalid shell syntax, while the MoA startup + observation remains a non-blocking performance watchpoint without an observed + failure. + +**Session reference:** 2026-07-14 Devin review-only fork PR #9 of the validated +`amy/patches` stack. + +--- + ## 2026-07-14 - Non-Destructive Secret Redaction and Replay **Problem:** The generic redactor modified replayable tool-call arguments before @@ -199,7 +236,7 @@ review. Counted from the release base, not by diffing against a moving upstream branch or an unsynced fork branch. Fork `main` is supposed to match the release version that `amy/patches` is based on; it may intentionally lag current `upstream/main` between upgrades. - Current base: `v2026.6.19` -- Current stack: `27` patches on `amy/patches` +- Current stack: `28` patches on `amy/patches` - Pre-v0.17 backup for comparison: `amy/patches-backup-v2026.6.19-20260621-140428` - Pre-v0.17 stack: `32` patches on `v2026.6.5` - Exact patch-id absorption check against current `upstream/main`: all 24 pre-2026-07-04 patches showed `+`; the new configurable macOS app-wrapper identity patch still needs a future upstream-overlap check before any PR/rebase decision. Semantic absorption still has to be judged by workflow/code inspection. @@ -241,9 +278,10 @@ These old local patches are no longer carried in the current stack because Herme | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | | `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | New mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | | `8447438c9` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; adapted from merged #54061/#54136 plus a narrower version of open #47348. | -| `this commit` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy conservative replacement for open #63788; removes quoted interpreter-payload false positives without hiding active shell syntax. | +| `69c7663c6` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy conservative replacement for open #63788; removes quoted interpreter-payload false positives without hiding active shell syntax. | +| `this commit` | `fix(gateway): preserve app wrapper during plist recovery` | Upstream-worthy macOS self-heal fix found by Devin review-only PR #9. | -### Current 27-Patch Stack +### Current 28-Patch Stack | Commit | Subject | Current classification | |---|---|---| @@ -273,7 +311,8 @@ These old local patches are no longer carried in the current stack because Herme | `bc8b031e9` | `feat(providers): support provider-scoped model headers` | New upstream-worthy provider-scoped header plumbing; prevents project/billing/proxy headers from leaking across OpenAI-compatible providers. | | `54c14d995` | `feat(gateway): configure macOS app-wrapper identity` | Mostly upstream-worthy launchd/TCC improvement; local config sets the wrapper display/signing identity to Amy for the Mac mini. | | `8447438c9` | `fix(redact): preserve replayable tool arguments` | Upstream-worthy non-destructive replay and false-positive fix; retain until a release contains equivalent merged fixes. | -| `this commit` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy shell-guard fix; retain until #63788 or a hardened equivalent is merged and released. | +| `69c7663c6` | `fix(terminal): ignore inert heredoc background markers` | Upstream-worthy shell-guard fix; retain until #63788 or a hardened equivalent is merged and released. | +| `this commit` | `fix(gateway): preserve app wrapper during plist recovery` | Upstream-worthy missing-plist self-heal fix; retain with the app-wrapper patch until absorbed upstream. | ### Push/Upgrade Implications diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 8dbdde84b6e0e..809439640ac5e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4040,7 +4040,13 @@ def launchd_start(): # Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade) if not plist_path.exists(): - new_plist = generate_launchd_plist() + # The plist normally records whether launchd uses the optional app + # wrapper. If the plist itself vanished, preserve that mode from the + # installed bundle instead of silently falling back to raw Python. + target_app_wrapper = get_launchd_app_wrapper_path().exists() + if target_app_wrapper and not launchd_app_wrapper_is_current(): + install_launchd_app_wrapper(force=True) + new_plist = generate_launchd_plist(app_wrapper=target_app_wrapper) if _refuse_temp_home_service_write(new_plist, "launchd plist"): sys.exit(1) print("↻ launchd plist missing; regenerating service definition") diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index e8ce493061b83..568507e6b53c5 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1324,10 +1324,18 @@ def fake_run(cmd, **kwargs): def test_launchd_start_defers_missing_plist_bootstrap_inside_gateway_tree(self, tmp_path, monkeypatch): plist_path = tmp_path / "ai.hermes.gateway.plist" + wrapper_path = tmp_path / "missing.app" + generated_modes = [] expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["python"]}).decode("utf-8") monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) - monkeypatch.setattr(gateway_cli, "generate_launchd_plist", lambda app_wrapper=False: expected) + monkeypatch.setattr(gateway_cli, "get_launchd_app_wrapper_path", lambda: wrapper_path) + + def fake_generate(app_wrapper=False): + generated_modes.append(app_wrapper) + return expected + + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", fake_generate) monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) calls = [] @@ -1339,9 +1347,85 @@ def fake_run(cmd, **kwargs): gateway_cli.launchd_start() + assert generated_modes == [False] assert plist_path.read_text(encoding="utf-8") == expected assert calls == [] + def test_launchd_start_missing_plist_preserves_existing_app_wrapper(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + wrapper_path = tmp_path / "Amy.app" + wrapper_path.mkdir() + install_calls = [] + generated_modes = [] + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["Amy"]}).decode("utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "get_launchd_app_wrapper_path", lambda: wrapper_path) + monkeypatch.setattr(gateway_cli, "launchd_app_wrapper_is_current", lambda: True) + monkeypatch.setattr( + gateway_cli, + "install_launchd_app_wrapper", + lambda force=False: install_calls.append(force) or wrapper_path, + ) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) + + def fake_generate(app_wrapper=False): + generated_modes.append(app_wrapper) + return expected + + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", fake_generate) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda cmd, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + gateway_cli.launchd_start() + + assert install_calls == [] + assert generated_modes == [True] + assert plist_path.read_text(encoding="utf-8") == expected + + def test_launchd_start_missing_plist_refreshes_stale_app_wrapper(self, tmp_path, monkeypatch): + plist_path = tmp_path / "ai.hermes.gateway.plist" + wrapper_path = tmp_path / "Amy.app" + wrapper_path.mkdir() + events = [] + install_calls = [] + generated_modes = [] + expected = plistlib.dumps({"Label": "ai.hermes.gateway", "ProgramArguments": ["Amy"]}).decode("utf-8") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "get_launchd_app_wrapper_path", lambda: wrapper_path) + monkeypatch.setattr(gateway_cli, "launchd_app_wrapper_is_current", lambda: False) + + def fake_install(force=False): + events.append("install") + install_calls.append(force) + return wrapper_path + + monkeypatch.setattr(gateway_cli, "install_launchd_app_wrapper", fake_install) + monkeypatch.setattr(gateway_cli, "_is_running_inside_gateway_process_tree", lambda: True, raising=False) + + def fake_generate(app_wrapper=False): + events.append("generate") + generated_modes.append(app_wrapper) + return expected + + monkeypatch.setattr(gateway_cli, "generate_launchd_plist", fake_generate) + monkeypatch.setattr( + gateway_cli.subprocess, + "run", + lambda cmd, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + gateway_cli.launchd_start() + + assert events == ["install", "generate"] + assert install_calls == [True] + assert generated_modes == [True] + assert plist_path.read_text(encoding="utf-8") == expected + def test_running_inside_gateway_process_tree_requires_matching_launchd_job_pid(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_macos", lambda: True) monkeypatch.setattr("gateway.status.get_running_pid", lambda: 123)