From ad7d3bc84c3bccf2f8f714941ca7375179adfe8f Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Thu, 14 May 2026 19:08:38 -0700 Subject: [PATCH 01/48] test(e2e): fix Discord mock exception surface --- tests/e2e/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 332cccee4976..acb999e9e34f 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -66,6 +66,9 @@ def _ensure_discord_mock(): discord_mod.DMChannel = type("DMChannel", (), {}) discord_mod.Thread = type("Thread", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Forbidden = type("Forbidden", (Exception,), {}) + discord_mod.MessageType = SimpleNamespace(default=0, reply=19) + discord_mod.Object = lambda *, id: SimpleNamespace(id=id) discord_mod.Interaction = object discord_mod.app_commands = SimpleNamespace( describe=lambda **kwargs: (lambda fn: fn), From e8a4c85e889b8990ef4cb5d70276b286d82afac7 Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Thu, 14 May 2026 19:24:12 -0700 Subject: [PATCH 02/48] test(run-agent): isolate Nous provider parity model --- tests/run_agent/test_provider_parity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index d3a5a1b37fa1..c65c22004a9a 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -61,6 +61,8 @@ def _make_agent(monkeypatch, provider, api_mode="chat_completions", base_url="ht ) if model: kwargs["model"] = model + elif provider == "nous": + kwargs["model"] = "gpt-5" base_url="https://openrouter.ai/api/v1", api_key="test-key", base_url="https://openrouter.ai/api/v1", From 4e89c53082b13b71d0c7f2f662cd65ea80d9f17c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:00:01 -0700 Subject: [PATCH 03/48] fix(async): close unscheduled coroutines in all threadsafe bridges (#26584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps every sync->async coroutine-scheduling site in the codebase with a new agent.async_utils.safe_schedule_threadsafe() helper that closes the coroutine on scheduling failure (closed loop, shutdown race, etc.) instead of leaking it as 'coroutine was never awaited' RuntimeWarnings plus reference leaks. 22 production call sites migrated across the codebase: - acp_adapter/events.py, acp_adapter/permissions.py - agent/lsp/manager.py - cron/scheduler.py (media + text delivery paths) - gateway/platforms/feishu.py (5 sites, via existing _submit_on_loop helper which now delegates to safe_schedule_threadsafe) - gateway/run.py (10 sites: telegram rename, agent:step hook, status callback, interim+bg-review, clarify send, exec-approval button+text, temp-bubble cleanup, channel-directory refresh) - plugins/memory/hindsight, plugins/platforms/google_chat - tools/browser_supervisor.py (3), browser_cdp_tool.py, computer_use/cua_backend.py, slash_confirm.py - tools/environments/modal.py (_AsyncWorker) - tools/mcp_tool.py (2 + 8 _run_on_mcp_loop callers converted to factory-style so the coroutine is never constructed on a dead loop) - tui_gateway/ws.py Tests: new tests/agent/test_async_utils.py covers helper behavior under live loop, dead loop, None loop, and scheduling exceptions. Regression tests added at three PR-original sites (acp events, acp permissions, mcp loop runner) mirroring contributor's intent. Live-tested end-to-end: - Helper stress test: 1500 schedules across live/dead/race scenarios, zero leaked coroutines - Race exercised: 5000 schedules with loop killed mid-flight, 100 ok / 4900 None returns, zero leaks - hermes chat -q with terminal tool call (exercises step_callback bridge) - MCP probe against failing subprocess servers + factory path - Real gateway daemon boot + SIGINT shutdown across multiple platform adapter inits - WSTransport 100 live + 50 dead-loop writes - Cron delivery path live + dead loop Salvages PR #2657 — adopts contributor's intent over a much wider site list and a single centralized helper instead of inline try/except at each site. 3 of the original PR's 6 sites no longer exist on main (environments/patches.py deleted, DingTalk refactored to native async); the equivalent fix lives in tools/environments/modal.py instead. Co-authored-by: JithendraNara --- acp_adapter/events.py | 13 +- acp_adapter/permissions.py | 27 ++- agent/async_utils.py | 68 +++++++ agent/lsp/manager.py | 7 +- cron/scheduler.py | 39 ++-- gateway/platforms/feishu.py | 44 ++--- gateway/run.py | 206 ++++++++++++--------- plugins/memory/hindsight/__init__.py | 5 +- plugins/platforms/google_chat/adapter.py | 10 +- scripts/release.py | 1 + tests/acp/test_events.py | 46 +++++ tests/acp/test_permissions.py | 49 ++++- tests/agent/test_async_utils.py | 157 ++++++++++++++++ tests/tools/test_mcp_probe.py | 12 +- tests/tools/test_mcp_structured_content.py | 3 +- tests/tools/test_mcp_tool.py | 80 +++++++- tools/browser_cdp_tool.py | 8 +- tools/browser_supervisor.py | 22 ++- tools/computer_use/cua_backend.py | 7 +- tools/environments/modal.py | 7 +- tools/mcp_tool.py | 52 ++++-- tools/slash_confirm.py | 7 +- tui_gateway/ws.py | 6 +- 23 files changed, 690 insertions(+), 186 deletions(-) create mode 100644 agent/async_utils.py create mode 100644 tests/agent/test_async_utils.py diff --git a/acp_adapter/events.py b/acp_adapter/events.py index 1257f902ebbb..f0442ca2e8f3 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -31,10 +31,17 @@ def _send_update( update: Any, ) -> None: """Fire-and-forget an ACP session update from a worker thread.""" + from agent.async_utils import safe_schedule_threadsafe + + future = safe_schedule_threadsafe( + conn.session_update(session_id, update), + loop, + logger=logger, + log_message="Failed to send ACP update", + ) + if future is None: + return try: - future = asyncio.run_coroutine_threadsafe( - conn.session_update(session_id, update), loop - ) future.result(timeout=5) except Exception: logger.debug("Failed to send ACP update", exc_info=True) diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 44aead28742e..76474e55dacf 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -111,21 +111,28 @@ def make_approval_callback( allow_permanent: bool = True, **_: object, ) -> str: + from agent.async_utils import safe_schedule_threadsafe + options = _build_permission_options(allow_permanent=allow_permanent) - future = None + tool_call = _build_permission_tool_call(command, description) + coro = request_permission_fn( + session_id=session_id, + tool_call=tool_call, + options=options, + ) + future = safe_schedule_threadsafe( + coro, loop, + logger=logger, + log_message="Permission request: failed to schedule on loop", + ) + if future is None: + return "deny" + try: - tool_call = _build_permission_tool_call(command, description) - coro = request_permission_fn( - session_id=session_id, - tool_call=tool_call, - options=options, - ) - future = asyncio.run_coroutine_threadsafe(coro, loop) response = future.result(timeout=timeout) except (FutureTimeout, Exception) as exc: - if future is not None: - future.cancel() + future.cancel() logger.warning("Permission request timed out or failed: %s", exc) return "deny" diff --git a/agent/async_utils.py b/agent/async_utils.py new file mode 100644 index 000000000000..d268e1a3a84a --- /dev/null +++ b/agent/async_utils.py @@ -0,0 +1,68 @@ +"""Async/sync bridging helpers. + +The codebase has ~30 sites that schedule a coroutine onto an event loop from a +worker thread via :func:`asyncio.run_coroutine_threadsafe`. That function can +raise :class:`RuntimeError` (e.g. the loop was closed during a shutdown race), +and when it does the coroutine object is never awaited and never closed — +which triggers a ``"coroutine '' was never awaited"`` RuntimeWarning and +leaks the coroutine's frame until GC. + +:func:`safe_schedule_threadsafe` wraps the call, closes the coroutine on +scheduling failure, and returns ``None`` (instead of a half-formed future) so +callers can branch cleanly: + + fut = safe_schedule_threadsafe(coro, loop) + if fut is None: + return # or fallback behavior + fut.result(timeout=5) + +The helper deliberately does NOT also handle ``future.result()`` failures — +that is a separate concern. Once the loop has accepted the coroutine, its +lifecycle belongs to the loop, not the scheduling thread. +""" +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import Future +from typing import Any, Coroutine, Optional + + +_DEFAULT_LOGGER = logging.getLogger(__name__) + + +def safe_schedule_threadsafe( + coro: Coroutine[Any, Any, Any], + loop: Optional[asyncio.AbstractEventLoop], + *, + logger: Optional[logging.Logger] = None, + log_message: str = "Failed to schedule coroutine on loop", + log_level: int = logging.DEBUG, +) -> Optional[Future]: + """Schedule ``coro`` on ``loop`` from a sync context, leak-safe. + + Returns the :class:`concurrent.futures.Future` on success, or ``None`` if + the loop is missing or :func:`asyncio.run_coroutine_threadsafe` raised + (e.g. the loop was closed during a shutdown race). In all failure paths + the coroutine is :meth:`close`-d so it does not trigger + ``"coroutine was never awaited"`` warnings or leak its frame. + + Callers retain full control over what to do with the returned future + (call ``.result(timeout=...)``, attach ``add_done_callback``, ignore it + fire-and-forget, etc.). + """ + log = logger if logger is not None else _DEFAULT_LOGGER + + if loop is None: + if asyncio.iscoroutine(coro): + coro.close() + log.log(log_level, "%s: loop is None", log_message) + return None + + try: + return asyncio.run_coroutine_threadsafe(coro, loop) + except Exception as exc: + if asyncio.iscoroutine(coro): + coro.close() + log.log(log_level, "%s: %s", log_message, exc) + return None diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index 34c0b0ba92b4..7f5feaa170f3 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -107,9 +107,14 @@ class _BackgroundLoop: Returns the coroutine's result, or raises its exception. """ + from agent.async_utils import safe_schedule_threadsafe if self._loop is None: + if asyncio.iscoroutine(coro): + coro.close() raise RuntimeError("background loop not started") - fut: ConcurrentFuture = asyncio.run_coroutine_threadsafe(coro, self._loop) + fut = safe_schedule_threadsafe(coro, self._loop) + if fut is None: + raise RuntimeError("background loop not running") try: return fut.result(timeout=timeout) except Exception: diff --git a/cron/scheduler.py b/cron/scheduler.py index b585ef2e42ba..d470e8c2c746 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -464,7 +464,14 @@ def _send_media_via_adapter( else: coro = adapter.send_document(chat_id=chat_id, file_path=media_path, metadata=metadata) - future = asyncio.run_coroutine_threadsafe(coro, loop) + from agent.async_utils import safe_schedule_threadsafe + future = safe_schedule_threadsafe(coro, loop) + if future is None: + logger.warning( + "Job '%s': cannot send media %s, gateway loop unavailable", + job.get("id", "?"), media_path, + ) + return try: result = future.result(timeout=30) except TimeoutError: @@ -585,22 +592,26 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option text_to_send = cleaned_delivery_content.strip() adapter_ok = True if text_to_send: - future = asyncio.run_coroutine_threadsafe( + from agent.async_utils import safe_schedule_threadsafe + future = safe_schedule_threadsafe( runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata), loop, ) - try: - send_result = future.result(timeout=60) - except TimeoutError: - future.cancel() - raise - if send_result and not getattr(send_result, "success", True): - err = getattr(send_result, "error", "unknown") - logger.warning( - "Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone", - job["id"], platform_name, chat_id, err, - ) - adapter_ok = False # fall through to standalone path + if future is None: + adapter_ok = False + else: + try: + send_result = future.result(timeout=60) + except TimeoutError: + future.cancel() + raise + if send_result and not getattr(send_result, "success", True): + err = getattr(send_result, "error", "unknown") + logger.warning( + "Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone", + job["id"], platform_name, chat_id, err, + ) + adapter_ok = False # fall through to standalone path # Send extracted media files as native attachments via the live adapter if adapter_ok and media_files: diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 8d60046d35d0..a9b0447080de 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -2273,11 +2273,7 @@ class FeishuAdapter(BasePlatformAdapter): daemon=True, ).start() return - future = asyncio.run_coroutine_threadsafe( - self._handle_message_event_data(data), - loop, - ) - future.add_done_callback(self._log_background_failure) + self._submit_on_loop(loop, self._handle_message_event_data(data)) def _enqueue_pending_inbound_event(self, data: Any) -> bool: """Append an event to the pending-inbound queue. @@ -2353,16 +2349,12 @@ class FeishuAdapter(BasePlatformAdapter): dispatched = 0 requeue: List[Any] = [] for event in batch: - try: - fut = asyncio.run_coroutine_threadsafe( - self._handle_message_event_data(event), - loop, - ) - fut.add_done_callback(self._log_background_failure) + if self._submit_on_loop( + loop, self._handle_message_event_data(event) + ): dispatched += 1 - except RuntimeError: - # Loop closed between check and submit — requeue - # and poll again. + else: + # Loop closed/unavailable — requeue and poll again. requeue.append(event) if requeue: with self._pending_inbound_lock: @@ -2466,11 +2458,10 @@ class FeishuAdapter(BasePlatformAdapter): if not self._loop_accepts_callbacks(loop): logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready") return - future = asyncio.run_coroutine_threadsafe( - handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id), + self._submit_on_loop( loop, + handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id), ) - future.add_done_callback(self._log_background_failure) def _on_reaction_event(self, event_type: str, data: Any) -> None: """Route user reactions on bot messages as synthetic text events.""" @@ -2498,11 +2489,7 @@ class FeishuAdapter(BasePlatformAdapter): or bool(getattr(loop, "is_closed", lambda: False)()) ): return - future = asyncio.run_coroutine_threadsafe( - self._handle_reaction_event(event_type, data), - loop, - ) - future.add_done_callback(self._log_background_failure) + self._submit_on_loop(loop, self._handle_reaction_event(event_type, data)) def _on_card_action_trigger(self, data: Any) -> Any: """Handle card-action callback from the Feishu SDK (synchronous). @@ -2548,11 +2535,14 @@ class FeishuAdapter(BasePlatformAdapter): def _submit_on_loop(self, loop: Any, coro: Any) -> bool: """Schedule background work on the adapter loop with shared failure logging.""" - try: - future = asyncio.run_coroutine_threadsafe(coro, loop) - except Exception: - coro.close() - logger.warning("[Feishu] Failed to schedule background callback work", exc_info=True) + from agent.async_utils import safe_schedule_threadsafe + future = safe_schedule_threadsafe( + coro, loop, + logger=logger, + log_message="[Feishu] Failed to schedule background callback work", + log_level=logging.WARNING, + ) + if future is None: return False future.add_done_callback(self._log_background_failure) return True diff --git a/gateway/run.py b/gateway/run.py index 5e8fce8e18de..f41357673f73 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -50,6 +50,7 @@ from typing import Dict, Optional, Any, List, Union # gateway is a long-running daemon, so its boot cost matters less than # preserving the established test-patch surface. from agent.account_usage import fetch_account_usage, render_account_usage_lines +from agent.async_utils import safe_schedule_threadsafe from agent.i18n import t from hermes_cli.config import cfg_get @@ -11217,10 +11218,14 @@ class GatewayRunner: copied_source = dataclasses.replace(source) except Exception: copied_source = source - future = asyncio.run_coroutine_threadsafe( + future = safe_schedule_threadsafe( self._rename_telegram_topic_for_session_title(copied_source, session_id, title), loop, + logger=logger, + log_message="Telegram topic title rename failed to schedule", ) + if future is None: + return def _log_rename_failure(fut) -> None: try: fut.result() @@ -14810,29 +14815,28 @@ class GatewayRunner: def _step_callback_sync(iteration: int, prev_tools: list) -> None: if not _run_still_current(): return - try: - # prev_tools may be list[str] or list[dict] with "name"/"result" - # keys. Normalise to keep "tool_names" backward-compatible for - # user-authored hooks that do ', '.join(tool_names)'. - _names: list[str] = [] - for _t in (prev_tools or []): - if isinstance(_t, dict): - _names.append(_t.get("name") or "") - else: - _names.append(str(_t)) - asyncio.run_coroutine_threadsafe( - _hooks_ref.emit("agent:step", { - "platform": source.platform.value if source.platform else "", - "user_id": source.user_id, - "session_id": session_id, - "iteration": iteration, - "tool_names": _names, - "tools": prev_tools, - }), - _loop_for_step, - ) - except Exception as _e: - logger.debug("agent:step hook error: %s", _e) + # prev_tools may be list[str] or list[dict] with "name"/"result" + # keys. Normalise to keep "tool_names" backward-compatible for + # user-authored hooks that do ', '.join(tool_names)'. + _names: list[str] = [] + for _t in (prev_tools or []): + if isinstance(_t, dict): + _names.append(_t.get("name") or "") + else: + _names.append(str(_t)) + safe_schedule_threadsafe( + _hooks_ref.emit("agent:step", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_id": session_id, + "iteration": iteration, + "tool_names": _names, + "tools": prev_tools, + }), + _loop_for_step, + logger=logger, + log_message="agent:step hook scheduling error", + ) # Bridge sync status_callback → async adapter.send for context pressure _status_adapter = self.adapters.get(source.platform) @@ -14852,27 +14856,28 @@ class GatewayRunner: def _status_callback_sync(event_type: str, message: str) -> None: if not _status_adapter or not _run_still_current(): return - try: - _fut = asyncio.run_coroutine_threadsafe( - _status_adapter.send( - _status_chat_id, - message, - metadata=_status_thread_metadata, - ), - _loop_for_step, - ) - if _cleanup_progress: - def _track_status_id(fut) -> None: - try: - res = fut.result() - except Exception: - return - mid = getattr(res, "message_id", None) - if getattr(res, "success", False) and mid: - _cleanup_msg_ids.append(str(mid)) - _fut.add_done_callback(_track_status_id) - except Exception as _e: - logger.debug("status_callback error (%s): %s", event_type, _e) + _fut = safe_schedule_threadsafe( + _status_adapter.send( + _status_chat_id, + message, + metadata=_status_thread_metadata, + ), + _loop_for_step, + logger=logger, + log_message=f"status_callback ({event_type}) scheduling error", + ) + if _fut is None: + return + if _cleanup_progress: + def _track_status_id(fut) -> None: + try: + res = fut.result() + except Exception: + return + mid = getattr(res, "message_id", None) + if getattr(res, "success", False) and mid: + _cleanup_msg_ids.append(str(mid)) + _fut.add_done_callback(_track_status_id) def run_sync(): # The conditional re-assignment of `message` further below @@ -15026,17 +15031,16 @@ class GatewayRunner: return if already_streamed or not _status_adapter or not str(text or "").strip(): return - try: - asyncio.run_coroutine_threadsafe( - _status_adapter.send( - _status_chat_id, - text, - metadata=_status_thread_metadata, - ), - _loop_for_step, - ) - except Exception as _e: - logger.debug("interim_assistant_callback error: %s", _e) + safe_schedule_threadsafe( + _status_adapter.send( + _status_chat_id, + text, + metadata=_status_thread_metadata, + ), + _loop_for_step, + logger=logger, + log_message="interim_assistant_callback scheduling error", + ) turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs) @@ -15125,17 +15129,16 @@ class GatewayRunner: def _deliver_bg_review_message(message: str) -> None: if not _status_adapter or not _run_still_current(): return - try: - asyncio.run_coroutine_threadsafe( - _status_adapter.send( - _status_chat_id, - message, - metadata=_status_thread_metadata, - ), - _loop_for_step, - ) - except Exception as _e: - logger.debug("background_review_callback error: %s", _e) + safe_schedule_threadsafe( + _status_adapter.send( + _status_chat_id, + message, + metadata=_status_thread_metadata, + ), + _loop_for_step, + logger=logger, + log_message="background_review_callback scheduling error", + ) def _release_bg_review_messages() -> None: _bg_review_release.set() @@ -15207,23 +15210,28 @@ class GatewayRunner: pass send_ok = False - try: - fut = asyncio.run_coroutine_threadsafe( - _status_adapter.send_clarify( - chat_id=_status_chat_id, - question=question, - choices=list(choices) if choices else None, - clarify_id=clarify_id, - session_key=session_key or "", - metadata=_status_thread_metadata, - ), - _loop_for_step, - ) - result = fut.result(timeout=15) - send_ok = bool(getattr(result, "success", False)) - except Exception as exc: - logger.warning("Clarify send failed: %s", exc) + fut = safe_schedule_threadsafe( + _status_adapter.send_clarify( + chat_id=_status_chat_id, + question=question, + choices=list(choices) if choices else None, + clarify_id=clarify_id, + session_key=session_key or "", + metadata=_status_thread_metadata, + ), + _loop_for_step, + logger=logger, + log_message="Clarify send failed to schedule", + ) + if fut is None: send_ok = False + else: + try: + result = fut.result(timeout=15) + send_ok = bool(getattr(result, "success", False)) + except Exception as exc: + logger.warning("Clarify send failed: %s", exc) + send_ok = False if not send_ok: # Couldn't deliver the prompt — clean up and return @@ -15343,7 +15351,7 @@ class GatewayRunner: # false positives from MagicMock auto-attribute creation in tests. if getattr(type(_status_adapter), "send_exec_approval", None) is not None: try: - _approval_result = asyncio.run_coroutine_threadsafe( + _approval_fut = safe_schedule_threadsafe( _status_adapter.send_exec_approval( chat_id=_status_chat_id, command=cmd, @@ -15352,7 +15360,12 @@ class GatewayRunner: metadata=_status_thread_metadata, ), _loop_for_step, - ).result(timeout=15) + logger=logger, + log_message="send_exec_approval scheduling error", + ) + if _approval_fut is None: + raise RuntimeError("send_exec_approval: loop unavailable") + _approval_result = _approval_fut.result(timeout=15) if _approval_result.success: return logger.warning( @@ -15374,14 +15387,18 @@ class GatewayRunner: f"for the session, `/approve always` to approve permanently, or `/deny` to cancel." ) try: - asyncio.run_coroutine_threadsafe( + _approval_send_fut = safe_schedule_threadsafe( _status_adapter.send( _status_chat_id, msg, metadata=_status_thread_metadata, ), _loop_for_step, - ).result(timeout=15) + logger=logger, + log_message="Approval text-send scheduling error", + ) + if _approval_send_fut is not None: + _approval_send_fut.result(timeout=15) except Exception as _e: logger.error("Failed to send approval request: %s", _e) @@ -16343,7 +16360,11 @@ class GatewayRunner: except Exception: pass try: - asyncio.run_coroutine_threadsafe(_delete_all(), _loop_snapshot) + safe_schedule_threadsafe( + _delete_all(), _loop_snapshot, + logger=logger, + log_message="Temp bubble cleanup scheduling error", + ) except Exception: pass @@ -16400,10 +16421,13 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in # this ticker runs in a background thread. Schedule onto # the gateway event loop and wait briefly for completion # so refresh failures are still logged via the except. - fut = asyncio.run_coroutine_threadsafe( - build_channel_directory(adapters), loop + fut = safe_schedule_threadsafe( + build_channel_directory(adapters), loop, + logger=logger, + log_message="Channel directory refresh scheduling error", ) - fut.result(timeout=30) + if fut is not None: + fut.result(timeout=30) except Exception as e: logger.debug("Channel directory refresh error: %s", e) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 3a42a3204533..52b1ac247f17 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -221,8 +221,11 @@ def _get_loop() -> asyncio.AbstractEventLoop: def _run_sync(coro, timeout: float = _DEFAULT_TIMEOUT): """Schedule *coro* on the shared loop and block until done.""" + from agent.async_utils import safe_schedule_threadsafe loop = _get_loop() - future = asyncio.run_coroutine_threadsafe(coro, loop) + future = safe_schedule_threadsafe(coro, loop) + if future is None: + raise RuntimeError("Hindsight loop unavailable") return future.result(timeout=timeout) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 1d58e801f460..d8777bf71012 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -670,10 +670,18 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event") return try: - future = asyncio.run_coroutine_threadsafe(coro, loop) + from agent.async_utils import safe_schedule_threadsafe + future = safe_schedule_threadsafe( + coro, loop, + logger=logger, + log_message="[GoogleChat] Failed to schedule background callback", + log_level=logging.WARNING, + ) except RuntimeError: logger.warning("[GoogleChat] Loop closed between check and submit") return + if future is None: + return future.add_done_callback(self._log_background_failure) # ------------------------------------------------------------------ diff --git a/scripts/release.py b/scripts/release.py index 740b79091b14..c9cd9c173c00 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -62,6 +62,7 @@ AUTHOR_MAP = { "nidhi2894@gmail.com": "nidhi-singh02", "30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel", "oleksii.lisikh@gmail.com": "olisikh", + "jithendranaidunara@gmail.com": "JithendraNara", "jeremy@geocaching.com": "outdoorsea", "leone.parise@gmail.com": "leoneparise", "mr@shu.io": "mrshu", diff --git a/tests/acp/test_events.py b/tests/acp/test_events.py index c9f91a181ed6..56a2687226ce 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -1,6 +1,8 @@ """Tests for acp_adapter.events — callback factories for ACP notifications.""" import asyncio +import gc +import warnings from concurrent.futures import Future from unittest.mock import AsyncMock, MagicMock, patch @@ -10,6 +12,7 @@ import acp from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk from acp_adapter.events import ( + _send_update, make_message_cb, make_step_cb, make_thinking_cb, @@ -325,3 +328,46 @@ class TestMessageCallback: cb("") mock_rcts.assert_not_called() + + +# --------------------------------------------------------------------------- +# Scheduler-failure regression +# --------------------------------------------------------------------------- + +class TestSendUpdate: + def test_scheduler_failure_closes_update_coroutine(self, event_loop_fixture): + """If run_coroutine_threadsafe raises, _send_update must close the coro.""" + created = {"coro": None} + + async def _session_update(session_id, update): + return None + + conn = MagicMock() + + def _capture_update(session_id, update): + created["coro"] = _session_update(session_id, update) + return created["coro"] + + conn.session_update = _capture_update + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with patch( + "agent.async_utils.asyncio.run_coroutine_threadsafe", + side_effect=RuntimeError("scheduler down"), + ): + _send_update(conn, "session-1", event_loop_fixture, {"type": "noop"}) + gc.collect() + + assert created["coro"] is not None + assert created["coro"].cr_frame is None + # Only count warnings about THIS test's coroutine; other tests in the + # same xdist worker (or stdlib mock internals) may emit unrelated + # "coroutine was never awaited" warnings that bleed through. + runtime_warnings = [ + w for w in caught + if issubclass(w.category, RuntimeWarning) + and "was never awaited" in str(w.message) + and "_session_update" in str(w.message) + ] + assert runtime_warnings == [] diff --git a/tests/acp/test_permissions.py b/tests/acp/test_permissions.py index 8bbdeeb392ac..b4c121829dc5 100644 --- a/tests/acp/test_permissions.py +++ b/tests/acp/test_permissions.py @@ -38,7 +38,7 @@ def _invoke_callback( scheduled["loop"] = passed_loop return future - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule): cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=timeout) if use_prompt_path: result = prompt_dangerous_approval( @@ -135,7 +135,7 @@ class TestApprovalBridge: scheduled["loop"] = passed_loop return future - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule): cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=0.01) result = cb("rm -rf /", "dangerous command") @@ -159,10 +159,53 @@ class TestApprovalBridge: scheduled["loop"] = passed_loop return future - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule): cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=1.0) result = cb("echo hi", "demo") scheduled["coro"].close() assert result == "deny" + + +# --------------------------------------------------------------------------- +# Scheduler-failure regression +# --------------------------------------------------------------------------- + +import gc # noqa: E402 +import warnings # noqa: E402 + + +class TestSchedulerFailure: + def test_scheduler_failure_closes_permission_coroutine(self): + """If run_coroutine_threadsafe raises, the coro is closed and we return 'deny'.""" + loop = MagicMock(spec=asyncio.AbstractEventLoop) + created = {"coro": None} + + async def _response_coro(**kwargs): + return _make_response(AllowedOutcome(option_id="allow_once", outcome="selected")) + + def _request_permission(**kwargs): + created["coro"] = _response_coro(**kwargs) + return created["coro"] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with patch( + "agent.async_utils.asyncio.run_coroutine_threadsafe", + side_effect=RuntimeError("scheduler down"), + ): + cb = make_approval_callback(_request_permission, loop, session_id="s1", timeout=0.01) + result = cb("rm -rf /", "dangerous") + gc.collect() + + assert result == "deny" + assert created["coro"] is not None + assert created["coro"].cr_frame is None + runtime_warnings = [ + w for w in caught + if issubclass(w.category, RuntimeWarning) + and "was never awaited" in str(w.message) + and "_response_coro" in str(w.message) + ] + assert runtime_warnings == [] diff --git a/tests/agent/test_async_utils.py b/tests/agent/test_async_utils.py new file mode 100644 index 000000000000..33ce84ee0c6d --- /dev/null +++ b/tests/agent/test_async_utils.py @@ -0,0 +1,157 @@ +"""Tests for agent.async_utils.safe_schedule_threadsafe.""" + +from __future__ import annotations + +import asyncio +import gc +import warnings +from concurrent.futures import Future +from unittest.mock import patch + +import pytest + +from agent.async_utils import safe_schedule_threadsafe + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _no_unawaited_warnings(caught, *, coro_name: str = "") -> bool: + """Return True if no "X was never awaited" warning slipped through. + + When *coro_name* is provided, only warnings naming that coroutine are + counted — xdist workers may emit unrelated unawaited-coroutine warnings + (e.g. ``AsyncMockMixin._execute_mock_call``) from concurrent tests. + """ + bad = [ + w for w in caught + if issubclass(w.category, RuntimeWarning) + and "was never awaited" in str(w.message) + and (not coro_name or coro_name in str(w.message)) + ] + return not bad + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestSafeScheduleThreadsafe: + def test_returns_future_on_success(self): + loop = asyncio.new_event_loop() + try: + import threading + ready = threading.Event() + stop = threading.Event() + + def _runner(): + asyncio.set_event_loop(loop) + ready.set() + loop.run_until_complete(_wait_for_stop(stop)) + + async def _wait_for_stop(ev): + while not ev.is_set(): + await asyncio.sleep(0.005) + + t = threading.Thread(target=_runner, daemon=True) + t.start() + ready.wait(timeout=2) + + async def _sample(): + return 42 + + fut = safe_schedule_threadsafe(_sample(), loop) + assert isinstance(fut, Future) + assert fut.result(timeout=2) == 42 + + stop.set() + t.join(timeout=2) + finally: + if loop.is_running(): + loop.call_soon_threadsafe(loop.stop) + loop.close() + + def test_closed_loop_returns_none_and_closes_coroutine(self): + loop = asyncio.new_event_loop() + loop.close() + + async def _sample(): + return "ok" + + coro = _sample() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = safe_schedule_threadsafe(coro, loop) + del coro + gc.collect() + + assert result is None + assert _no_unawaited_warnings(caught, coro_name='_sample') + + def test_none_loop_returns_none_and_closes_coroutine(self): + async def _sample(): + return "ok" + + coro = _sample() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = safe_schedule_threadsafe(coro, None) + del coro + gc.collect() + + assert result is None + assert _no_unawaited_warnings(caught, coro_name='_sample') + + def test_scheduling_exception_closes_coroutine(self): + """If run_coroutine_threadsafe raises, close the coroutine and return None.""" + # A loop that *looks* open but raises on submission + loop = asyncio.new_event_loop() + try: + async def _sample(): + return "ok" + + coro = _sample() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with patch( + "agent.async_utils.asyncio.run_coroutine_threadsafe", + side_effect=RuntimeError("scheduler down"), + ): + result = safe_schedule_threadsafe(coro, loop) + del coro + gc.collect() + + assert result is None + assert _no_unawaited_warnings(caught, coro_name='_sample') + finally: + loop.close() + + def test_logs_at_specified_level(self, caplog): + import logging + loop = asyncio.new_event_loop() + loop.close() + + async def _sample(): + return None + + custom = logging.getLogger("test_async_utils") + with caplog.at_level(logging.WARNING, logger="test_async_utils"): + result = safe_schedule_threadsafe( + _sample(), loop, + logger=custom, + log_message="custom-msg", + log_level=logging.WARNING, + ) + + assert result is None + assert any("custom-msg" in rec.message for rec in caplog.records) + + def test_non_coroutine_arg_does_not_crash(self): + """Defensive: even if the caller hands us something weird, don't blow up.""" + loop = asyncio.new_event_loop() + loop.close() + + # Pass a non-coroutine sentinel + result = safe_schedule_threadsafe("not-a-coroutine", loop) # type: ignore[arg-type] + assert result is None diff --git a/tests/tools/test_mcp_probe.py b/tests/tools/test_mcp_probe.py index 46459e44c874..89d4d1478d18 100644 --- a/tests/tools/test_mcp_probe.py +++ b/tests/tools/test_mcp_probe.py @@ -69,7 +69,8 @@ class TestProbeMcpServerTools: patch("tools.mcp_tool._stop_mcp_loop"): # Simulate running the async probe - def run_coro(coro, timeout=120): + def run_coro(coro_or_factory, timeout=120): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) @@ -110,7 +111,8 @@ class TestProbeMcpServerTools: patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \ patch("tools.mcp_tool._stop_mcp_loop"): - def run_coro(coro, timeout=120): + def run_coro(coro_or_factory, timeout=120): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) @@ -144,7 +146,8 @@ class TestProbeMcpServerTools: patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \ patch("tools.mcp_tool._stop_mcp_loop"): - def run_coro(coro, timeout=120): + def run_coro(coro_or_factory, timeout=120): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) @@ -198,7 +201,8 @@ class TestProbeMcpServerTools: patch("tools.mcp_tool._run_on_mcp_loop") as mock_run, \ patch("tools.mcp_tool._stop_mcp_loop"): - def run_coro(coro, timeout=120): + def run_coro(coro_or_factory, timeout=120): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) diff --git a/tests/tools/test_mcp_structured_content.py b/tests/tools/test_mcp_structured_content.py index 2870ce1e8607..f4cda00f9f01 100644 --- a/tests/tools/test_mcp_structured_content.py +++ b/tests/tools/test_mcp_structured_content.py @@ -31,7 +31,8 @@ class _FakeCallToolResult: self.structuredContent = structuredContent -def _fake_run_on_mcp_loop(coro, timeout=30): +def _fake_run_on_mcp_loop(coro_or_factory, timeout=30): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory """Run an MCP coroutine directly in a fresh event loop.""" loop = asyncio.new_event_loop() try: diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 5558a0df48c1..7f6c3f6704c2 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -397,6 +397,77 @@ class TestCheckFunction: _servers.pop("test_server", None) +# --------------------------------------------------------------------------- +# MCP loop runner +# --------------------------------------------------------------------------- + +class TestRunOnMcpLoop: + def test_scheduler_failure_closes_factory_coroutine(self): + """If run_coroutine_threadsafe raises, the factory's coroutine is closed.""" + import gc + import warnings + import tools.mcp_tool as mcp + + created = {"coro": None} + + async def _sample(): + return "ok" + + def factory(): + created["coro"] = _sample() + return created["coro"] + + fake_loop = MagicMock() + fake_loop.is_running.return_value = True + + with patch.object(mcp, "_mcp_loop", fake_loop): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with patch( + "agent.async_utils.asyncio.run_coroutine_threadsafe", + side_effect=RuntimeError("scheduler down"), + ): + with pytest.raises(RuntimeError): + mcp._run_on_mcp_loop(factory) + gc.collect() + + assert created["coro"] is not None + assert created["coro"].cr_frame is None + runtime_warnings = [ + w for w in caught + if issubclass(w.category, RuntimeWarning) + and "was never awaited" in str(w.message) + and "_sample" in str(w.message) + ] + assert runtime_warnings == [] + + def test_dead_loop_closes_passed_coroutine(self): + """If loop is None, a passed coroutine (not factory) is closed.""" + import gc + import warnings + import tools.mcp_tool as mcp + + async def _sample(): + return "ok" + + coro = _sample() + with patch.object(mcp, "_mcp_loop", None): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(RuntimeError, match="not running"): + mcp._run_on_mcp_loop(coro) + gc.collect() + + assert coro.cr_frame is None + runtime_warnings = [ + w for w in caught + if issubclass(w.category, RuntimeWarning) + and "was never awaited" in str(w.message) + and "_sample" in str(w.message) + ] + assert runtime_warnings == [] + + # --------------------------------------------------------------------------- # Tool handler # --------------------------------------------------------------------------- @@ -406,7 +477,8 @@ class TestToolHandler: def _patch_mcp_loop(self, coro_side_effect=None): """Return a patch for _run_on_mcp_loop that runs the coroutine directly.""" - def fake_run(coro, timeout=30): + def fake_run(coro_or_factory, timeout=30): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory return asyncio.run(coro) if coro_side_effect: return patch("tools.mcp_tool._run_on_mcp_loop", side_effect=coro_side_effect) @@ -485,7 +557,8 @@ class TestToolHandler: try: handler = _make_tool_handler("test_srv", "greet", 120) - def _interrupting_run(coro, timeout=30): + def _interrupting_run(coro_or_factory, timeout=30): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory coro.close() raise InterruptedError("User sent a new message") with patch( @@ -1792,7 +1865,8 @@ class TestUtilityHandlers: def _patch_mcp_loop(self): """Return a patch for _run_on_mcp_loop that runs the coroutine directly.""" - def fake_run(coro, timeout=30): + def fake_run(coro_or_factory, timeout=30): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory return asyncio.run(coro) return patch("tools.mcp_tool._run_on_mcp_loop", side_effect=fake_run) diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index 8e829556a57d..f10a15419233 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -274,7 +274,13 @@ def _browser_cdp_via_supervisor( ) try: - fut = _asyncio.run_coroutine_threadsafe(_do_cdp(), loop) + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe(_do_cdp(), loop) + if fut is None: + return tool_error( + "CDP call via supervisor failed: loop unavailable", + cdp_docs=CDP_DOCS_URL, + ) result_msg = fut.result(timeout=timeout + 2) except Exception as exc: return tool_error( diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index af8d40ee1853..73dd3e51bb5c 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -368,11 +368,13 @@ class CDPSupervisor: pass try: - fut = asyncio.run_coroutine_threadsafe(_close_ws(), loop) - try: - fut.result(timeout=2.0) - except Exception: - pass + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe(_close_ws(), loop) + if fut is not None: + try: + fut.result(timeout=2.0) + except Exception: + pass except RuntimeError: pass # loop already shutting down if self._thread is not None: @@ -451,7 +453,10 @@ class CDPSupervisor: ) try: - fut = asyncio.run_coroutine_threadsafe(_do_respond(), loop) + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe(_do_respond(), loop) + if fut is None: + return {"ok": False, "error": "Browser supervisor loop unavailable"} fut.result(timeout=timeout) except Exception as e: return {"ok": False, "error": f"{type(e).__name__}: {e}"} @@ -507,7 +512,10 @@ class CDPSupervisor: ) try: - fut = asyncio.run_coroutine_threadsafe(_do_eval(), loop) + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe(_do_eval(), loop) + if fut is None: + return {"ok": False, "error": "Browser supervisor loop unavailable"} response = fut.result(timeout=timeout + 1) except Exception as exc: return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index df1162c5d79b..96aab60f8c72 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -183,9 +183,14 @@ class _AsyncBridge: raise RuntimeError("cua-driver asyncio bridge failed to start") def run(self, coro, timeout: Optional[float] = 30.0) -> Any: + from agent.async_utils import safe_schedule_threadsafe if not self._loop or not self._thread or not self._thread.is_alive(): + if asyncio.iscoroutine(coro): + coro.close() + raise RuntimeError("cua-driver bridge not started") + fut = safe_schedule_threadsafe(coro, self._loop) + if fut is None: raise RuntimeError("cua-driver bridge not started") - fut: Future = asyncio.run_coroutine_threadsafe(coro, self._loop) return fut.result(timeout=timeout) def stop(self) -> None: diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 1a230d85603b..3137b322113f 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -144,9 +144,14 @@ class _AsyncWorker: self._loop.run_forever() def run_coroutine(self, coro, timeout=600): + from agent.async_utils import safe_schedule_threadsafe if self._loop is None or self._loop.is_closed(): + if asyncio.iscoroutine(coro): + coro.close() + raise RuntimeError("AsyncWorker loop is not running") + future = safe_schedule_threadsafe(coro, self._loop) + if future is None: raise RuntimeError("AsyncWorker loop is not running") - future = asyncio.run_coroutine_threadsafe(coro, self._loop) return future.result(timeout=timeout) def stop(self): diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index c2668395e5dd..ba104cc42739 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1781,7 +1781,7 @@ def _handle_auth_error_and_retry( return await manager.handle_401(server_name, None) try: - recovered = _run_on_mcp_loop(_recover(), timeout=10) + recovered = _run_on_mcp_loop(_recover, timeout=10) except Exception as rec_exc: logger.warning( "MCP OAuth '%s': recovery attempt failed: %s", @@ -2054,19 +2054,35 @@ def _ensure_mcp_loop(): _mcp_thread.start() -def _run_on_mcp_loop(coro, timeout: float = 30): +def _run_on_mcp_loop(coro_or_factory, timeout: float = 30): """Schedule a coroutine on the MCP event loop and block until done. + Accepts either a coroutine object or a zero-arg callable that returns one. + Callers can pass a factory to avoid constructing coroutine objects when + the MCP loop is unavailable (which would otherwise leak the coroutine + frame and emit ``"coroutine was never awaited"`` warnings). + Poll in short intervals so the calling agent thread can honor user interrupts while the MCP work is still running on the background loop. """ from tools.interrupt import is_interrupted + from agent.async_utils import safe_schedule_threadsafe with _lock: loop = _mcp_loop if loop is None or not loop.is_running(): + if asyncio.iscoroutine(coro_or_factory): + coro_or_factory.close() raise RuntimeError("MCP event loop is not running") - future = asyncio.run_coroutine_threadsafe(coro, loop) + + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory + future = safe_schedule_threadsafe( + coro, loop, + logger=logger, + log_message="MCP scheduling failed", + ) + if future is None: + raise RuntimeError("MCP event loop unavailable (failed to schedule)") start_time = time.monotonic() deadline = None if timeout is None else start_time + timeout @@ -2263,7 +2279,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): return json.dumps({"result": text_result}, ensure_ascii=False) def _call_once(): - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + return _run_on_mcp_loop(_call, timeout=tool_timeout) try: result = _call_once() @@ -2343,7 +2359,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): return json.dumps({"resources": resources}, ensure_ascii=False) def _call_once(): - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + return _run_on_mcp_loop(_call, timeout=tool_timeout) try: return _call_once() @@ -2403,7 +2419,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): return json.dumps({"result": "\n".join(parts) if parts else ""}, ensure_ascii=False) def _call_once(): - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + return _run_on_mcp_loop(_call, timeout=tool_timeout) try: return _call_once() @@ -2466,7 +2482,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): return json.dumps({"prompts": prompts}, ensure_ascii=False) def _call_once(): - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + return _run_on_mcp_loop(_call, timeout=tool_timeout) try: return _call_once() @@ -2537,7 +2553,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): return json.dumps(resp, ensure_ascii=False) def _call_once(): - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + return _run_on_mcp_loop(_call, timeout=tool_timeout) try: return _call_once() @@ -3121,7 +3137,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: if _was_interrupted: _set_interrupt(False) try: - _run_on_mcp_loop(_discover_all(), timeout=120) + _run_on_mcp_loop(_discover_all, timeout=120) finally: if _was_interrupted: _set_interrupt(True) @@ -3289,7 +3305,7 @@ def probe_mcp_server_tools() -> Dict[str, List[tuple]]: ) try: - _run_on_mcp_loop(_probe_all(), timeout=120) + _run_on_mcp_loop(_probe_all, timeout=120) except Exception as exc: logger.debug("MCP probe failed: %s", exc) finally: @@ -3329,11 +3345,17 @@ def shutdown_mcp_servers(): with _lock: loop = _mcp_loop if loop is not None and loop.is_running(): - try: - future = asyncio.run_coroutine_threadsafe(_shutdown(), loop) - future.result(timeout=15) - except Exception as exc: - logger.debug("Error during MCP shutdown: %s", exc) + from agent.async_utils import safe_schedule_threadsafe + future = safe_schedule_threadsafe( + _shutdown(), loop, + logger=logger, + log_message="MCP shutdown: failed to schedule", + ) + if future is not None: + try: + future.result(timeout=15) + except Exception as exc: + logger.debug("Error during MCP shutdown: %s", exc) _stop_mcp_loop() diff --git a/tools/slash_confirm.py b/tools/slash_confirm.py index 81c152635276..21db18fe3197 100644 --- a/tools/slash_confirm.py +++ b/tools/slash_confirm.py @@ -153,9 +153,14 @@ def resolve_sync_compat( Prefer the async ``resolve()`` from an async context. """ try: - fut = asyncio.run_coroutine_threadsafe( + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe( resolve(session_key, confirm_id, choice), loop, + logger=logger, + log_message="resolve_sync_compat scheduling failed", ) + if fut is None: + return None return fut.result(timeout=30) except Exception as exc: logger.error("resolve_sync_compat failed: %s", exc) diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 1661811dbd64..a5879ef3a1c7 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -83,7 +83,11 @@ class WSTransport: return True try: - fut = asyncio.run_coroutine_threadsafe(self._safe_send(line), self._loop) + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe(self._safe_send(line), self._loop) + if fut is None: + self._closed = True + return False fut.result(timeout=_WS_WRITE_TIMEOUT_S) return not self._closed except Exception as exc: From 13c3d4b4efa2f39d7bc3178cf3eca77167ff7699 Mon Sep 17 00:00:00 2001 From: kchantharuan Date: Wed, 13 May 2026 12:46:07 -0700 Subject: [PATCH 04/48] feat(nvidia): add NIM billing origin header --- agent/auxiliary_client.py | 25 +++++++ run_agent.py | 30 ++++++-- tests/agent/test_auxiliary_client.py | 41 +++++++++++ tests/providers/test_provider_profiles.py | 4 ++ .../test_provider_attribution_headers.py | 68 +++++++++++++++++++ 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index cd655e70e56f..1c7dd9f7497e 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -369,6 +369,21 @@ def build_or_headers(or_config: dict | None = None) -> dict: return headers + +# NVIDIA NIM cloud billing attribution. Keep this host-gated because the +# nvidia provider also supports local/on-prem NIM endpoints via NVIDIA_BASE_URL. +_NVIDIA_NIM_CLOUD_HEADERS = { + "X-BILLING-INVOKE-ORIGIN": "HermesAgent", +} + + +def build_nvidia_nim_headers(base_url: str | None) -> dict: + """Return NVIDIA NIM cloud attribution headers for build.nvidia.com traffic.""" + if base_url_host_matches(str(base_url or ""), "integrate.api.nvidia.com"): + return dict(_NVIDIA_NIM_CLOUD_HEADERS) + return {} + + # Vercel AI Gateway app attribution headers. HTTP-Referer maps to # referrerUrl and X-Title maps to appName in the gateway's analytics. from hermes_cli import __version__ as _HERMES_VERSION @@ -1372,6 +1387,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() + elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): + extra["default_headers"] = build_nvidia_nim_headers(base_url) else: try: from providers import get_provider_profile as _gpf_aux @@ -1407,6 +1424,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() + elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): + extra["default_headers"] = build_nvidia_nim_headers(base_url) else: try: from providers import get_provider_profile as _gpf_aux2 @@ -2690,6 +2709,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): ) elif base_url_host_matches(sync_base_url, "api.kimi.com"): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} + elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): + async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -2951,6 +2972,8 @@ def resolve_provider_client( extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision ) + elif base_url_host_matches(custom_base, "integrate.api.nvidia.com"): + extra["default_headers"] = build_nvidia_nim_headers(custom_base) else: # Fall back to profile.default_headers for providers that # declare client-level attribution headers on their profile. @@ -3149,6 +3172,8 @@ def resolve_provider_client( headers.update(copilot_request_headers( is_agent_turn=True, is_vision=is_vision )) + elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): + headers.update(build_nvidia_nim_headers(base_url)) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI diff --git a/run_agent.py b/run_agent.py index a82c6417ae1f..7e42beb3eba4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1664,6 +1664,9 @@ class AIAgent: if base_url_host_matches(effective_base, "openrouter.ai"): from agent.auxiliary_client import build_or_headers client_kwargs["default_headers"] = build_or_headers() + elif base_url_host_matches(effective_base, "integrate.api.nvidia.com"): + from agent.auxiliary_client import build_nvidia_nim_headers + client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base) elif base_url_host_matches(effective_base, "api.routermint.com"): client_kwargs["default_headers"] = _routermint_headers() elif base_url_host_matches(effective_base, "api.githubcopilot.com"): @@ -1702,9 +1705,15 @@ class AIAgent: } if _provider_timeout is not None: client_kwargs["timeout"] = _provider_timeout - # Preserve any default_headers the router set - if hasattr(_routed_client, '_default_headers') and _routed_client._default_headers: - client_kwargs["default_headers"] = dict(_routed_client._default_headers) + # Preserve provider-specific headers the router set. The + # OpenAI SDK stores caller-provided default_headers in + # _custom_headers; older/mocked clients may expose + # _default_headers instead. + _routed_headers = getattr(_routed_client, "_custom_headers", None) + if not _routed_headers: + _routed_headers = getattr(_routed_client, "_default_headers", None) + if _routed_headers: + client_kwargs["default_headers"] = dict(_routed_headers) else: # When the user explicitly chose a non-OpenRouter provider # but no credentials were found, fail fast with a clear @@ -1753,8 +1762,11 @@ class AIAgent: } if _provider_timeout is not None: client_kwargs["timeout"] = _provider_timeout - if hasattr(_fb_client, "_default_headers") and _fb_client._default_headers: - client_kwargs["default_headers"] = dict(_fb_client._default_headers) + _fb_headers = getattr(_fb_client, "_custom_headers", None) + if not _fb_headers: + _fb_headers = getattr(_fb_client, "_default_headers", None) + if _fb_headers: + client_kwargs["default_headers"] = dict(_fb_headers) _fb_resolved = True break if not _fb_resolved: @@ -7334,12 +7346,18 @@ class AIAgent: return True def _apply_client_headers_for_base_url(self, base_url: str) -> None: - from agent.auxiliary_client import _AI_GATEWAY_HEADERS, build_or_headers + from agent.auxiliary_client import ( + _AI_GATEWAY_HEADERS, + build_nvidia_nim_headers, + build_or_headers, + ) if base_url_host_matches(base_url, "openrouter.ai"): self._client_kwargs["default_headers"] = build_or_headers() elif base_url_host_matches(base_url, "ai-gateway.vercel.sh"): self._client_kwargs["default_headers"] = dict(_AI_GATEWAY_HEADERS) + elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): + self._client_kwargs["default_headers"] = build_nvidia_nim_headers(base_url) elif base_url_host_matches(base_url, "api.routermint.com"): self._client_kwargs["default_headers"] = _routermint_headers() elif base_url_host_matches(base_url, "api.githubcopilot.com"): diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index c25ca2193799..9dd85762956c 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2415,10 +2415,51 @@ def _clean_env(monkeypatch): """Strip provider env vars so each test starts clean.""" for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", + "NVIDIA_API_KEY", "NVIDIA_BASE_URL", ): monkeypatch.delenv(key, raising=False) +class TestNvidiaBillingHeaders: + """NVIDIA NIM billing-origin headers are scoped to NVIDIA cloud.""" + + def test_resolve_provider_client_cloud_adds_billing_origin_header(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "nvidia-key") + monkeypatch.delenv("NVIDIA_BASE_URL", raising=False) + mock_openai = MagicMock() + mock_openai.return_value = MagicMock(name="nvidia-client") + + with patch("agent.auxiliary_client.OpenAI", mock_openai): + client, model = resolve_provider_client( + provider="nvidia", + model="nvidia/test-model", + ) + + assert client is not None + assert model == "nvidia/test-model" + call_kwargs = mock_openai.call_args[1] + headers = call_kwargs["default_headers"] + assert headers["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent" + + def test_resolve_provider_client_local_nim_skips_billing_origin_header(self, monkeypatch): + monkeypatch.setenv("NVIDIA_API_KEY", "nvidia-key") + monkeypatch.setenv("NVIDIA_BASE_URL", "http://localhost:8000/v1") + mock_openai = MagicMock() + mock_openai.return_value = MagicMock(name="nvidia-local-client") + + with patch("agent.auxiliary_client.OpenAI", mock_openai): + client, model = resolve_provider_client( + provider="nvidia", + model="nvidia/test-model", + ) + + assert client is not None + assert model == "nvidia/test-model" + call_kwargs = mock_openai.call_args[1] + headers = call_kwargs.get("default_headers", {}) + assert "X-BILLING-INVOKE-ORIGIN" not in headers + + class TestOpenRouterExplicitApiKey: """Test that explicit_api_key is correctly propagated to _try_openrouter().""" diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index c79ed2aea9b5..df96a80fd80b 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -42,6 +42,10 @@ class TestNvidiaProfile: p = get_provider_profile("nvidia") assert "nvidia.com" in p.base_url + def test_billing_header_not_profile_wide(self): + p = get_provider_profile("nvidia") + assert p.default_headers == {} + class TestKimiProfile: def test_temperature_omit(self): diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index 2a1d9088c466..a4ce301a8575 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -3,6 +3,7 @@ Mirrors the OpenRouter pattern for the Vercel AI Gateway so that referrerUrl / appName / User-Agent flow into gateway analytics. """ +from types import SimpleNamespace from unittest.mock import MagicMock, patch from run_agent import AIAgent @@ -65,6 +66,73 @@ def test_routermint_base_url_applies_user_agent_header(mock_openai): assert headers["User-Agent"].startswith("HermesAgent/") +@patch("run_agent.OpenAI") +def test_nvidia_cloud_base_url_applies_billing_origin_header(mock_openai): + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://integrate.api.nvidia.com/v1", + model="nvidia/test-model", + provider="nvidia", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent._client_kwargs["default_headers"]["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent" + + agent._apply_client_headers_for_base_url("https://integrate.api.nvidia.com/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent" + + +@patch("run_agent.OpenAI") +def test_nvidia_local_base_url_does_not_apply_billing_origin_header(mock_openai): + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://integrate.api.nvidia.com/v1", + model="nvidia/test-model", + provider="nvidia", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent._client_kwargs["default_headers"] = { + "X-BILLING-INVOKE-ORIGIN": "HermesAgent", + } + + agent._apply_client_headers_for_base_url("http://localhost:8000/v1") + + assert "default_headers" not in agent._client_kwargs + + +@patch("run_agent.OpenAI") +def test_routed_client_preserves_openai_sdk_custom_headers(mock_openai): + mock_openai.return_value = MagicMock() + routed_client = SimpleNamespace( + api_key="test-key", + base_url="https://integrate.api.nvidia.com/v1", + _custom_headers={"X-BILLING-INVOKE-ORIGIN": "HermesAgent"}, + ) + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=( + routed_client, + "nvidia/test-model", + )): + agent = AIAgent( + provider="nvidia", + model="nvidia/test-model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + headers = agent._client_kwargs["default_headers"] + assert headers["X-BILLING-INVOKE-ORIGIN"] == "HermesAgent" + + @patch("run_agent.OpenAI") def test_gmi_base_url_picks_up_profile_user_agent(mock_openai): """GMI declares User-Agent on its ProviderProfile.default_headers. From 6fc0fa6e50a2eb6307c1e5afbeff360708b734ef Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:00:13 -0700 Subject: [PATCH 05/48] chore(release): add AUTHOR_MAP entry for kchantharuan@nvidia.com --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index c9cd9c173c00..aafa626329e4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -755,6 +755,7 @@ AUTHOR_MAP = { "zhujianxyz@gmail.com": "opriz", "tuancanhnguyen706@gmail.com": "xxxigm", "asurla@nvidia.com": "anniesurla", + "kchantharuan@nvidia.com": "nv-kasikritc", "limkuan24@gmail.com": "WideLee", "aviralarora002@gmail.com": "AviArora02-commits", "draixagent@gmail.com": "draix", From 4444d5fe4f65dcbca939a1f39ae58438205e7dad Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 15:26:08 +0100 Subject: [PATCH 06/48] fix(acp): emit native plan updates for todo --- acp_adapter/events.py | 51 ++++++++++++++++++++++++++++++++++++++++ tests/acp/test_events.py | 32 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/acp_adapter/events.py b/acp_adapter/events.py index f0442ca2e8f3..828807c3aefe 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -14,6 +14,7 @@ from collections import deque from typing import Any, Callable, Deque, Dict import acp +from acp.schema import AgentPlanUpdate, PlanEntry from .tools import ( build_tool_complete, @@ -24,6 +25,52 @@ from .tools import ( logger = logging.getLogger(__name__) +def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: + """Translate Hermes' todo tool result into ACP's native plan update. + + Zed renders ``sessionUpdate: plan`` as its first-class task/todo panel. The + Hermes agent already maintains task state through the ``todo`` tool, so the + ACP adapter should expose that state natively instead of only as a generic + tool-call transcript block. + """ + if not isinstance(result, str) or not result.strip(): + return None + + try: + data = json.loads(result) + except Exception: + return None + + if not isinstance(data, dict) or not isinstance(data.get("todos"), list): + return None + + status_map = { + "pending": "pending", + "in_progress": "in_progress", + "completed": "completed", + # ACP plans only support pending/in_progress/completed. Preserve + # cancelled tasks as terminal entries instead of dropping them and + # making the client's full-list replacement lose visible context. + "cancelled": "completed", + } + entries: list[PlanEntry] = [] + for item in data["todos"]: + if not isinstance(item, dict): + continue + content = str(item.get("content") or item.get("id") or "").strip() + if not content: + continue + raw_status = str(item.get("status") or "pending").strip() + status = status_map.get(raw_status, "pending") + if raw_status == "cancelled": + content = f"[cancelled] {content}" + entries.append(PlanEntry(content=content, priority="medium", status=status)) + + if not entries: + return None + return AgentPlanUpdate(session_update="plan", entries=entries) + + def _send_update( conn: acp.Client, session_id: str, @@ -175,6 +222,10 @@ def make_step_cb( snapshot=meta.get("snapshot"), ) _send_update(conn, session_id, loop, update) + if tool_name == "todo": + plan_update = _build_plan_update_from_todo_result(result) + if plan_update is not None: + _send_update(conn, session_id, loop, plan_update) if not queue: tool_call_ids.pop(tool_name, None) diff --git a/tests/acp/test_events.py b/tests/acp/test_events.py index 56a2687226ce..ebddf076dbdf 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import acp -from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk +from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk from acp_adapter.events import ( _send_update, @@ -296,6 +296,36 @@ class TestStepCallback: } mock_send.assert_called_once() + def test_todo_completion_emits_native_plan_update(self, mock_conn, event_loop_fixture): + from collections import deque + + tool_call_ids = {"todo": deque(["tc-todo"])} + loop = event_loop_fixture + cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {}) + todo_result = ( + '{"todos":[' + '{"id":"inspect","content":"Inspect ACP","status":"completed"},' + '{"id":"patch","content":"Patch renderer","status":"in_progress"},' + '{"id":"old","content":"Drop stale task","status":"cancelled"}' + '],"summary":{"total":3}}' + ) + + with patch("acp_adapter.events._send_update") as mock_send: + cb(1, [{"name": "todo", "result": todo_result}]) + + updates = [call.args[3] for call in mock_send.call_args_list] + plan_updates = [u for u in updates if getattr(u, "session_update", None) == "plan"] + assert len(plan_updates) == 1 + plan = plan_updates[0] + assert isinstance(plan, AgentPlanUpdate) + assert [entry.content for entry in plan.entries] == [ + "Inspect ACP", + "Patch renderer", + "[cancelled] Drop stale task", + ] + assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"] + assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"] + # --------------------------------------------------------------------------- # Message callback From bd3a5873e11f084d74be876a505a406224a6ef3e Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 16:15:04 +0100 Subject: [PATCH 07/48] fix(acp): replay native todo plans --- acp_adapter/events.py | 21 ++++++++++++---- acp_adapter/server.py | 8 ++++++- tests/acp/test_events.py | 27 +++++++++++++++++---- tests/acp/test_server.py | 52 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/acp_adapter/events.py b/acp_adapter/events.py index 828807c3aefe..00e940b9ee0d 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -25,6 +25,17 @@ from .tools import ( logger = logging.getLogger(__name__) +def _json_loads_maybe_prefix(value: str) -> Any: + """Parse a JSON object even when Hermes appended a human hint after it.""" + text = value.strip() + try: + return json.loads(text) + except Exception: + decoder = json.JSONDecoder() + data, _ = decoder.raw_decode(text) + return data + + def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: """Translate Hermes' todo tool result into ACP's native plan update. @@ -37,13 +48,17 @@ def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: return None try: - data = json.loads(result) + data = _json_loads_maybe_prefix(result) except Exception: return None if not isinstance(data, dict) or not isinstance(data.get("todos"), list): return None + todos = data["todos"] + if not todos: + return AgentPlanUpdate(session_update="plan", entries=[]) + status_map = { "pending": "pending", "in_progress": "in_progress", @@ -54,7 +69,7 @@ def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: "cancelled": "completed", } entries: list[PlanEntry] = [] - for item in data["todos"]: + for item in todos: if not isinstance(item, dict): continue content = str(item.get("content") or item.get("id") or "").strip() @@ -66,8 +81,6 @@ def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: content = f"[cancelled] {content}" entries.append(PlanEntry(content=content, priority="medium", status=status)) - if not entries: - return None return AgentPlanUpdate(session_update="plan", entries=entries) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 20c4d7cdb4fa..71fce1890d1b 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -59,6 +59,7 @@ from acp.schema import ( from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, detect_provider from acp_adapter.events import ( + _build_plan_update_from_todo_result, make_message_cb, make_step_cb, make_thinking_cb, @@ -910,15 +911,20 @@ class HermesACPAgent(acp.Agent): if not tool_call_id or not tool_name: continue result = message.get("content") + result_text = result if isinstance(result, str) else None if not await _send( build_tool_complete( tool_call_id, tool_name, - result=result if isinstance(result, str) else None, + result=result_text, function_args=function_args, ) ): return + if tool_name == "todo": + plan_update = _build_plan_update_from_todo_result(result_text) + if plan_update is not None and not await _send(plan_update): + return async def new_session( self, diff --git a/tests/acp/test_events.py b/tests/acp/test_events.py index ebddf076dbdf..ec0b32549da3 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -12,6 +12,7 @@ import acp from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk from acp_adapter.events import ( + _build_plan_update_from_todo_result, _send_update, make_message_cb, make_step_cb, @@ -296,7 +297,7 @@ class TestStepCallback: } mock_send.assert_called_once() - def test_todo_completion_emits_native_plan_update(self, mock_conn, event_loop_fixture): + def test_todo_completion_emits_native_plan_update_after_tool_completion(self, mock_conn, event_loop_fixture): from collections import deque tool_call_ids = {"todo": deque(["tc-todo"])} @@ -314,9 +315,11 @@ class TestStepCallback: cb(1, [{"name": "todo", "result": todo_result}]) updates = [call.args[3] for call in mock_send.call_args_list] - plan_updates = [u for u in updates if getattr(u, "session_update", None) == "plan"] - assert len(plan_updates) == 1 - plan = plan_updates[0] + assert [getattr(update, "session_update", None) for update in updates] == [ + "tool_call_update", + "plan", + ] + plan = updates[1] assert isinstance(plan, AgentPlanUpdate) assert [entry.content for entry in plan.entries] == [ "Inspect ACP", @@ -326,6 +329,22 @@ class TestStepCallback: assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"] assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"] + def test_todo_plan_update_parses_json_with_trailing_hint(self): + result = '{"todos":[{"id":"ship","content":"Ship ACP plan","status":"pending"}]}\n\n[Hint: persisted]' + + update = _build_plan_update_from_todo_result(result) + + assert isinstance(update, AgentPlanUpdate) + assert [entry.content for entry in update.entries] == ["Ship ACP plan"] + assert [entry.status for entry in update.entries] == ["pending"] + + def test_todo_plan_update_with_empty_todos_clears_plan(self): + update = _build_plan_update_from_todo_result('{"todos":[],"summary":{"total":0}}') + + assert isinstance(update, AgentPlanUpdate) + assert update.session_update == "plan" + assert update.entries == [] + # --------------------------------------------------------------------------- # Message callback diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 6e2039d2b24c..511d6e00934b 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -12,6 +12,7 @@ from acp.agent.router import build_agent_router from acp.schema import ( AgentCapabilities, AgentMessageChunk, + AgentPlanUpdate, AuthenticateResponse, AvailableCommandsUpdate, Implementation, @@ -391,6 +392,57 @@ class TestSessionOps: assert "Search results" in tool_updates[1].content[0].content.text assert "cli.py:42" in tool_updates[1].content[0].content.text + @pytest.mark.asyncio + async def test_load_session_replays_native_plan_for_persisted_todo_tool(self, agent): + """Persisted todo tool results should rebuild Zed's native plan panel.""" + mock_conn = MagicMock(spec=acp.Client) + mock_conn.session_update = AsyncMock() + agent._conn = mock_conn + + new_resp = await agent.new_session(cwd="/tmp") + state = agent.session_manager.get_session(new_resp.session_id) + state.history = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_todo_1", + "type": "function", + "function": { + "name": "todo", + "arguments": '{"todos":[{"id":"ship","content":"Ship it","status":"in_progress"}]}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_todo_1", + "content": '{"todos":[{"id":"ship","content":"Ship it","status":"in_progress"}]}', + }, + ] + + mock_conn.session_update.reset_mock() + resp = await agent.load_session(cwd="/tmp", session_id=new_resp.session_id) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert isinstance(resp, LoadSessionResponse) + relevant_updates = [ + update for update in (call.kwargs["update"] for call in mock_conn.session_update.await_args_list) + if getattr(update, "session_update", None) in {"tool_call", "tool_call_update", "plan"} + ] + assert [getattr(update, "session_update", None) for update in relevant_updates] == [ + "tool_call", + "tool_call_update", + "plan", + ] + plan = relevant_updates[2] + assert isinstance(plan, AgentPlanUpdate) + assert [entry.content for entry in plan.entries] == ["Ship it"] + assert [entry.status for entry in plan.entries] == ["in_progress"] + @pytest.mark.asyncio async def test_resume_session_replays_persisted_history_to_client(self, agent): mock_conn = MagicMock(spec=acp.Client) From 622c27e55c58a0d11739a21ae29dd6d072230cf0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:07:56 -0700 Subject: [PATCH 08/48] fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'. --- scripts/install.ps1 | 77 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2cf81969beb5..5ed7aa755fd3 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -145,19 +145,39 @@ function Test-Python { # Python not found — use uv to install it (no admin needed!) Write-Info "Python $PythonVersion not found, installing via uv..." try { + # Temporarily relax ErrorActionPreference: uv writes download progress + # ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to + # stderr. With $ErrorActionPreference = "Stop" (set at the top of this + # script) PowerShell wraps stderr lines from native commands as + # ErrorRecord objects when captured via 2>&1, then throws a terminating + # exception on the first one — even though uv exits 0 and Python was + # installed successfully. Verify success via `uv python find` + # afterwards, which is the reliable signal regardless of exit-code + # semantics or stderr noise. This fix was previously landed as + # commit ec1714e71 and then lost in a release squash; reapplied here. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" $uvOutput = & $UvCmd python install $PythonVersion 2>&1 - if ($LASTEXITCODE -eq 0) { - $pythonPath = & $UvCmd python find $PythonVersion 2>$null - if ($pythonPath) { - $ver = & $pythonPath --version 2>$null - Write-Success "Python installed: $ver" - return $true - } - } else { + $uvExitCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + + # Check if Python is now available (more reliable than exit code + # since uv may return non-zero due to "already installed" etc.) + $pythonPath = & $UvCmd python find $PythonVersion 2>$null + if ($pythonPath) { + $ver = & $pythonPath --version 2>$null + Write-Success "Python installed: $ver" + return $true + } + + # uv ran but Python still not findable — show what happened + if ($uvExitCode -ne 0) { Write-Warn "uv python install output:" Write-Host $uvOutput -ForegroundColor DarkGray } } catch { + # Restore EAP in case the try block threw before the assignment + if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "uv python install error: $_" } @@ -175,15 +195,42 @@ function Test-Python { } catch { } } - # Fallback: try system python - if (Get-Command python -ErrorAction SilentlyContinue) { - $sysVer = python --version 2>$null - if ($sysVer -match "3\.(1[0-9]|[1-9][0-9])") { - Write-Success "Using system Python: $sysVer" - return $true + # Fallback: try system python — but skip the Microsoft Store stub. + # On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte + # reparse-point stub that prints "Python was not found; run without + # arguments to install from the Microsoft Store..." to stdout and exits + # non-zero. Get-Command finds it; invoking it produces a confusing error + # that the user sees as our installer crashing. + $pythonCmd = Get-Command python -ErrorAction SilentlyContinue + if ($pythonCmd) { + $isStoreStub = $false + try { + $pythonSource = $pythonCmd.Source + if ($pythonSource -and $pythonSource -like "*\WindowsApps\*") { + $isStoreStub = $true + } else { + # Even outside WindowsApps, a 0-byte file is the stub + $item = Get-Item $pythonSource -ErrorAction SilentlyContinue + if ($item -and $item.Length -eq 0) { $isStoreStub = $true } + } + } catch { } + + if (-not $isStoreStub) { + try { + $prevEAP2 = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $sysVer = & python --version 2>&1 + $ErrorActionPreference = $prevEAP2 + if ($sysVer -match "Python 3\.(1[0-9]|[1-9][0-9])") { + Write-Success "Using system Python: $sysVer" + return $true + } + } catch { + if ($prevEAP2) { $ErrorActionPreference = $prevEAP2 } + } } } - + Write-Err "Failed to install Python $PythonVersion" Write-Info "Install Python 3.11 manually, then re-run this script:" Write-Info " https://www.python.org/downloads/" From 3b9368a0c47176b449ea0254cdac31ec4d5ae925 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:27:50 -0700 Subject: [PATCH 09/48] fix(auth): point SSH OAuth users at the tunnel they actually need (#26592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loopback-redirect OAuth flows (xAI Grok, Spotify) silently fail when Hermes runs on a remote host: the auth server redirects to 127.0.0.1: on the user's laptop, not on the remote box. The --no-browser flag only suppresses webbrowser.open() — it doesn't change the bind address. Symptom xAI surfaces is 'Could not establish connection. We couldn't reach your app.', followed by a 'xAI authorization timed out waiting for the local callback' on the CLI side. Changes - hermes_cli/auth.py: new _print_loopback_ssh_hint() helper, called from _xai_oauth_loopback_login() and _spotify_login() right after they print the redirect URI. Silent off SSH; on SSH prints the exact 'ssh -N -L :127.0.0.1:' command using the actually-bound port (not the hardcoded constant — the listener auto-bumps when the preferred port is busy), a provider-specific docs URL, and a link to the new shared guide. - website/docs/guides/oauth-over-ssh.md (new): single source of truth for the tunnel pattern — TL;DR command, jump-box / ProxyJump variant, mosh+tmux+ControlMaster gotchas, troubleshooting. - website/docs/guides/xai-grok-oauth.md: fix the two sections that claimed --no-browser alone was enough; link to the shared guide. - website/docs/user-guide/features/spotify.md: expand the existing one-liner; link to the shared guide. - website/sidebars.ts: register the new page. - tests/hermes_cli/test_auth_loopback_ssh_hint.py: 7 unit tests covering SSH-vs-not, loopback-vs-not, malformed URIs, port echo, with and without provider docs URL. --- hermes_cli/auth.py | 46 ++++++ .../hermes_cli/test_auth_loopback_ssh_hint.py | 95 ++++++++++++ website/docs/guides/oauth-over-ssh.md | 137 ++++++++++++++++++ website/docs/guides/xai-grok-oauth.md | 23 ++- website/docs/user-guide/features/spotify.md | 8 +- website/sidebars.ts | 1 + 6 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 tests/hermes_cli/test_auth_loopback_ssh_hint.py create mode 100644 website/docs/guides/oauth-over-ssh.md diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c6dce7093846..6cabb61570d7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -107,6 +107,9 @@ DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback" SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify" SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard" SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 + +XAI_OAUTH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth" +OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" DEFAULT_SPOTIFY_SCOPE = " ".join(( "user-modify-playback-state", "user-read-playback-state", @@ -2528,6 +2531,8 @@ def login_spotify_command(args) -> None: print(f"Full setup guide: {SPOTIFY_DOCS_URL}") print() + _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) + if open_browser and not _is_remote_session(): try: opened = webbrowser.open(authorize_url) @@ -2584,6 +2589,45 @@ def _is_remote_session() -> bool: return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) +def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None: + """Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a + remote host. The auth server (xAI, Spotify, ...) will redirect the user's + browser to ``127.0.0.1:/callback``. If the browser is on a different + machine than the loopback listener (the usual SSH case), the redirect can't + reach the listener without a local port forward. + + The hint is best-effort: silent if we don't think we're remote, or if we + can't parse a host/port out of the redirect URI. + + Pass ``docs_url`` for a provider-specific guide (e.g. the xAI Grok OAuth + page); the generic OAuth-over-SSH guide is always shown after it. + """ + if not _is_remote_session(): + return + try: + parsed = urlparse(redirect_uri) + except Exception: + return + host = parsed.hostname or "" + port = parsed.port + if host not in ("127.0.0.1", "::1", "localhost") or not port: + return + print() + print("Remote session detected. Your browser will redirect to") + print(f" {redirect_uri}") + print("which the loopback listener on THIS machine is waiting on. If your") + print("browser is on a different machine, forward the port first from your") + print("local machine in a separate terminal:") + print() + print(f" ssh -N -L {port}:127.0.0.1:{port} @") + print() + print("Then open the authorize URL above in your local browser.") + if docs_url: + print(f"Provider docs: {docs_url}") + print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") + print() + + # ============================================================================= # OpenAI Codex auth — tokens stored in ~/.hermes/auth.json (not ~/.codex/) # @@ -5297,6 +5341,8 @@ def _xai_oauth_loopback_login( print() print(f"Waiting for callback on {redirect_uri}") + _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) + if open_browser and not _is_remote_session(): try: opened = webbrowser.open(authorize_url) diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py new file mode 100644 index 000000000000..fb88a6bf4ce4 --- /dev/null +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -0,0 +1,95 @@ +"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py. + +The helper exists to warn users that loopback OAuth flows (xAI Grok OAuth, +Spotify) don't work over SSH unless they set up an `ssh -L` port forward +between their laptop's browser and the remote host's loopback listener. +""" + +from __future__ import annotations + +import io +import contextlib + +import pytest + +from hermes_cli import auth as auth_mod + + +def _cap(fn): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + fn() + return buf.getvalue() + + +def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_prints_tunnel_command_on_ssh(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + # Must include the exact ssh -L command with the port from the redirect URI + assert "ssh -N -L 56121:127.0.0.1:56121" in out + # Must include the provider-specific docs URL + assert auth_mod.XAI_OAUTH_DOCS_URL in out + # Must always include the cross-provider SSH guide + assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out + + +def test_loopback_ssh_hint_uses_actual_bound_port(monkeypatch): + """When the preferred port is busy, _xai_start_callback_server falls back to + an OS-assigned port. The hint must echo whichever port actually got bound, + not the hardcoded constant.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:51234/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert "ssh -N -L 51234:127.0.0.1:51234" in out + assert "56121" not in out + + +def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch): + """Defense in depth: if a future caller passes a non-loopback redirect URI + by mistake, we don't tell the user to forward an external port.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "https://example.com/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_silent_for_malformed_uri(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "not-a-uri", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:43827/spotify/callback" + )) + assert "ssh -N -L 43827:127.0.0.1:43827" in out + # Generic SSH guide is always present even without a provider-specific URL + assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out + # Should not falsely show "Provider docs:" when no docs_url was passed + assert "Provider docs:" not in out + + +def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch): + """The constant is 127.0.0.1, but parsing tolerates `localhost` too in case + a future caller normalizes the URI differently.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://localhost:56121/callback" + )) + assert "ssh -N -L 56121:127.0.0.1:56121" in out diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md new file mode 100644 index 000000000000..46a818a7934c --- /dev/null +++ b/website/docs/guides/oauth-over-ssh.md @@ -0,0 +1,137 @@ +--- +sidebar_position: 17 +title: "OAuth over SSH / Remote Hosts" +description: "How to complete browser-based OAuth (xAI, Spotify) when Hermes runs on a remote machine, container, or behind a jump box" +--- + +# OAuth over SSH / Remote Hosts + +Some Hermes providers — currently **xAI Grok OAuth** and **Spotify** — use a *loopback redirect* OAuth flow. The auth server (xAI, Spotify) redirects your browser to `http://127.0.0.1:/callback` so a tiny HTTP listener started by the `hermes auth ...` command can grab the authorization code. + +This works perfectly when Hermes and your browser are on the same machine. It breaks the moment they aren't: your laptop's browser tries to reach `127.0.0.1` on **your laptop**, but the listener is bound to `127.0.0.1` on **the remote server**. + +The fix is a one-line SSH local-forward. + +## TL;DR + +```bash +# On your local machine (laptop), in a separate terminal: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# In your existing SSH session on the remote machine: +hermes auth add xai-oauth --no-browser +# → Hermes prints an authorize URL. Open it in a browser on your laptop. +# → Your browser redirects to 127.0.0.1:56121/callback, the tunnel forwards +# the request to the remote listener, login completes. +``` + +Port `56121` is what xAI OAuth uses. For Spotify, replace it with `43827`. Hermes prints the exact port it bound to on the `Waiting for callback on ...` line — copy it from there. + +## Which Providers Need This + +| Provider | Loopback port | Tunnel needed? | +|----------|---------------|----------------| +| `xai-oauth` (Grok SuperGrok) | `56121` | Yes, when Hermes is remote | +| Spotify | `43827` | Yes, when Hermes is remote | +| `anthropic` (Claude Pro/Max) | n/a | No — paste-the-code flow | +| `openai-codex` (ChatGPT Plus/Pro) | n/a | No — device code flow | +| `minimax`, `nous-portal` | n/a | No — device code flow | + +If your provider isn't in the table, you don't need a tunnel. + +## Why the listener can't just bind 0.0.0.0 + +xAI and Spotify both validate the `redirect_uri` parameter against an allowlist. Both require the loopback form (`http://127.0.0.1:/callback`). Binding the listener to `0.0.0.0` or a different port would cause the auth server to reject the request as a redirect_uri mismatch. The SSH tunnel keeps the loopback URI intact end-to-end. + +## Step-by-step: single SSH hop + +### 1. Start the tunnel from your local machine + +```bash +# xAI Grok OAuth (port 56121) +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Or for Spotify (port 43827) +ssh -N -L 43827:127.0.0.1:43827 user@remote-host +``` + +`-N` means "don't open a remote shell, just hold the tunnel open." Keep this terminal running for the duration of the login. + +### 2. In a separate SSH session, run the auth command + +```bash +ssh user@remote-host +hermes auth add xai-oauth --no-browser +# or for Spotify: +# hermes auth add spotify --no-browser +``` + +Hermes detects the SSH session, skips the browser auto-open, and prints an authorize URL plus a `Waiting for callback on http://127.0.0.1:/callback` line. + +### 3. Open the URL in your local browser + +Copy the authorize URL from the remote terminal and paste it into the browser on your laptop. Approve the consent screen. The auth server redirects to `http://127.0.0.1:/callback`. Your browser hits the tunnel, the request is forwarded to the remote listener, and Hermes prints `Login successful!`. + +You can tear down the tunnel (Ctrl+C in the first terminal) once you see the success line. + +## Step-by-step: through a jump box + +If you reach Hermes through a bastion / jump host, use SSH's built-in `-J` (ProxyJump): + +```bash +ssh -N -L 56121:127.0.0.1:56121 -J jump-user@jump-host user@final-host +``` + +This chains a SSH connection through the jump host without putting the loopback port on the jump box itself. The local `127.0.0.1:56121` on your laptop tunnels straight through to `127.0.0.1:56121` on the final remote host. + +For older OpenSSH that doesn't support `-J`, the long form is: + +```bash +ssh -N \ + -o "ProxyCommand=ssh -W %h:%p jump-user@jump-host" \ + -L 56121:127.0.0.1:56121 \ + user@final-host +``` + +## Mosh, tmux, ssh ControlMaster + +The tunnel is a property of the underlying SSH connection. If you're running Hermes inside `tmux` over a mosh session, the mosh roaming doesn't carry the `-L` forwarding. Open a *separate* plain SSH session **only** for the `-L` tunnel — that's the connection that has to stay alive during the auth flow. Your interactive mosh/tmux session can keep running Hermes normally. + +If you use `ssh -o ControlMaster=auto`, port forwards on a multiplexed connection share the master's lifetime. Restart the master if the tunnel doesn't come up: + +```bash +ssh -O exit user@remote-host +ssh -N -L 56121:127.0.0.1:56121 user@remote-host +``` + +## Troubleshooting + +### `bind [127.0.0.1]:56121: Address already in use` + +Something on your laptop is already using that port. Either the previous tunnel didn't shut down cleanly, or a local Hermes is also listening on it. Find and kill the offender: + +```bash +# macOS / Linux +lsof -iTCP:56121 -sTCP:LISTEN +kill +``` + +Then retry the `ssh -L` command. + +### "Could not establish connection. We couldn't reach your app." (xAI) + +xAI's authorize page shows this when its redirect to `127.0.0.1:/callback` doesn't reach a listener. Either the tunnel isn't running, the port is wrong, or you're using the port Hermes printed in a previous run (the port can be auto-bumped if the preferred one is busy — always read the latest `Waiting for callback on ...` line). + +### `xAI authorization timed out waiting for the local callback` + +Same root cause as above — the redirect never made it back. Check the tunnel is still alive (`ssh -N` doesn't show output, so look at the terminal you started it from), restart it if needed, and re-run `hermes auth add xai-oauth --no-browser`. + +### Tokens land in the wrong `~/.hermes` + +The tokens are written under the Linux user that ran `hermes auth add ...`. If your gateway / systemd service runs as a different user (e.g. `root` or a dedicated `hermes` user), authenticate as **that** user so the tokens land in their `~/.hermes/auth.json`. `sudo -u hermes -i` or equivalent. + +## See Also + +- [xAI Grok OAuth](./xai-grok-oauth.md) +- [Spotify (`Running over SSH`)](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment) +- [SSH `-J` / ProxyJump (man page)](https://man.openbsd.org/ssh#J) diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 5afccb6d8819..95167a2430cd 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -59,14 +59,23 @@ hermes auth add xai-oauth ### Remote / headless sessions -On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser. Open the URL on any device with a browser, complete the consent flow, and Hermes finishes the loopback exchange when the redirect comes back. +On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser. -If you need to force this behaviour explicitly: +**Important:** the loopback listener still runs on the remote machine at `127.0.0.1:56121`. The xAI redirect needs to reach *that* listener, so opening the URL on your laptop will fail (`Could not establish connection. We couldn't reach your app.`) unless you forward the port: ```bash +# In a separate terminal on your local machine: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Then in your SSH session on the remote machine: hermes auth add xai-oauth --no-browser +# Open the printed authorize URL in your local browser. ``` +Through a jump box / bastion: add `-J jump-user@jump-host`. + +See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) for the full step-by-step, including ProxyJump chains, mosh/tmux, and ControlMaster gotchas. + ## How the Login Works 1. Hermes opens your browser to `accounts.x.ai`. @@ -182,14 +191,18 @@ Hermes detected that the `state` value returned by the authorization server does ### Logging in from a remote server -On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. Open the URL on any device with a browser and complete the consent there — the loopback callback comes back to your remote host. - -You can also force this behaviour: +On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. The loopback callback listener still binds `127.0.0.1:56121` on the remote host — your laptop's browser can't reach it without an SSH local-forward: ```bash +# Local machine, separate terminal: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Remote machine: hermes auth add xai-oauth --no-browser ``` +Full walkthrough (jump boxes, mosh/tmux, port conflicts): [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md). + ### "No xAI credentials found" error at runtime The auth store has no `xai-oauth` entry and no `XAI_API_KEY` is set. You haven't logged in yet, or the credential file was deleted. diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md index bf9d652b3181..5e57688e48f0 100644 --- a/website/docs/user-guide/features/spotify.md +++ b/website/docs/user-guide/features/spotify.md @@ -68,7 +68,13 @@ Agree to the terms and click **Save**. On the next page click **Settings** → c ### Running over SSH / in a headless environment -If `SSH_CLIENT` or `SSH_TTY` is set, Hermes skips the automatic browser open during both the wizard and the OAuth step. Copy the dashboard URL and the authorization URL Hermes prints, open them in a browser on your local machine, and proceed normally — the local HTTP listener still runs on the remote host on port 43827. If you need to reach it through an SSH tunnel, forward that port: `ssh -L 43827:127.0.0.1:43827 remote`. +If `SSH_CLIENT` or `SSH_TTY` is set, Hermes skips the automatic browser open during both the wizard and the OAuth step. Copy the dashboard URL and the authorization URL Hermes prints, open them in a browser on your local machine, and proceed normally — the local HTTP listener still runs on the remote host on port `43827`. Your laptop's browser can't reach the remote loopback without an SSH local-forward: + +```bash +ssh -N -L 43827:127.0.0.1:43827 user@remote-host +``` + +For jump-box / bastion setups and other gotchas (mosh, tmux, port conflicts), see [OAuth over SSH / Remote Hosts](../../guides/oauth-over-ssh.md). ## Verify diff --git a/website/sidebars.ts b/website/sidebars.ts index a0fb24b8c507..f0a0658c3bfd 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -192,6 +192,7 @@ const sidebars: SidebarsConfig = { 'guides/aws-bedrock', 'guides/azure-foundry', 'guides/xai-grok-oauth', + 'guides/oauth-over-ssh', 'guides/microsoft-graph-app-registration', 'guides/operate-teams-meeting-pipeline', ], From 518f39557b6753a5dc766a05dd14dd5cf2b9edeb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:32:14 -0700 Subject: [PATCH 10/48] fix(gateway): keep running when platforms fail; add per-platform circuit breaker + /platform (#26600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop the gateway from exiting (or systemd-restart-looping) when a single messaging adapter fails at startup or runtime. A misconfigured WhatsApp (npm install timeout, unpaired bridge, missing creds.json) used to take the entire gateway down, killing cron jobs and any other connected platforms with it. Changes: • Startup (gateway/run.py): when connected_count==0 but the only errors are retryable, log a degraded-state warning and keep the gateway alive instead of returning False. Reconnect watcher then recovers platforms as their underlying problem clears. • Runtime (gateway/run.py _handle_adapter_fatal_error): when the last adapter goes down with a retryable error and is queued for reconnection, stay alive instead of exit-with-failure. Previously this triggered systemd Restart=on-failure, which created infinite restart loops on persistent retryable failures (proxy outage, repeated bridge crashes). • Reconnect watcher (gateway/run.py _platform_reconnect_watcher): replace the 20-attempt hard drop with a circuit-breaker pause. After _PAUSE_AFTER_FAILURES (10) consecutive retryable failures, the platform stays in _failed_platforms with paused=True so the watcher skips it but the operator can still see and resume it. Non-retryable errors still drop out of the queue immediately. Resolves #17063 (gateway giving up on Telegram after 20 attempts). • WhatsApp preflight (gateway/platforms/whatsapp.py): refuse to start the Node bridge when creds.json is missing. Sets a non-retryable whatsapp_not_paired fatal error so the watcher drops it cleanly with a single 'run hermes whatsapp' log line instead of paying the 30s bridge bootstrap timeout on every gateway start. • WhatsApp setup ordering (hermes_cli/main.py cmd_whatsapp): only set WHATSAPP_ENABLED=true once pairing actually succeeds. Previously the wizard wrote the env var at step 2 (before npm install and QR pairing), so any Ctrl+C left .env claiming WhatsApp was ready when the bridge had no creds.json. Also propagate the env var when the user keeps an existing pairing on a re-run. • /platform slash command (hermes_cli/commands.py + gateway/run.py): new gateway-only command for manual circuit-breaker control. /platform list — show connected + failed/paused platforms /platform pause — silence a known-broken platform /platform resume — re-queue a paused platform Tests: • New: pause/resume helpers, /platform list|pause|resume command, WhatsApp creds.json preflight, WhatsApp setup ordering. • Updated: stale assertions that codified the old 'exit and let systemd restart' behavior in test_runner_fatal_adapter.py, test_runner_startup_failures.py, and test_platform_reconnect.py (the 20-attempt give-up test became a circuit-breaker pause test). 5488 tests pass in tests/gateway/. --- gateway/platforms/whatsapp.py | 34 ++- gateway/run.py | 255 +++++++++++++++--- hermes_cli/commands.py | 2 + hermes_cli/main.py | 27 +- tests/gateway/test_platform_reconnect.py | 230 +++++++++++++++- tests/gateway/test_runner_fatal_adapter.py | 12 +- tests/gateway/test_runner_startup_failures.py | 17 +- tests/gateway/test_whatsapp_connect.py | 90 +++++++ .../test_whatsapp_setup_ordering.py | 140 ++++++++++ 9 files changed, 745 insertions(+), 62 deletions(-) create mode 100644 tests/hermes_cli/test_whatsapp_setup_ordering.py diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 5239df3b5aef..0ca3d41fabbe 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -493,13 +493,45 @@ class WhatsAppAdapter(BasePlatformAdapter): """ if not check_whatsapp_requirements(): logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name) + self._set_fatal_error( + "whatsapp_node_missing", + "Node.js is not installed — install Node.js and re-run `hermes gateway`.", + retryable=False, + ) return False bridge_path = Path(self._bridge_script) if not bridge_path.exists(): logger.warning("[%s] Bridge script not found: %s", self.name, bridge_path) + self._set_fatal_error( + "whatsapp_bridge_missing", + f"WhatsApp bridge script missing at {bridge_path}.", + retryable=False, + ) return False - + + # Pre-flight: skip the 30s bridge bootstrap entirely if the user + # never finished pairing. Without creds.json the bridge prints + # QR codes to its log file and never reaches status:connected, + # so every gateway restart paid the 30s timeout + queued WhatsApp + # for indefinite retries. Mark non-retryable so the user gets a + # clear "run hermes whatsapp" message instead of the watcher + # silently hammering an unconfigured platform. + creds_path = self._session_path / "creds.json" + if not creds_path.exists(): + logger.warning( + "[%s] WhatsApp is enabled but not paired (no creds.json at %s). " + "Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from " + "your .env to disable.", + self.name, creds_path, + ) + self._set_fatal_error( + "whatsapp_not_paired", + "WhatsApp enabled but not paired — run `hermes whatsapp` to pair.", + retryable=False, + ) + return False + logger.info("[%s] Bridge found at %s", self.name, bridge_path) # Acquire scoped lock to prevent duplicate sessions diff --git a/gateway/run.py b/gateway/run.py index f41357673f73..f9a282a413fb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1990,21 +1990,21 @@ class GatewayRunner: await self.stop() elif not self.adapters and self._failed_platforms: # All platforms are down and queued for background reconnection. - # If the error is retryable, exit with failure so systemd Restart=on-failure - # can restart the process. Otherwise stay alive and keep retrying in background. - if adapter.fatal_error_retryable: - self._exit_reason = adapter.fatal_error_message or "All messaging platforms failed with retryable errors" - self._exit_with_failure = True - logger.error( - "All messaging platforms failed with retryable errors. " - "Shutting down gateway for service restart (systemd will retry)." - ) - await self.stop() - else: - logger.warning( - "No connected messaging platforms remain, but %d platform(s) queued for reconnection", - len(self._failed_platforms), - ) + # Keep the gateway alive so: + # • cron jobs still run + # • the reconnect watcher can recover platforms when the + # underlying problem clears (proxy comes back, user runs + # `hermes whatsapp`, etc.) + # We used to exit-with-failure here to trigger systemd restart, + # but that converted a transient outage into a restart loop and + # killed in-process state every time. The reconnect watcher + # already handles long-running recovery — let it do its job. + logger.warning( + "No connected messaging platforms remain, but %d platform(s) " + "queued for reconnection — gateway staying alive, watcher will " + "retry in background.", + len(self._failed_platforms), + ) def _request_clean_exit(self, reason: str) -> None: self._exit_cleanly = True @@ -2180,6 +2180,73 @@ class GatewayRunner: except Exception: pass + # ------------------------------------------------------------------ + # Per-platform circuit breaker (pause/resume) — used by the reconnect + # watcher when a retryable failure recurs past a threshold, and by the + # /platform pause|resume slash command for manual control. + # ------------------------------------------------------------------ + def _pause_failed_platform(self, platform, *, reason: str = "") -> None: + """Mark a queued platform as paused — keep it in ``_failed_platforms`` + but stop the reconnect watcher from hammering it. + + Used by the circuit breaker after ``_PAUSE_AFTER_FAILURES`` consecutive + retryable failures, and by ``/platform pause `` for manual + intervention. Paused platforms are surfaced in ``/platform list`` + and resumed with ``/platform resume ``. + """ + info = getattr(self, "_failed_platforms", {}).get(platform) + if info is None: + return + if info.get("paused"): + return + info["paused"] = True + info["pause_reason"] = reason or "auto-paused after repeated failures" + # Push next_retry far enough out that even if "paused" is missed + # by a stale code path, the watcher won't fire on it. + info["next_retry"] = float("inf") + try: + self._update_platform_runtime_status( + platform.value, + platform_state="paused", + error_code=None, + error_message=info["pause_reason"], + ) + except Exception: + pass + logger.warning( + "%s paused after %d consecutive failures (%s) — " + "fix the underlying issue then run `/platform resume %s` " + "to retry, or `hermes gateway restart` to restart the gateway.", + platform.value, info.get("attempts", 0), + info["pause_reason"], platform.value, + ) + + def _resume_paused_platform(self, platform) -> bool: + """Unpause a platform — reset its attempt counter and schedule an + immediate retry. Returns True if the platform was paused and is + now queued; False if it wasn't paused (or wasn't in the queue). + """ + info = getattr(self, "_failed_platforms", {}).get(platform) + if info is None: + return False + if not info.get("paused"): + return False + info["paused"] = False + info.pop("pause_reason", None) + info["attempts"] = 0 + info["next_retry"] = time.monotonic() # retry on next watcher tick + try: + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=None, + error_message=None, + ) + except Exception: + pass + logger.info("%s resumed — retrying on next watcher tick", platform.value) + return True + @staticmethod def _load_prefill_messages() -> List[Dict[str, Any]]: """Load ephemeral prefill messages from config or env var. @@ -3613,16 +3680,32 @@ class GatewayRunner: return True if enabled_platform_count > 0: if startup_retryable_errors: - # At least one platform attempted a connection and failed — - # this is a real startup error that should block the gateway. + # All enabled platforms hit retryable failures (network + # blip, bridge not paired, npm install timeout, etc.). + # Keep the gateway alive so: + # • cron jobs still run + # • the reconnect watcher gets a chance to recover the + # failing platforms once the underlying problem is + # fixed (e.g. user runs `hermes whatsapp`, fixes + # proxy, etc.) + # Exiting here used to convert a single misconfigured + # platform into an infinite systemd restart loop. reason = "; ".join(startup_retryable_errors) - logger.error("Gateway failed to connect any configured messaging platform: %s", reason) + logger.warning( + "Gateway started with no connected platforms — " + "%d platform(s) queued for retry: %s", + len(self._failed_platforms), reason, + ) try: from gateway.status import write_runtime_status - write_runtime_status(gateway_state="startup_failed", exit_reason=reason) + write_runtime_status( + gateway_state="degraded", + exit_reason=None, + ) except Exception: pass - return False + # Fall through to the normal "running" state — reconnect + # watcher takes it from here. # All enabled platforms had no adapter (missing library or credentials). # In fleet deployments the same config.yaml is shared across nodes that # may only have credentials for a subset of platforms. Rather than @@ -4737,11 +4820,15 @@ class GatewayRunner: """Background task that periodically retries connecting failed platforms. Uses exponential backoff: 30s → 60s → 120s → 240s → 300s (cap). - Stops retrying a platform after 20 failed attempts or if the error - is non-retryable (e.g. bad auth token). + Retryable failures keep retrying at the backoff cap indefinitely + — but if a platform fails ``_PAUSE_AFTER_FAILURES`` times in a row + without ever succeeding, it is *paused*: kept in the retry queue + but no longer hammered. The user surfaces it with ``/platform list`` + and resumes it with ``/platform resume ``. Non-retryable + failures (bad auth, etc.) still drop out of the queue immediately. """ - _MAX_ATTEMPTS = 20 _BACKOFF_CAP = 300 # 5 minutes max between retries + _PAUSE_AFTER_FAILURES = 10 # circuit-breaker threshold await asyncio.sleep(10) # initial delay — let startup finish while self._running: @@ -4758,22 +4845,18 @@ class GatewayRunner: if not self._running: return info = self._failed_platforms[platform] + # Skip paused platforms entirely — they need explicit + # /platform resume to come back. + if info.get("paused"): + continue if now < info["next_retry"]: continue # not time yet - if info["attempts"] >= _MAX_ATTEMPTS: - logger.warning( - "Giving up reconnecting %s after %d attempts", - platform.value, info["attempts"], - ) - del self._failed_platforms[platform] - continue - platform_config = info["config"] attempt = info["attempts"] + 1 logger.info( - "Reconnecting %s (attempt %d/%d)...", - platform.value, attempt, _MAX_ATTEMPTS, + "Reconnecting %s (attempt %d)...", + platform.value, attempt, ) try: @@ -4838,6 +4921,14 @@ class GatewayRunner: "Reconnect %s failed, next retry in %ds", platform.value, backoff, ) + if attempt >= _PAUSE_AFTER_FAILURES: + self._pause_failed_platform( + platform, + reason=( + adapter.fatal_error_message + or "failed to reconnect" + ), + ) except Exception as e: self._update_platform_runtime_status( platform.value, @@ -4852,6 +4943,8 @@ class GatewayRunner: "Reconnect %s error: %s, next retry in %ds", platform.value, e, backoff, ) + if attempt >= _PAUSE_AFTER_FAILURES: + self._pause_failed_platform(platform, reason=str(e)) # Check every 10 seconds for platforms that need reconnection for _ in range(10): @@ -6451,6 +6544,9 @@ class GatewayRunner: if canonical == "agents": return await self._handle_agents_command(event) + if canonical == "platform": + return await self._handle_platform_command(event) + if canonical == "restart": return await self._handle_restart_command(event) @@ -8698,6 +8794,99 @@ class GatewayRunner: else: return t("gateway.stop.no_active") + async def _handle_platform_command(self, event: MessageEvent) -> str: + """Handle ``/platform list|pause|resume [name]`` — surface and + manually control failed/paused gateway adapters. + + Examples: + ``/platform list`` — show connected + failed/paused platforms + ``/platform pause whatsapp`` — stop the reconnect watcher hammering whatsapp + ``/platform resume whatsapp`` — re-queue a paused platform for retry + """ + text = (getattr(event, "content", "") or "").strip() + # Strip the leading "/platform" (or "/PLATFORM") token if present + parts = text.split(maxsplit=2) + if parts and parts[0].lower().lstrip("/").startswith("platform"): + parts = parts[1:] + action = (parts[0] if parts else "list").lower() + target = parts[1].lower() if len(parts) > 1 else "" + + # Resolve platform name (case-insensitive, value match) + def _resolve_platform(name: str): + if not name: + return None + for p in Platform.__members__.values(): + if p.value.lower() == name: + return p + return None + + if action == "list": + lines = ["**Gateway platforms**"] + connected = sorted(p.value for p in self.adapters.keys()) + if connected: + lines.append("Connected: " + ", ".join(connected)) + else: + lines.append("Connected: (none)") + failed = getattr(self, "_failed_platforms", {}) or {} + if failed: + for p, info in failed.items(): + if info.get("paused"): + reason = info.get("pause_reason") or "paused" + lines.append( + f" · {p.value} — PAUSED ({reason}). " + f"Resume with `/platform resume {p.value}`." + ) + else: + attempts = info.get("attempts", 0) + lines.append( + f" · {p.value} — retrying (attempt {attempts})" + ) + else: + lines.append("Failed/paused: (none)") + return "\n".join(lines) + + if action in ("pause", "resume"): + if not target: + return f"Usage: /platform {action} " + platform = _resolve_platform(target) + if platform is None: + return f"Unknown platform: {target}" + failed = getattr(self, "_failed_platforms", {}) or {} + if action == "pause": + if platform not in failed: + return ( + f"{platform.value} is not in the retry queue " + f"(it's either connected or not enabled)." + ) + if failed[platform].get("paused"): + return f"{platform.value} is already paused." + self._pause_failed_platform(platform, reason="paused via /platform pause") + return ( + f"✓ {platform.value} paused. " + f"Resume with `/platform resume {platform.value}` or " + f"`hermes gateway restart` to reset." + ) + # action == "resume" + if platform not in failed: + return ( + f"{platform.value} is not in the retry queue — " + f"nothing to resume." + ) + if not failed[platform].get("paused"): + return ( + f"{platform.value} is already retrying — " + f"no resume needed." + ) + self._resume_paused_platform(platform) + return f"✓ {platform.value} resumed — retrying on next watcher tick." + + return ( + "Usage: /platform [name]\n" + " /platform list — show platform status\n" + " /platform pause — stop retrying a failing platform\n" + " /platform resume — re-queue a paused platform" + ) + async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /restart command - drain active work, then restart the gateway.""" # Defensive idempotency check: if the previous gateway process diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b3556d3932df..83d86c4a3a93 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -198,6 +198,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", cli_only=True, aliases=("gateway",)), + CommandDef("platform", "Pause, resume, or list a failing gateway platform", "Info", + gateway_only=True, args_hint=" [name]"), CommandDef("copy", "Copy the last assistant response to clipboard", "Info", cli_only=True, args_hint="[number]"), CommandDef("paste", "Attach clipboard image from your clipboard", "Info", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c2c8a6880d2f..7eedc3fd3222 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1522,14 +1522,18 @@ def cmd_whatsapp(args): ) print(f"\n✓ Mode: {mode_label}") - # ── Step 2: Enable WhatsApp ────────────────────────────────────────── + # ── Step 2: Mode is selected, will enable WhatsApp only after pairing ── + # We intentionally don't write WHATSAPP_ENABLED=true here. If the user + # aborts the wizard later (Ctrl+C, failed npm install, missed QR scan), + # we'd otherwise leave .env claiming WhatsApp is ready when the bridge + # has no creds.json. Every subsequent `hermes gateway` then paid a 30s + # bridge-bootstrap timeout and queued WhatsApp for indefinite retries. + # Now: aborted setup leaves WHATSAPP_ENABLED unset → gateway skips it. + # Re-runs that already have WHATSAPP_ENABLED=true (from a prior + # successful pairing) stay enabled — we just don't write it pre-emptively. print() - current = get_env_value("WHATSAPP_ENABLED") - if current and current.lower() == "true": + if (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true": print("✓ WhatsApp is already enabled") - else: - save_env_value("WHATSAPP_ENABLED", "true") - print("✓ WhatsApp enabled") # ── Step 3: Allowed users ──────────────────────────────────────────── current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or "" @@ -1619,6 +1623,12 @@ def cmd_whatsapp(args): session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") else: + # Existing pairing — ensure WHATSAPP_ENABLED reflects that. + # (Older installs may have lost the env var; covers re-runs + # where the user picked "no, keep my session" but the var + # was never set or got removed.) + if (get_env_value("WHATSAPP_ENABLED") or "").lower() != "true": + save_env_value("WHATSAPP_ENABLED", "true") print("\n✓ WhatsApp is configured and paired!") print(" Start the gateway with: hermes gateway") return @@ -1647,6 +1657,11 @@ def cmd_whatsapp(args): # ── Step 7: Post-pairing ───────────────────────────────────────────── print() if (session_dir / "creds.json").exists(): + # Only enable WhatsApp now that pairing actually succeeded. If the + # user Ctrl+C'd at any earlier step, WHATSAPP_ENABLED stays unset + # and `hermes gateway` skips it cleanly instead of paying a 30s + # bridge timeout + queueing the platform for indefinite retries. + save_env_value("WHATSAPP_ENABLED", "true") print("✓ WhatsApp paired successfully!") print() if wa_mode == "bot": diff --git a/tests/gateway/test_platform_reconnect.py b/tests/gateway/test_platform_reconnect.py index a0bd7ab9eec5..e4362a025624 100644 --- a/tests/gateway/test_platform_reconnect.py +++ b/tests/gateway/test_platform_reconnect.py @@ -294,15 +294,63 @@ class TestPlatformReconnectWatcher: assert runner._failed_platforms[Platform.TELEGRAM]["attempts"] == 2 @pytest.mark.asyncio - async def test_reconnect_gives_up_after_max_attempts(self): - """After max attempts, platform should be removed from retry queue.""" + async def test_reconnect_pauses_after_circuit_breaker_threshold(self): + """After enough consecutive retryable failures, the watcher should + *pause* the platform (keep it in the queue but stop hammering it), + not drop it. The user resumes via /platform resume. + """ + runner = _make_runner() + + platform_config = PlatformConfig(enabled=True, token="test") + # 9 prior attempts — the next failure will be the 10th and should + # trip the circuit breaker. + runner._failed_platforms[Platform.TELEGRAM] = { + "config": platform_config, + "attempts": 9, + "next_retry": time.monotonic() - 1, + } + + fail_adapter = StubAdapter( + succeed=False, fatal_error="DNS failure", fatal_retryable=True + ) + real_sleep = asyncio.sleep + + with patch.object(runner, "_create_adapter", return_value=fail_adapter): + async def run_one_iteration(): + runner._running = True + call_count = 0 + + async def fake_sleep(n): + nonlocal call_count + call_count += 1 + if call_count > 1: + runner._running = False + await real_sleep(0) + + with patch("asyncio.sleep", side_effect=fake_sleep): + await runner._platform_reconnect_watcher() + + await run_one_iteration() + + # Platform stays in queue — paused, not dropped + assert Platform.TELEGRAM in runner._failed_platforms + info = runner._failed_platforms[Platform.TELEGRAM] + assert info["paused"] is True + assert info["attempts"] == 10 + assert "pause_reason" in info + + @pytest.mark.asyncio + async def test_reconnect_skips_paused_platforms(self): + """A paused platform should not be retried by the watcher tick.""" runner = _make_runner() platform_config = PlatformConfig(enabled=True, token="test") runner._failed_platforms[Platform.TELEGRAM] = { "config": platform_config, - "attempts": 20, # At max - "next_retry": time.monotonic() - 1, + "attempts": 10, + "next_retry": time.monotonic() - 1, # would normally retry now + "paused": True, + "pause_reason": "paused via /platform pause", } real_sleep = asyncio.sleep @@ -324,8 +372,10 @@ class TestPlatformReconnectWatcher: await run_one_iteration() - assert Platform.TELEGRAM not in runner._failed_platforms - mock_create.assert_not_called() # Should give up without trying + # Paused platform stays queued and was never touched + assert Platform.TELEGRAM in runner._failed_platforms + assert runner._failed_platforms[Platform.TELEGRAM]["paused"] is True + mock_create.assert_not_called() @pytest.mark.asyncio async def test_reconnect_skips_when_not_time_yet(self): @@ -459,11 +509,12 @@ class TestRuntimeDisconnectQueuing: assert Platform.TELEGRAM not in runner._failed_platforms @pytest.mark.asyncio - async def test_retryable_error_exits_for_service_restart_when_all_down(self): - """Gateway should exit with failure when all platforms fail with retryable errors. - - This lets systemd Restart=on-failure restart the process, which is more - reliable than in-process background reconnection after exhausted retries. + async def test_retryable_error_keeps_gateway_alive_when_all_down(self): + """When all adapters fail at runtime with retryable errors, the + gateway should stay alive and let the reconnect watcher recover them + in the background. (Previously this exited-with-failure to trigger + a systemd restart — that converted transient outages into infinite + restart loops and killed in-process state.) """ runner = _make_runner() runner.stop = AsyncMock() @@ -474,9 +525,9 @@ class TestRuntimeDisconnectQueuing: await runner._handle_adapter_fatal_error(adapter) - # stop() SHOULD be called — gateway exits for systemd restart - runner.stop.assert_called_once() - assert runner._exit_with_failure is True + # stop() should NOT be called — gateway stays alive for the watcher + runner.stop.assert_not_called() + assert runner._exit_with_failure is False assert Platform.TELEGRAM in runner._failed_platforms @pytest.mark.asyncio @@ -512,3 +563,154 @@ class TestRuntimeDisconnectQueuing: await runner._handle_adapter_fatal_error(adapter) runner.stop.assert_called_once() + + +# --- Pause / resume circuit breaker --- + + +class TestPauseResume: + """Test the per-platform pause/resume helpers and slash command.""" + + def test_pause_marks_platform_paused(self): + runner = _make_runner() + runner._failed_platforms[Platform.TELEGRAM] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 3, + "next_retry": time.monotonic() + 30, + } + runner._pause_failed_platform(Platform.TELEGRAM, reason="manual") + info = runner._failed_platforms[Platform.TELEGRAM] + assert info["paused"] is True + assert info["pause_reason"] == "manual" + assert info["next_retry"] == float("inf") + + def test_pause_is_idempotent(self): + runner = _make_runner() + runner._failed_platforms[Platform.TELEGRAM] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 3, + "next_retry": time.monotonic() + 30, + "paused": True, + "pause_reason": "first reason", + } + runner._pause_failed_platform(Platform.TELEGRAM, reason="second reason") + # Reason should not be overwritten on a second pause call. + assert ( + runner._failed_platforms[Platform.TELEGRAM]["pause_reason"] + == "first reason" + ) + + def test_pause_no_op_when_platform_not_queued(self): + runner = _make_runner() + # No exception even when the platform isn't in _failed_platforms. + runner._pause_failed_platform(Platform.TELEGRAM, reason="x") + assert Platform.TELEGRAM not in runner._failed_platforms + + def test_resume_clears_paused_and_resets_attempts(self): + runner = _make_runner() + runner._failed_platforms[Platform.TELEGRAM] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 10, + "next_retry": float("inf"), + "paused": True, + "pause_reason": "auto-paused", + } + assert runner._resume_paused_platform(Platform.TELEGRAM) is True + info = runner._failed_platforms[Platform.TELEGRAM] + assert info["paused"] is False + assert info["attempts"] == 0 + assert info["next_retry"] != float("inf") + assert "pause_reason" not in info + + def test_resume_returns_false_when_not_paused(self): + runner = _make_runner() + runner._failed_platforms[Platform.TELEGRAM] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 1, + "next_retry": time.monotonic() + 30, + } + assert runner._resume_paused_platform(Platform.TELEGRAM) is False + + def test_resume_returns_false_when_not_queued(self): + runner = _make_runner() + assert runner._resume_paused_platform(Platform.TELEGRAM) is False + + +class TestPlatformSlashCommand: + """Test the /platform list|pause|resume slash command handler.""" + + def _make_event(self, content: str): + ev = MagicMock() + ev.content = content + return ev + + @pytest.mark.asyncio + async def test_list_shows_connected_and_paused(self): + runner = _make_runner() + runner.adapters[Platform.DISCORD] = StubAdapter(platform=Platform.DISCORD) + runner._failed_platforms[Platform.WHATSAPP] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 10, + "next_retry": float("inf"), + "paused": True, + "pause_reason": "not paired", + } + out = await runner._handle_platform_command(self._make_event("/platform list")) + assert "discord" in out + assert "whatsapp" in out + assert "PAUSED" in out + assert "not paired" in out + + @pytest.mark.asyncio + async def test_pause_command_pauses_queued_platform(self): + runner = _make_runner() + runner._failed_platforms[Platform.WHATSAPP] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 2, + "next_retry": time.monotonic() + 30, + } + out = await runner._handle_platform_command( + self._make_event("/platform pause whatsapp") + ) + assert "paused" in out.lower() + assert runner._failed_platforms[Platform.WHATSAPP]["paused"] is True + + @pytest.mark.asyncio + async def test_pause_rejects_unqueued_platform(self): + runner = _make_runner() + out = await runner._handle_platform_command( + self._make_event("/platform pause whatsapp") + ) + assert "not in the retry queue" in out + + @pytest.mark.asyncio + async def test_resume_command_resumes_paused_platform(self): + runner = _make_runner() + runner._failed_platforms[Platform.WHATSAPP] = { + "config": PlatformConfig(enabled=True, token="t"), + "attempts": 10, + "next_retry": float("inf"), + "paused": True, + "pause_reason": "x", + } + out = await runner._handle_platform_command( + self._make_event("/platform resume whatsapp") + ) + assert "resumed" in out.lower() + assert runner._failed_platforms[Platform.WHATSAPP]["paused"] is False + + @pytest.mark.asyncio + async def test_unknown_platform_name(self): + runner = _make_runner() + out = await runner._handle_platform_command( + self._make_event("/platform pause notarealplatform") + ) + assert "Unknown platform" in out + + @pytest.mark.asyncio + async def test_bare_platform_shows_usage_with_list(self): + # An empty /platform call defaults to "list". + runner = _make_runner() + out = await runner._handle_platform_command(self._make_event("/platform")) + assert "Gateway platforms" in out + diff --git a/tests/gateway/test_runner_fatal_adapter.py b/tests/gateway/test_runner_fatal_adapter.py index 13b9a7d99e84..706514f1ae6f 100644 --- a/tests/gateway/test_runner_fatal_adapter.py +++ b/tests/gateway/test_runner_fatal_adapter.py @@ -68,7 +68,11 @@ async def test_runner_requests_clean_exit_for_nonretryable_startup_conflict(monk @pytest.mark.asyncio async def test_runner_queues_retryable_runtime_fatal_for_reconnection(monkeypatch, tmp_path): """Retryable runtime fatal errors queue the platform for reconnection - instead of shutting down the gateway.""" + AND keep the gateway alive — the background reconnect watcher recovers + the platform when the underlying issue clears. (Previously this + exited-with-failure to trigger a systemd restart; that converted + transient failures into infinite restart loops.) + """ config = GatewayConfig( platforms={ Platform.WHATSAPP: PlatformConfig(enabled=True, token="token") @@ -89,8 +93,8 @@ async def test_runner_queues_retryable_runtime_fatal_for_reconnection(monkeypatc await runner._handle_adapter_fatal_error(adapter) - # Should shut down with failure — systemd Restart=on-failure will restart - runner.stop.assert_awaited_once() - assert runner._exit_with_failure is True + # Gateway stays alive — watcher will retry in background + runner.stop.assert_not_awaited() + assert runner._exit_with_failure is False assert Platform.WHATSAPP in runner._failed_platforms assert runner._failed_platforms[Platform.WHATSAPP]["attempts"] == 0 diff --git a/tests/gateway/test_runner_startup_failures.py b/tests/gateway/test_runner_startup_failures.py index fc5c775a7799..438553f34edb 100644 --- a/tests/gateway/test_runner_startup_failures.py +++ b/tests/gateway/test_runner_startup_failures.py @@ -64,7 +64,14 @@ class _SuccessfulAdapter(BasePlatformAdapter): @pytest.mark.asyncio -async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch, tmp_path): +async def test_runner_stays_alive_for_retryable_startup_errors(monkeypatch, tmp_path): + """Retryable startup errors should leave the gateway running in + degraded mode so the reconnect watcher can recover the platform when + the underlying problem clears. Previously this returned False from + ``start()`` and exited the process, which converted a single broken + platform (e.g. unpaired WhatsApp, DNS blip on Telegram) into a + systemd restart loop and killed cron jobs in the meantime. + """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config = GatewayConfig( platforms={ @@ -78,11 +85,13 @@ async def test_runner_returns_failure_for_retryable_startup_errors(monkeypatch, ok = await runner.start() - assert ok is False + # Gateway stays alive in degraded mode; reconnect watcher takes over. + assert ok is True assert runner.should_exit_cleanly is False state = read_runtime_status() - assert state["gateway_state"] == "startup_failed" - assert "temporary DNS resolution failure" in state["exit_reason"] + assert state["gateway_state"] in {"degraded", "running"} + # Telegram was queued for retry, not given up on. + assert Platform.TELEGRAM in runner._failed_platforms assert state["platforms"]["telegram"]["state"] == "retrying" assert state["platforms"]["telegram"]["error_code"] == "telegram_connect_error" diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 0a359fb7511b..9d7807734bbc 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -611,3 +611,93 @@ class TestHttpSessionLifecycle: mock_task.cancel.assert_not_called() assert adapter._poll_task is None + + +# --------------------------------------------------------------------------- +# Pre-flight: refuse to start the bridge when creds.json is missing +# --------------------------------------------------------------------------- + + +class TestNoCredsPreflight: + """Verify ``connect()`` fast-fails as non-retryable when WhatsApp is + enabled but the user never finished pairing (no ``creds.json``). + + Without this guard, every gateway boot: + • spawned the bridge subprocess (npm install if needed) + • waited 30s for status:connected (never happens without creds) + • queued WhatsApp for indefinite retries that would just repeat + With the guard, ``connect()`` returns False immediately with a + non-retryable fatal error so the reconnect watcher drops the platform + and the gateway gets a single clear log line telling the user to run + ``hermes whatsapp``. + """ + + @pytest.mark.asyncio + async def test_connect_returns_false_when_no_creds(self, tmp_path): + from gateway.platforms.whatsapp import WhatsAppAdapter + + adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) + adapter.platform = Platform.WHATSAPP + adapter.config = MagicMock() + adapter._bridge_port = 19876 + # Point bridge_script at a real existing file so the earlier + # bridge-missing check doesn't trip — we want to exercise the + # creds.json check specifically. + bridge = tmp_path / "bridge.js" + bridge.write_text("// stub") + adapter._bridge_script = str(bridge) + adapter._session_path = tmp_path / "session" # no creds.json inside + adapter._session_path.mkdir() + adapter._bridge_log_fh = None + adapter._fatal_error_code = None + adapter._fatal_error_message = None + adapter._fatal_error_retryable = True + + with patch( + "gateway.platforms.whatsapp.check_whatsapp_requirements", + return_value=True, + ): + result = await adapter.connect() + + assert result is False + # Non-retryable so the reconnect watcher drops it cleanly + assert adapter._fatal_error_code == "whatsapp_not_paired" + assert adapter._fatal_error_retryable is False + + @pytest.mark.asyncio + async def test_connect_proceeds_when_creds_present(self, tmp_path): + """When creds.json exists, the preflight check is bypassed and + connect() proceeds to the bridge bootstrap path. We don't fully + simulate the bridge here — we just verify no fast-fail occurs. + """ + from gateway.platforms.whatsapp import WhatsAppAdapter + + adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) + adapter.platform = Platform.WHATSAPP + adapter.config = MagicMock() + adapter._bridge_port = 19877 + bridge = tmp_path / "bridge.js" + bridge.write_text("// stub") + adapter._bridge_script = str(bridge) + session_dir = tmp_path / "session" + session_dir.mkdir() + (session_dir / "creds.json").write_text("{}") + adapter._session_path = session_dir + adapter._bridge_log_fh = None + adapter._fatal_error_code = None + adapter._fatal_error_message = None + adapter._fatal_error_retryable = True + # Stub _acquire_platform_lock to return False so connect() exits + # cleanly *after* the preflight, without spawning subprocesses. + adapter._acquire_platform_lock = MagicMock(return_value=False) + + with patch( + "gateway.platforms.whatsapp.check_whatsapp_requirements", + return_value=True, + ): + result = await adapter.connect() + + # Preflight passed — exits because we faked lock acquisition, + # but the fatal-error code is NOT the "not paired" one. + assert result is False + assert adapter._fatal_error_code != "whatsapp_not_paired" diff --git a/tests/hermes_cli/test_whatsapp_setup_ordering.py b/tests/hermes_cli/test_whatsapp_setup_ordering.py new file mode 100644 index 000000000000..47952bcc7966 --- /dev/null +++ b/tests/hermes_cli/test_whatsapp_setup_ordering.py @@ -0,0 +1,140 @@ +"""Regression tests for ``cmd_whatsapp`` env-var write ordering. + +Before the fix, ``hermes whatsapp`` wrote ``WHATSAPP_ENABLED=true`` at +step 2 — before npm install (step 4) and before QR pairing (step 6). +If the user Ctrl+C'd at any later step, ``.env`` claimed WhatsApp was +ready when the bridge still had no ``creds.json``. Every subsequent +``hermes gateway`` then paid a 30s bridge-bootstrap timeout and queued +WhatsApp for indefinite retries — looking like "the gateway is broken." + +The fix: only set ``WHATSAPP_ENABLED=true`` once pairing actually +succeeds (creds.json exists). Aborted setup leaves no enabled state. +""" + +from __future__ import annotations + +import io +import os +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / "home" + hermes = home / ".hermes" + hermes.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setenv("HERMES_HOME", str(hermes)) + # Ensure get_env_value cache doesn't carry stale state. + for key in list(os.environ): + if key.startswith("WHATSAPP_"): + monkeypatch.delenv(key, raising=False) + return hermes + + +def _env_value(hermes_home: Path, key: str) -> str | None: + env_file = hermes_home / ".env" + if not env_file.exists(): + return None + for line in env_file.read_text().splitlines(): + if "=" not in line: + continue + k, _, v = line.partition("=") + if k.strip() == key: + return v.strip().strip('"').strip("'") + return None + + +def test_aborted_setup_does_not_enable_whatsapp(isolated_home, monkeypatch): + """User picks mode 1, then Ctrl+C's at the allowed-users prompt. + + WHATSAPP_ENABLED must NOT be present in .env after abort. + """ + from hermes_cli.main import cmd_whatsapp + + # First input() = mode choice, second input() = allowed-users prompt + # We raise KeyboardInterrupt on the second call to simulate abort. + inputs = iter(["1"]) + + def fake_input(_prompt=""): + try: + return next(inputs) + except StopIteration: + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", fake_input) + # _require_tty calls sys.stdin.isatty — make it pass. + monkeypatch.setattr("hermes_cli.main._require_tty", lambda *_a, **_kw: None) + # No node, no bridge script — we shouldn't reach those steps anyway. + + buf = io.StringIO() + with redirect_stdout(buf): + try: + cmd_whatsapp(MagicMock()) + except KeyboardInterrupt: + pass + + assert _env_value(isolated_home, "WHATSAPP_ENABLED") is None, ( + "Setup aborted before pairing — WHATSAPP_ENABLED must not be set. " + f"Got .env: {(isolated_home / '.env').read_text() if (isolated_home / '.env').exists() else '(missing)'}" + ) + + +def test_existing_pairing_skip_branch_enables_whatsapp(isolated_home, monkeypatch): + """User runs ``hermes whatsapp`` with an existing paired session and + chooses "no, keep my session" at the re-pair prompt. The env var + should be (re-)written to true so the gateway picks WhatsApp back up, + even if the var was lost since the original pairing. + """ + from hermes_cli.main import cmd_whatsapp + + # Pre-create a paired session WITHOUT WHATSAPP_ENABLED in .env. + session = isolated_home / "whatsapp" / "session" + session.mkdir(parents=True) + (session / "creds.json").write_text("{}") + monkeypatch.setenv("WHATSAPP_MODE", "bot") + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15551234567") + + # mode already set → skip mode prompt; users already set → skip update + # prompt with "no"; pairing exists → "no, keep session" → return. + inputs = iter(["n", "n"]) + + def fake_input(_prompt=""): + try: + return next(inputs) + except StopIteration: + return "n" + + monkeypatch.setattr("builtins.input", fake_input) + monkeypatch.setattr("hermes_cli.main._require_tty", lambda *_a, **_kw: None) + # Skip the bridge npm install — we're testing setup-ordering, not bridge + # bootstrapping. Pretend node_modules exists (Path.exists -> True for that + # specific check is hard to scope, so instead pretend npm install would + # succeed silently if reached). + monkeypatch.setattr( + "subprocess.run", + lambda *_a, **_kw: MagicMock(returncode=0, stderr=""), + ) + monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/npm") + # Patch (bridge_dir / "node_modules").exists() by stubbing Path.exists + # to True for that one specific subpath. Easier: pre-create it as a + # symlink to /tmp. But we can't write to the repo. Instead, stub + # Path.exists wholesale to True for node_modules; the creds.json check + # in the same function still works because we wrote it ourselves. + _orig_exists = Path.exists + def _stub_exists(self): + if self.name == "node_modules": + return True + return _orig_exists(self) + monkeypatch.setattr(Path, "exists", _stub_exists) + + buf = io.StringIO() + with redirect_stdout(buf): + cmd_whatsapp(MagicMock()) + + # The skip-rebar branch should have set the env var on its way out. + assert _env_value(isolated_home, "WHATSAPP_ENABLED") == "true" From 032fb842225dedf5e6649489f81631465f1aa809 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:44:27 -0700 Subject: [PATCH 11/48] docs(hermes_tools_mcp_server): align scope docstring with EXPOSED_TOOLS (#26603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-of-file scope docstring listed delegate_task, memory, and session_search as exposed tools, but EXPOSED_TOOLS deliberately omits them (they're _AGENT_LOOP_TOOLS and require the running AIAgent context to dispatch — the inline comment block already explains this). Kanban tools, which ARE exposed, were missing from the docstring entirely. Rewrite the Scope / DO NOT expose sections to match the actual tuple: drop delegate_task/memory/session_search from 'expose', add the kanban_* family, move delegate_task/memory/session_search/todo into 'DO NOT expose' with the agent-loop rationale. Fixes #26567 (doc-only fix; option 2 — shimming memory/session_search through MemoryStore/SessionDB directly — left for a follow-up issue once the plugin-memory locking story is audited). --- agent/transports/hermes_tools_mcp_server.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index f7f8ae24887f..37f2d6179d11 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -14,20 +14,28 @@ the user gets full Hermes capability inside a Codex turn. Scope (what we expose): - web_search, web_extract — Firecrawl, no codex equivalent - browser_navigate / _click / _type / — Camofox/Browserbase automation - _snapshot / _screenshot / _scroll / _back / _press / _vision - - delegate_task — Hermes subagents + _snapshot / _scroll / _back / _press / + _get_images / _console / _vision - vision_analyze — image inspection by vision model - image_generate — image generation - - memory — Hermes' persistent memory store - skill_view, skills_list — Hermes' skill library - - session_search — cross-session search - text_to_speech — TTS + - kanban_* (complete/block/comment/ — kanban worker + orchestrator + heartbeat/show/list/create/ handoff (stateless: read env var, + unblock/link) write ~/.hermes/kanban.db) -What we DO NOT expose (codex has equivalents): +What we DO NOT expose: - terminal / shell — codex's own shell tool - read_file / write_file / patch — codex's apply_patch + shell - search_files / process — codex's shell - - clarify, todo — codex's own UX + - clarify — codex's own UX + - delegate_task / memory / — `_AGENT_LOOP_TOOLS` in Hermes + session_search / todo (model_tools.py). They require + the running AIAgent context to + dispatch (mid-loop state), so a + stateless MCP callback can't + drive them. See the inline + comment on EXPOSED_TOOLS below. Run with: python -m agent.transports.hermes_tools_mcp_server Spawned by: CodexAppServerSession.ensure_started() when the runtime is From 3215ef160938c71ff61bab279b30545c0cc14a14 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 11:59:22 +0000 Subject: [PATCH 12/48] ci(pypi): build web dashboard + TUI bundle before creating wheel --- .github/workflows/upload_to_pypi.yml | 21 +++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 4e2fe4748d3a..ae68ed034a15 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -50,6 +50,27 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build web dashboard + run: cd web && npm ci && npm run build + + - name: Build TUI bundle + run: cd ui-tui && npm ci && npm run build + + - name: Bundle TUI into hermes_cli + run: | + mkdir -p hermes_cli/tui_dist + cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js + + - name: Verify frontend assets exist + run: | + test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } + test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } + - name: Build wheel and sdist run: uv build --sdist --wheel diff --git a/pyproject.toml b/pyproject.toml index ae2fff385a3a..87674601db08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*"] gateway = ["assets/**/*"] acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"] From 384ec9684e86081c4add84d671d2bbf7c8ee69d4 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:00:07 +0000 Subject: [PATCH 13/48] feat(banner): check PyPI for updates when not a git install For pip-installed hermes-agent (no .git directory), fall back to querying PyPI's JSON API to compare __version__ against the latest published release, using stdlib only (urllib + json, no packaging dep). --- hermes_cli/banner.py | 48 +++++++++++++++++++++- tests/hermes_cli/test_banner_pip_update.py | 35 ++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_banner_pip_update.py diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 036412ac072c..061992b4746b 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -175,6 +175,49 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: return None +def _version_tuple(v: str) -> tuple[int, ...]: + """Parse '0.13.0' into (0, 13, 0) for comparison. Non-numeric segments become 0.""" + parts = [] + for segment in v.split("."): + try: + parts.append(int(segment)) + except ValueError: + parts.append(0) + return tuple(parts) + + +def _fetch_pypi_latest(package: str = "hermes-agent") -> Optional[str]: + """Fetch the latest version of a package from PyPI. Returns None on failure.""" + try: + import urllib.request + import json as _json + url = f"https://pypi.org/pypi/{package}/json" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data = _json.loads(resp.read()) + return data.get("info", {}).get("version") + except Exception: + return None + + +def _check_via_pypi() -> Optional[int]: + """Compare installed version against PyPI latest. + + Returns 0 if up-to-date, 1 if behind, None on failure. + """ + latest = _fetch_pypi_latest() + if latest is None: + return None + if latest == VERSION: + return 0 + try: + if _version_tuple(latest) > _version_tuple(VERSION): + return 1 + return 0 + except Exception: + return 1 if latest != VERSION else 0 + + def check_for_updates() -> Optional[int]: """Check whether a Hermes update is available. @@ -213,8 +256,9 @@ def check_for_updates() -> Optional[int]: if not (repo_dir / ".git").exists(): repo_dir = hermes_home / "hermes-agent" if not (repo_dir / ".git").exists(): - return None - behind = _check_via_local_git(repo_dir) + behind = _check_via_pypi() + else: + behind = _check_via_local_git(repo_dir) try: cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev})) diff --git a/tests/hermes_cli/test_banner_pip_update.py b/tests/hermes_cli/test_banner_pip_update.py new file mode 100644 index 000000000000..a0e9266f6987 --- /dev/null +++ b/tests/hermes_cli/test_banner_pip_update.py @@ -0,0 +1,35 @@ +from unittest.mock import patch + + +def test_check_via_pypi_detects_update(): + """_check_via_pypi returns 1 when PyPI has newer version.""" + from hermes_cli.banner import _check_via_pypi + with patch("hermes_cli.banner.VERSION", "0.12.0"): + with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): + result = _check_via_pypi() + assert result == 1 + + +def test_check_via_pypi_up_to_date(): + """_check_via_pypi returns 0 when versions match.""" + from hermes_cli.banner import _check_via_pypi + with patch("hermes_cli.banner.VERSION", "0.13.0"): + with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): + result = _check_via_pypi() + assert result == 0 + + +def test_check_via_pypi_network_failure(): + """_check_via_pypi returns None on network error.""" + from hermes_cli.banner import _check_via_pypi + with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None): + result = _check_via_pypi() + assert result is None + + +def test_version_tuple_comparison(): + """Version comparison works with multi-segment versions.""" + from hermes_cli.banner import _version_tuple + assert _version_tuple("0.13.0") > _version_tuple("0.12.0") + assert _version_tuple("0.13.0") == _version_tuple("0.13.0") + assert _version_tuple("1.0.0") > _version_tuple("0.99.99") From cc07e30f45267c00fac97ea5569c606aca5a1ffb Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:00:28 +0000 Subject: [PATCH 14/48] feat(install): add --ensure and --postinstall modes for targeted dep bootstrap Adds --ensure DEPS for pip-runtime dep installation and --postinstall for pip users who want the full post-install experience without cloning. --- scripts/install.sh | 106 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 9c5db6b1c080..9b1b7469bb84 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,6 +71,8 @@ USE_VENV=true RUN_SETUP=true SKIP_BROWSER=false BRANCH="main" +ENSURE_DEPS="" +POSTINSTALL_MODE=false # Detect non-interactive mode (e.g. curl | bash) # When stdin is not a terminal, read -p will fail with EOF, @@ -109,6 +111,14 @@ while [[ $# -gt 0 ]]; do HERMES_HOME="$2" shift 2 ;; + --ensure) + ENSURE_DEPS="$2" + shift 2 + ;; + --postinstall) + POSTINSTALL_MODE=true + shift + ;; -h|--help) echo "Hermes Agent Installer" echo "" @@ -133,6 +143,12 @@ while [[ $# -gt 0 ]]; do echo " (default /root/.hermes). This keeps Docker bind-mounted volumes" echo " small and ensures the command is on PATH for all shells." echo " Existing installs at \$HERMES_HOME/hermes-agent are preserved in-place." + echo " --ensure DEPS Install only specified deps (comma-separated)" + echo " Supported: node, browser, ripgrep, ffmpeg" + echo " Does NOT clone repo or create venv" + echo " --postinstall Run post-install setup only (for pip users)" + echo " Installs optional deps + runs hermes setup" + echo " Does NOT clone repo or create venv" exit 0 ;; *) @@ -1872,6 +1888,88 @@ print_success() { fi } +ensure_mode() { + detect_os + + IFS=',' read -ra DEPS <<< "$ENSURE_DEPS" + for dep in "${DEPS[@]}"; do + dep="$(echo "$dep" | tr -d '[:space:]')" + case "$dep" in + node) + check_node + ;; + browser) + check_node + if [ "$HAS_NODE" = true ]; then + DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" + if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then + log_info "Installing agent-browser + Chromium..." + npm_bin="$(command -v npm 2>/dev/null || echo "")" + if [ -n "$npm_bin" ]; then + local agent_browser_dir="$HERMES_HOME/node_modules" + mkdir -p "$agent_browser_dir" + "$npm_bin" install --prefix "$HERMES_HOME" agent-browser 2>/dev/null || true + npx playwright install chromium 2>/dev/null || true + fi + else + log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" + fi + fi + ;; + ripgrep) + if ! command -v rg &>/dev/null; then + HAS_RIPGREP=false + HAS_FFMPEG=true + install_system_packages + fi + ;; + ffmpeg) + if ! command -v ffmpeg &>/dev/null; then + HAS_FFMPEG=false + HAS_RIPGREP=true + install_system_packages + fi + ;; + *) + log_warn "Unknown dependency: $dep" + ;; + esac + done +} + +postinstall_mode() { + print_banner + detect_os + + log_info "Post-install mode: setting up Hermes for pip install" + + check_node + check_network_prerequisites + install_system_packages + + if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then + DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" + if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then + log_info "Installing browser engine..." + npm_bin="$(command -v npm 2>/dev/null || echo "")" + if [ -n "$npm_bin" ]; then + npx playwright install chromium 2>/dev/null || true + fi + else + log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" + fi + fi + + HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")" + if [ -n "$HERMES_CMD" ]; then + log_info "Running hermes setup..." + "$HERMES_CMD" setup + else + log_warn "hermes command not found on PATH" + log_info "Try: python -m hermes_cli.main setup" + fi +} + # ============================================================================ # Main # ============================================================================ @@ -1900,4 +1998,10 @@ main() { print_success } -main +if [ -n "$ENSURE_DEPS" ]; then + ensure_mode +elif [ "$POSTINSTALL_MODE" = true ]; then + postinstall_mode +else + main +fi From c4bda3f27c033f33eef824efc3e689119bfbee72 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:00:54 +0000 Subject: [PATCH 15/48] fix(doctor): generate config from defaults when template file is missing When cli-config.yaml.example is not present (e.g. pip wheel install), fall back to writing DEFAULT_CONFIG via save_config() instead of warning and requiring a manual fix. --- hermes_cli/doctor.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c2035b03e6e6..bf5a8865909e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -656,15 +656,17 @@ def run_doctor(args): if fallback_config.exists(): check_ok("cli-config.yaml exists (in project directory)") else: - example_config = PROJECT_ROOT / 'cli-config.yaml.example' - if should_fix and example_config.exists(): + if should_fix: config_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(example_config), str(config_path)) - check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") + example_config = PROJECT_ROOT / 'cli-config.yaml.example' + if example_config.exists(): + shutil.copy2(str(example_config), str(config_path)) + check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") + else: + from hermes_cli.config import DEFAULT_CONFIG, save_config + save_config(DEFAULT_CONFIG) + check_ok(f"Created {_DHH}/config.yaml from defaults") fixed_count += 1 - elif should_fix: - check_warn("config.yaml not found and no example to copy from") - manual_issues.append(f"Create {_DHH}/config.yaml manually") else: check_warn("config.yaml not found", "(using defaults)") From d69eab1efd96a4622e6b00fbb806d1cd049b3589 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:01:31 +0000 Subject: [PATCH 16/48] fix(gateway): build service PATH from existing dirs only, include ~/.hermes/node_modules Extract PATH building into _build_service_path_dirs() that skips directories which don't exist on disk (e.g. node_modules/.bin for pip installs) and also includes ~/.hermes/node/bin and ~/.hermes/node_modules/.bin for agent-browser. --- hermes_cli/gateway.py | 38 +++++++++++++++---- .../hermes_cli/test_gateway_service_paths.py | 31 +++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 tests/hermes_cli/test_gateway_service_paths.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b0cb579daa8f..a865bcaf8be2 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2103,15 +2103,41 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str: return str(current_hermes) +def _build_service_path_dirs(project_root: Path | None = None) -> list[str]: + """Build PATH directory list for service units, excluding non-existent dirs.""" + if project_root is None: + project_root = PROJECT_ROOT + + candidates = [] + + venv_bin = project_root / "venv" / "bin" + if venv_bin.is_dir(): + candidates.append(str(venv_bin)) + elif sys.prefix != sys.base_prefix: + candidates.append(str(Path(sys.prefix) / "bin")) + + node_bin = project_root / "node_modules" / ".bin" + if node_bin.is_dir(): + candidates.append(str(node_bin)) + + hermes_home = get_hermes_home() + hermes_node = hermes_home / "node" / "bin" + if hermes_node.is_dir(): + candidates.append(str(hermes_node)) + hermes_nm = hermes_home / "node_modules" / ".bin" + if hermes_nm.is_dir(): + candidates.append(str(hermes_nm)) + + return candidates + + def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: python_path = get_python_path() working_dir = str(PROJECT_ROOT) detected_venv = _detect_venv_dir() venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") - venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin") - node_bin = str(PROJECT_ROOT / "node_modules" / ".bin") - path_entries = [venv_bin, node_bin] + path_entries = _build_service_path_dirs() resolved_node = shutil.which("node") if resolved_node: resolved_node_dir = str(Path(resolved_node).resolve().parent) @@ -2138,8 +2164,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) python_path = _remap_path_for_user(python_path, home_dir) working_dir = _remap_path_for_user(working_dir, home_dir) venv_dir = _remap_path_for_user(venv_dir, home_dir) - venv_bin = _remap_path_for_user(venv_bin, home_dir) - node_bin = _remap_path_for_user(node_bin, home_dir) path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) path_entries.extend(_build_wsl_interop_paths(path_entries)) @@ -2754,12 +2778,10 @@ def generate_launchd_plist() -> str: # 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_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin") venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") - node_bin = str(PROJECT_ROOT / "node_modules" / ".bin") # 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 = [venv_bin, node_bin] + priority_dirs = _build_service_path_dirs() resolved_node = shutil.which("node") if resolved_node: resolved_node_dir = str(Path(resolved_node).resolve().parent) diff --git a/tests/hermes_cli/test_gateway_service_paths.py b/tests/hermes_cli/test_gateway_service_paths.py new file mode 100644 index 000000000000..71abc4aef240 --- /dev/null +++ b/tests/hermes_cli/test_gateway_service_paths.py @@ -0,0 +1,31 @@ +from pathlib import Path +from unittest.mock import patch + + +def test_service_path_skips_nonexistent_node_modules(tmp_path): + """Service PATH should not include node_modules/.bin if it doesn't exist.""" + from hermes_cli.gateway import _build_service_path_dirs + with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"): + dirs = _build_service_path_dirs(project_root=tmp_path) + node_modules_bin = str(tmp_path / "node_modules" / ".bin") + assert node_modules_bin not in dirs + + +def test_service_path_includes_node_modules_when_present(tmp_path): + """Service PATH should include node_modules/.bin when it exists.""" + nm_bin = tmp_path / "node_modules" / ".bin" + nm_bin.mkdir(parents=True) + from hermes_cli.gateway import _build_service_path_dirs + with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"): + dirs = _build_service_path_dirs(project_root=tmp_path) + assert str(nm_bin) in dirs + + +def test_service_path_includes_hermes_home_node_modules(tmp_path): + """Service PATH should include ~/.hermes/node_modules/.bin when it exists.""" + hermes_nm = tmp_path / ".hermes" / "node_modules" / ".bin" + hermes_nm.mkdir(parents=True) + from hermes_cli.gateway import _build_service_path_dirs + with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"): + dirs = _build_service_path_dirs(project_root=tmp_path) + assert str(hermes_nm) in dirs From b2bf658442f413a9a1d24b011589e5e38544947e Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:02:23 +0000 Subject: [PATCH 17/48] feat(tui): find bundled entry.js from wheel before falling back to npm build Add _find_bundled_tui() that checks for hermes_cli/tui_dist/entry.js (present in wheel installs) and wire it into _make_tui_argv() between the HERMES_TUI_DIR prebuilt path and the npm install fallback. --- hermes_cli/main.py | 14 ++++++++++++++ tests/hermes_cli/test_tui_bundled.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/hermes_cli/test_tui_bundled.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7eedc3fd3222..1324ff8e8e7e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1024,6 +1024,14 @@ def _ensure_tui_node() -> None: os.environ["PATH"] = os.pathsep.join(parts) +def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: + """Find a pre-built TUI entry.js bundled in the wheel.""" + if hermes_cli_dir is None: + hermes_cli_dir = Path(__file__).parent + bundled = hermes_cli_dir / "tui_dist" / "entry.js" + return bundled if bundled.is_file() else None + + def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" _ensure_tui_node() @@ -1058,6 +1066,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: node = _node_bin("node") return [node, str(p / "dist" / "entry.js")], p + # 1b. Bundled in wheel (pip install) + bundled = _find_bundled_tui() + if bundled is not None: + node = _node_bin("node") + return [node, str(bundled)], bundled.parent + # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. # --dev flow: npm install if needed, then tsx src/entry.tsx (no build). if _tui_need_npm_install(tui_dir): diff --git a/tests/hermes_cli/test_tui_bundled.py b/tests/hermes_cli/test_tui_bundled.py new file mode 100644 index 000000000000..c49443a3f769 --- /dev/null +++ b/tests/hermes_cli/test_tui_bundled.py @@ -0,0 +1,21 @@ +from pathlib import Path + + +def test_tui_finds_bundled_entry_js(tmp_path): + """_find_bundled_tui finds entry.js bundled in the package.""" + tui_dist = tmp_path / "hermes_cli" / "tui_dist" + tui_dist.mkdir(parents=True) + entry = tui_dist / "entry.js" + entry.write_text("// bundled TUI", encoding="utf-8") + + from hermes_cli.main import _find_bundled_tui + result = _find_bundled_tui(hermes_cli_dir=tmp_path / "hermes_cli") + assert result is not None + assert result.name == "entry.js" + + +def test_tui_returns_none_when_no_bundle(tmp_path): + """_find_bundled_tui returns None when no bundle exists.""" + from hermes_cli.main import _find_bundled_tui + result = _find_bundled_tui(hermes_cli_dir=tmp_path / "hermes_cli") + assert result is None From 624ce11ee846b57b59ca2e031f34e25813137c4d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:02:44 +0000 Subject: [PATCH 18/48] feat(config): detect pip install method and recommend correct update command Adds detect_install_method() to identify nixos/homebrew/git/pip installs, and recommended_update_command_for_method() to return the right upgrade command for each method. Updates recommended_update_command() to use these for pip-installed instances (no .git dir, not managed). --- hermes_cli/config.py | 33 ++++++++++++++++- .../hermes_cli/test_pip_install_detection.py | 37 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_pip_install_detection.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a560e1e6a1ed..10dd7b464128 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -199,9 +199,40 @@ def get_managed_update_command() -> Optional[str]: return None +def detect_install_method(project_root: Optional[Path] = None) -> str: + """Detect how Hermes was installed: 'nixos', 'homebrew', 'git', or 'pip'.""" + managed = get_managed_system() + if managed: + return managed.lower().replace(" ", "-") + if project_root is None: + project_root = Path(__file__).parent.parent.resolve() + if (project_root / ".git").is_dir(): + return "git" + return "pip" + + +def recommended_update_command_for_method(method: str) -> str: + """Return the update command for a given install method.""" + if method == "nixos": + return "sudo nixos-rebuild switch" + if method == "homebrew": + return "brew upgrade hermes-agent" + if method == "pip": + import shutil + uv = shutil.which("uv") + if uv: + return "uv pip install --upgrade hermes-agent" + return "pip install --upgrade hermes-agent" + return "hermes update" + + def recommended_update_command() -> str: """Return the best update command for the current installation.""" - return get_managed_update_command() or "hermes update" + managed_cmd = get_managed_update_command() + if managed_cmd: + return managed_cmd + method = detect_install_method() + return recommended_update_command_for_method(method) def format_managed_message(action: str = "modify this Hermes installation") -> str: diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py new file mode 100644 index 000000000000..b0f4cbd75ad3 --- /dev/null +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -0,0 +1,37 @@ +from pathlib import Path +from unittest.mock import patch + + +def test_pip_install_detected_when_no_git_dir(tmp_path): + """When PROJECT_ROOT has no .git, detect as pip install.""" + with patch("hermes_cli.config.get_managed_system", return_value=None): + from hermes_cli.config import detect_install_method + method = detect_install_method(project_root=tmp_path) + assert method == "pip" + + +def test_git_install_detected_when_git_dir_exists(tmp_path): + """When PROJECT_ROOT has .git, detect as git install.""" + (tmp_path / ".git").mkdir() + with patch("hermes_cli.config.get_managed_system", return_value=None): + from hermes_cli.config import detect_install_method + method = detect_install_method(project_root=tmp_path) + assert method == "git" + + +def test_managed_install_takes_precedence(tmp_path): + """When HERMES_MANAGED is set, that takes precedence over git detection.""" + (tmp_path / ".git").mkdir() + with patch("hermes_cli.config.get_managed_system", return_value="NixOS"): + from hermes_cli.config import detect_install_method + method = detect_install_method(project_root=tmp_path) + assert method == "nixos" + + +def test_recommended_update_command_pip(): + """Pip installs recommend pip install --upgrade.""" + from hermes_cli.config import recommended_update_command_for_method + cmd = recommended_update_command_for_method("pip") + assert "pip install" in cmd or "uv pip install" in cmd + assert "--upgrade" in cmd + assert "hermes-agent" in cmd From 79afa50703d18f91fb7878a7b7a31b425ab40382 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:04:34 +0000 Subject: [PATCH 19/48] feat(update): support pip install --upgrade for PyPI installs When .git is absent and detect_install_method returns "pip", fork hermes update to run `uv pip install --upgrade hermes-agent` (or `python -m pip install --upgrade hermes-agent` as fallback) instead of hard-exiting with "Not a git repository". --- hermes_cli/main.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1324ff8e8e7e..ea050126736a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7671,6 +7671,29 @@ def cmd_update(args): _finalize_update_output(_update_io_state) +def _cmd_update_pip(args): + """Update Hermes via pip (for PyPI installs).""" + import subprocess as _sp + from hermes_cli import __version__ + + print(f"→ Current version: {__version__}") + print("→ Checking PyPI for updates...") + + uv = shutil.which("uv") + if uv: + cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] + else: + cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] + + print(f"→ Running: {' '.join(cmd)}") + result = _sp.run(cmd) + if result.returncode != 0: + print("✗ Update failed") + sys.exit(1) + + print("✓ Update complete! Restart hermes to use the new version.") + + def _cmd_update_impl(args, gateway_mode: bool): """Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on ``sys.exit``.""" @@ -7698,6 +7721,11 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": use_zip_update = True else: + from hermes_cli.config import detect_install_method + method = detect_install_method(PROJECT_ROOT) + if method == "pip": + _cmd_update_pip(args) + return print("✗ Not a git repository. Please reinstall:") print( " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" From bea96e5cac3caf12885056fbc3a400cb5c008540 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:05:12 +0000 Subject: [PATCH 20/48] chore(config): expand ensure_hermes_home to create full directory scaffold Match the full set of subdirs created by install.sh: pairing, hooks, image_cache, audio_cache, and skills are now pre-created alongside the existing cron, sessions, logs, logs/curator, and memories dirs. This makes hermes doctor checks cleaner without changing any runtime behaviour. --- hermes_cli/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 10dd7b464128..508de0d3faac 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -432,7 +432,10 @@ def ensure_hermes_home(): else: home.mkdir(parents=True, exist_ok=True) _secure_dir(home) - for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"): + for subdir in ( + "cron", "sessions", "logs", "logs/curator", "memories", + "pairing", "hooks", "image_cache", "audio_cache", "skills", + ): d = home / subdir d.mkdir(parents=True, exist_ok=True) _secure_dir(d) From 259ae846c8ae1b84d4cbd2cb1d62c6eefd81957f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:06:05 +0000 Subject: [PATCH 21/48] feat: add ensure_dependency() wrapper + ship install.sh in wheel Includes paired change: browser tool now searches ~/.hermes/node_modules/.bin/ for agent-browser installed via install.sh --ensure browser. --- .github/workflows/upload_to_pypi.yml | 5 ++ hermes_cli/dep_ensure.py | 96 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/hermes_cli/test_dep_ensure.py | 43 +++++++++++++ tools/browser_tool.py | 3 +- 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/dep_ensure.py create mode 100644 tests/hermes_cli/test_dep_ensure.py diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index ae68ed034a15..9dce018d6903 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -71,6 +71,11 @@ jobs: test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } + - name: Bundle install.sh into wheel + run: | + mkdir -p hermes_cli/scripts + cp scripts/install.sh hermes_cli/scripts/install.sh + - name: Build wheel and sdist run: uv build --sdist --wheel diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py new file mode 100644 index 000000000000..03ddd80ef847 --- /dev/null +++ b/hermes_cli/dep_ensure.py @@ -0,0 +1,96 @@ +"""Lazy dependency bootstrapper for non-Python runtime deps. + +Wraps install.sh --ensure to install node, browser, ripgrep, ffmpeg +on first use. Prompts interactively unless told not to. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +_DEP_CHECKS = { + "node": lambda: shutil.which("node") is not None, + "browser": lambda: ( + shutil.which("agent-browser") is not None + or _has_system_browser() + or _has_hermes_agent_browser() + ), + "ripgrep": lambda: shutil.which("rg") is not None, + "ffmpeg": lambda: shutil.which("ffmpeg") is not None, +} + +_DEP_DESCRIPTIONS = { + "node": "Node.js (required for browser tools and TUI)", + "browser": "Browser engine (Chromium, for web browsing tools)", + "ripgrep": "ripgrep (fast file search)", + "ffmpeg": "ffmpeg (TTS voice messages)", +} + + +def _has_system_browser() -> bool: + for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"): + if shutil.which(name): + return True + return False + + +def _has_hermes_agent_browser() -> bool: + hermes_home = os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")) + return (Path(hermes_home) / "node_modules" / ".bin" / "agent-browser").is_file() + + +def _find_install_script( + package_dir: Path | None = None, + repo_root: Path | None = None, +) -> Path | None: + """Locate install.sh — bundled in wheel or in git checkout.""" + if package_dir is None: + package_dir = Path(__file__).parent + if repo_root is None: + repo_root = package_dir.parent + + bundled = package_dir / "scripts" / "install.sh" + if bundled.is_file(): + return bundled + repo = repo_root / "scripts" / "install.sh" + if repo.is_file(): + return repo + return None + + +def ensure_dependency(dep: str, interactive: bool = True) -> bool: + """Ensure a non-Python dependency is available. Returns True if available.""" + check = _DEP_CHECKS.get(dep) + if check and check(): + return True + + script = _find_install_script() + if script is None: + if interactive: + desc = _DEP_DESCRIPTIONS.get(dep, dep) + print(f" {desc} is not installed and install.sh was not found.") + print(f" Install {dep} manually and try again.") + return False + + if interactive and sys.stdin.isatty(): + desc = _DEP_DESCRIPTIONS.get(dep, dep) + try: + reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + if reply not in ("", "y", "yes"): + return False + + result = subprocess.run( + ["bash", str(script), "--ensure", dep], + env={**os.environ, "IS_INTERACTIVE": "false"}, + ) + if result.returncode != 0: + return False + + if check: + return check() + return True diff --git a/pyproject.toml b/pyproject.toml index 87674601db08..fff11f6a5d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*", "tui_dist/**/*"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh"] gateway = ["assets/**/*"] acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"] diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py new file mode 100644 index 000000000000..c980c290099e --- /dev/null +++ b/tests/hermes_cli/test_dep_ensure.py @@ -0,0 +1,43 @@ +from pathlib import Path +from unittest.mock import patch + + +def test_ensure_dependency_skips_when_present(): + """ensure_dependency is a no-op when the dep is already available.""" + from hermes_cli.dep_ensure import ensure_dependency + with patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.return_value = "/usr/bin/node" + result = ensure_dependency("node", interactive=False) + assert result is True + + +def test_ensure_dependency_returns_false_when_missing_noninteractive(): + """ensure_dependency returns False for missing dep in non-interactive mode.""" + from hermes_cli.dep_ensure import ensure_dependency + with patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.return_value = None + with patch("hermes_cli.dep_ensure._find_install_script", return_value=None): + result = ensure_dependency("node", interactive=False) + assert result is False + + +def test_find_install_script_from_checkout(tmp_path): + """_find_install_script finds scripts/install.sh in a git checkout.""" + from hermes_cli.dep_ensure import _find_install_script + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir() + (scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8") + result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert result is not None + assert result.name == "install.sh" + + +def test_find_install_script_from_wheel(tmp_path): + """_find_install_script finds bundled install.sh in a wheel.""" + from hermes_cli.dep_ensure import _find_install_script + bundled = tmp_path / "hermes_cli" / "scripts" + bundled.mkdir(parents=True) + (bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8") + result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert result is not None + assert result.name == "install.sh" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 575beba6c026..c01d25a6f0b3 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -144,7 +144,8 @@ def _browser_candidate_path_dirs() -> list[str]: """Return ordered browser CLI PATH candidates shared by discovery and execution.""" hermes_home = get_hermes_home() hermes_node_bin = str(hermes_home / "node" / "bin") - return [hermes_node_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] + hermes_nm_bin = str(hermes_home / "node_modules" / ".bin") + return [hermes_node_bin, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: From 96917fb74ae4b9857671f7addb957db0774e4c9f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 12:38:04 +0000 Subject: [PATCH 22/48] =?UTF-8?q?refactor:=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20remove=20duplicate=20imports=20and=20deduplicate=20?= =?UTF-8?q?update=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - banner.py: remove redundant `import json as _json` (json already at module level) - main.py: _cmd_update_pip now delegates to recommended_update_command_for_method instead of duplicating the uv-vs-pip detection logic - main.py: remove redundant `import subprocess as _sp` (subprocess already at module level) --- hermes_cli/banner.py | 3 +-- hermes_cli/main.py | 13 ++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 061992b4746b..077ee41f0a27 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -190,11 +190,10 @@ def _fetch_pypi_latest(package: str = "hermes-agent") -> Optional[str]: """Fetch the latest version of a package from PyPI. Returns None on failure.""" try: import urllib.request - import json as _json url = f"https://pypi.org/pypi/{package}/json" req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=5) as resp: - data = _json.loads(resp.read()) + data = json.loads(resp.read()) return data.get("info", {}).get("version") except Exception: return None diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ea050126736a..95947641aa5d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7673,20 +7673,19 @@ def cmd_update(args): def _cmd_update_pip(args): """Update Hermes via pip (for PyPI installs).""" - import subprocess as _sp from hermes_cli import __version__ + from hermes_cli.config import recommended_update_command_for_method print(f"→ Current version: {__version__}") print("→ Checking PyPI for updates...") - uv = shutil.which("uv") - if uv: - cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] - else: - cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] + cmd_str = recommended_update_command_for_method("pip") + cmd = cmd_str.split() + if cmd[0] == "pip": + cmd = [sys.executable, "-m", "pip"] + cmd[1:] print(f"→ Running: {' '.join(cmd)}") - result = _sp.run(cmd) + result = subprocess.run(cmd) if result.returncode != 0: print("✗ Update failed") sys.exit(1) From 55a7c45d379f288fb6dc0eb4e484e82b73471b2c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:01:37 +0000 Subject: [PATCH 23/48] fix(update): handle --check for pip installs (missed code path) _cmd_update_check() had its own `.git` gate separate from _cmd_update_impl. For pip installs, fork to _check_via_pypi() and display the result with the correct recommended_update_command(). --- hermes_cli/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 95947641aa5d..bb372c396f17 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7396,6 +7396,19 @@ def _cmd_update_check(): """Implement ``hermes update --check``: fetch and report without installing.""" git_dir = PROJECT_ROOT / ".git" if not git_dir.exists(): + from hermes_cli.config import detect_install_method, recommended_update_command + if detect_install_method(PROJECT_ROOT) == "pip": + from hermes_cli.banner import _check_via_pypi + result = _check_via_pypi() + if result is None: + print("✗ Could not reach PyPI to check for updates.") + sys.exit(1) + elif result == 0: + print("✓ Already up to date.") + else: + print(f"⚕ Update available on PyPI.") + print(f" Run '{recommended_update_command()}' to install.") + return print("✗ Not a git repository — cannot check for updates.") sys.exit(1) From e38a478c05e84f7fe563a1c9e980a0cebc8e4d02 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:22:21 +0000 Subject: [PATCH 24/48] chore(ci): pin actions/setup-node to SHA for supply-chain consistency --- .github/workflows/upload_to_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 9dce018d6903..95477ccf01fc 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -51,7 +51,7 @@ jobs: uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22' From c57709a3d68e7972bbc7180a1d6811f5f38546d1 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:28:21 +0000 Subject: [PATCH 25/48] feat: wire ensure_dependency into TUI and browser tool call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: missing node → hard exit; missing browser → FileNotFoundError. After: both try ensure_dependency() first, which prompts interactively and delegates installation to install.sh --ensure. ripgrep and ffmpeg already degrade gracefully (grep fallback, skip conversion) so they don't need wiring. Also documents the design rationale in dep_ensure.py: detection and prompting live in Python (portable, instant, UX-integrated); only the actual installation delegates to install.sh (1900 lines of battle-tested OS/package-manager logic). --- hermes_cli/dep_ensure.py | 14 ++++++++++++-- hermes_cli/main.py | 7 +++++++ tools/browser_tool.py | 18 +++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 03ddd80ef847..c06fc6db80a8 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -1,7 +1,17 @@ """Lazy dependency bootstrapper for non-Python runtime deps. -Wraps install.sh --ensure to install node, browser, ripgrep, ffmpeg -on first use. Prompts interactively unless told not to. +Detection and prompting live here in Python — not in install.sh — because: + 1. shutil.which() works on every platform; install.sh needs bash. + 2. Detection is instant; spawning bash for a "is node installed?" check is waste. + 3. Python controls the UX (rich prompts, non-interactive fallback, TTY detection). + +install.sh is still the *installation* backend because it has 1900 lines of +battle-tested OS detection and package-manager logic (apt/brew/pacman/dnf/ +zypper/Termux/…). Reimplementing that in Python would be huge duplication. + +Deps that degrade gracefully (ripgrep → grep fallback, ffmpeg → skip conversion) +don't need ensure_dependency wired in — only hard-fail sites do (TUI needs node, +browser tool needs agent-browser). """ from __future__ import annotations diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bb372c396f17..0b5e79fe9d9b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1042,6 +1042,13 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): return env_node path = shutil.which(bin) + if not path and bin == "node": + try: + from hermes_cli.dep_ensure import ensure_dependency + if ensure_dependency("node"): + path = shutil.which("node") + except Exception: + pass if not path: print(f"{bin} not found — install Node.js to use the TUI.") sys.exit(1) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index c01d25a6f0b3..b3eb24ee0441 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1703,7 +1703,23 @@ def _find_agent_browser() -> str: _agent_browser_resolved = True return _cached_agent_browser - # Nothing found — cache the failure so subsequent calls don't re-scan. + # Nothing found — try lazy installation before giving up. + try: + from hermes_cli.dep_ensure import ensure_dependency + if ensure_dependency("browser"): + recheck = shutil.which("agent-browser") + if not recheck and extended_path: + recheck = shutil.which("agent-browser", path=extended_path) + if not recheck: + hermes_nm = str(get_hermes_home() / "node_modules" / ".bin") + recheck = shutil.which("agent-browser", path=hermes_nm) + if recheck: + _cached_agent_browser = recheck + _agent_browser_resolved = True + return recheck + except Exception: + pass + _agent_browser_resolved = True raise FileNotFoundError( "agent-browser CLI not found. Install it with: " From b1edf3dfc8948b5ff93f42d26395fa6f30393d9f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:33:35 +0000 Subject: [PATCH 26/48] chore: gitignore hermes_cli/scripts/ (bundled at wheel build time) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6ae86265a60c..37b1f602cc95 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ mini-swe-agent/ result website/static/api/skills-index.json models-dev-upstream/ +hermes_cli/tui_dist/* +hermes_cli/scripts/ +docs/superpowers/* \ No newline at end of file From 99b81cd54b99d4c66812b1d076e593f566432065 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:43:20 +0000 Subject: [PATCH 27/48] feat: add `hermes postinstall` command for pip users One-shot bootstrap that installs non-Python deps (node, browser, ripgrep, ffmpeg) via ensure_dependency(), then runs setup if no provider is configured. Closes the gap between `pip install` and the full user-facing experience. Also fixes 3 pre-existing test regressions caused by earlier commits: - test_recommended_update_command: mock detect_install_method for git env - test_check_for_updates_no_git_dir: now falls back to PyPI, not None - test_plist_path_includes_node_modules_bin: skip when dir absent --- hermes_cli/main.py | 31 ++++++++++++++++++- tests/hermes_cli/test_managed_installs.py | 3 +- tests/hermes_cli/test_update_check.py | 7 +++-- .../hermes_cli/test_update_gateway_restart.py | 5 ++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0b5e79fe9d9b..121b77b0f912 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1713,6 +1713,24 @@ def cmd_setup(args): run_setup_wizard(args) +def cmd_postinstall(args): + """One-shot bootstrap for pip users: install non-Python deps + run setup.""" + from hermes_cli.dep_ensure import ensure_dependency + + print("⚕ Hermes post-install bootstrap") + print() + + for dep in ("node", "browser", "ripgrep", "ffmpeg"): + ensure_dependency(dep) + + if not _has_any_provider_configured(): + print() + cmd_setup(args) + else: + print() + print("✓ Post-install complete.") + + def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") @@ -9583,7 +9601,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", - "model", "pairing", "plugins", "profile", "proxy", "sessions", "setup", + "model", "pairing", "plugins", "postinstall", "profile", "proxy", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "chat", # Help-ish invocations — plugin commands not being listed in @@ -10022,6 +10040,17 @@ def main(): ) setup_parser.set_defaults(func=cmd_setup) + # ========================================================================= + # postinstall command + # ========================================================================= + postinstall_parser = subparsers.add_parser( + "postinstall", + help="Bootstrap non-Python deps for pip installs (node, browser, ripgrep, ffmpeg)", + description="One-shot post-install for pip users. Installs system " + "dependencies that pip cannot provide, then runs setup if needed.", + ) + postinstall_parser.set_defaults(func=cmd_postinstall) + # ========================================================================= # whatsapp command # ========================================================================= diff --git a/tests/hermes_cli/test_managed_installs.py b/tests/hermes_cli/test_managed_installs.py index c6b5d792ce06..d2cf2947c6dc 100644 --- a/tests/hermes_cli/test_managed_installs.py +++ b/tests/hermes_cli/test_managed_installs.py @@ -29,7 +29,8 @@ def test_format_managed_message_homebrew(monkeypatch): def test_recommended_update_command_defaults_to_hermes_update(monkeypatch): monkeypatch.delenv("HERMES_MANAGED", raising=False) - assert recommended_update_command() == "hermes update" + with patch("hermes_cli.config.detect_install_method", return_value="git"): + assert recommended_update_command() == "hermes update" def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys): diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 2bdc9b246215..92cd2d2e14cc 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -59,7 +59,7 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch): def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): - """Returns None when .git directory doesn't exist anywhere.""" + """Falls back to PyPI check when .git directory doesn't exist anywhere.""" import hermes_cli.banner as banner # Create a fake banner.py so the fallback path also has no .git @@ -70,8 +70,9 @@ def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): monkeypatch.setattr(banner, "__file__", str(fake_banner)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) with patch("hermes_cli.banner.subprocess.run") as mock_run: - result = banner.check_for_updates() - assert result is None + with patch("hermes_cli.banner._check_via_pypi", return_value=0): + result = banner.check_for_updates() + assert result == 0 mock_run.assert_not_called() diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 34c878eca790..b53b1463624a 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -178,8 +178,11 @@ class TestLaunchdPlistPath: raise AssertionError("PATH key not found in plist") def test_plist_path_includes_node_modules_bin(self): + node_bin_dir = gateway_cli.PROJECT_ROOT / "node_modules" / ".bin" + if not node_bin_dir.is_dir(): + pytest.skip("node_modules/.bin not present in this checkout") plist = gateway_cli.generate_launchd_plist() - node_bin = str(gateway_cli.PROJECT_ROOT / "node_modules" / ".bin") + node_bin = str(node_bin_dir) lines = plist.splitlines() for i, line in enumerate(lines): if "PATH" in line.strip(): From 164a77dec9b74955c17401e9cf79f5470960b015 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:45:46 +0000 Subject: [PATCH 28/48] docs: add pip install path to installation, quickstart, updating, and CLI reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document pip install hermes-agent as a first-class install option. Clarify that PyPI releases track tagged versions (major/minor), not every commit on main — git installer is for bleeding-edge. --- website/docs/getting-started/installation.md | 32 ++++++++++++++++++-- website/docs/getting-started/quickstart.md | 10 +++++- website/docs/getting-started/updating.md | 30 ++++++++++++++++-- website/docs/reference/cli-commands.md | 4 ++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index c8db40a9137b..14bd95151c12 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -10,7 +10,30 @@ Get Hermes Agent up and running in under two minutes with the one-line installer ## Quick Install -### Linux / macOS / WSL2 +### pip (recommended for most users) + +```bash +pip install hermes-agent +``` + +This gives you the full Hermes Agent — CLI, web dashboard, and TUI — with zero external dependencies for core usage. Node.js, browser engines, and other optional tools are bootstrapped lazily on first use (e.g. when you run `hermes --tui` or use browser tools). + +PyPI releases track **tagged versions** (major and minor releases), not every commit on `main`. If you want bleeding-edge changes as they land, use the git install below. + +After installing, run: + +```bash +hermes setup # interactive wizard — configures your LLM provider and API key +hermes # start chatting +``` + +:::tip +If you have [uv](https://docs.astral.sh/uv/) installed, `uv pip install hermes-agent` is faster. +::: + +### One-Line Installer (Linux / macOS / WSL2) + +For a git-based install that tracks `main` and gives you the latest changes immediately: ```bash curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash @@ -80,7 +103,8 @@ Where the installer puts things depends on whether you're installing as a normal | Installer | Code lives at | `hermes` binary | Data directory | |---|---|---|---| -| Per-user (normal) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` (symlink) | `~/.hermes/` | +| pip install | Python site-packages | `~/.local/bin/hermes` (console_scripts) | `~/.hermes/` | +| Per-user (git installer) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` (symlink) | `~/.hermes/` | | Root-mode (`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/` (or `$HERMES_HOME`) | The root-mode **FHS layout** (`/usr/local/lib/…`, `/usr/local/bin/hermes`) matches where other system-wide developer tools land on Linux. It's useful for shared-machine deployments where one system install should serve every user. Per-user config (auth, skills, sessions) still lives under each user's `~/.hermes/` or explicit `HERMES_HOME`. @@ -108,7 +132,9 @@ hermes setup # Or run the full setup wizard to configure everything at ## Prerequisites -The only prerequisite is **Git**. The installer automatically handles everything else: +**pip install:** No prerequisites beyond Python 3.11+. Everything else is handled automatically. + +**Git installer:** The only prerequisite is **Git**. The installer automatically handles everything else: - **uv** (fast Python package manager) - **Python 3.11** (via uv, no sudo needed) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f5a089ee7240..341618c614ce 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -48,7 +48,15 @@ Pick the row that matches your goal: ## 1. Install Hermes Agent -Run the one-line installer: +**Option A — pip (simplest):** + +```bash +pip install hermes-agent +``` + +PyPI releases track tagged versions (major/minor releases), not every commit on `main`. For bleeding-edge, use Option B. + +**Option B — git installer (tracks main branch):** ```bash # Linux / macOS / WSL2 / Android (Termux) diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index aa2a426db998..83b3eb3221cc 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -8,19 +8,36 @@ description: "How to update Hermes Agent to the latest version or uninstall it" ## Updating +### Git installs + Update to the latest version with a single command: ```bash hermes update ``` -This pulls the latest code, updates dependencies, and prompts you to configure any new options that were added since your last update. +This pulls the latest code from `main`, updates dependencies, and prompts you to configure any new options that were added since your last update. + +### pip installs + +PyPI releases track **tagged versions** (major and minor releases), not every commit on `main`. Check for updates and upgrade with: + +```bash +hermes update --check # see if a newer release is on PyPI +hermes update # runs pip install --upgrade hermes-agent +``` + +Or manually: + +```bash +pip install --upgrade hermes-agent # or: uv pip install --upgrade hermes-agent +``` :::tip `hermes update` automatically detects new configuration options and prompts you to add them. If you skipped that prompt, you can manually run `hermes config check` to see missing options, then `hermes config migrate` to interactively add them. ::: -### What happens during an update +### What happens during an update (git installs) When you run `hermes update`, the following steps occur: @@ -189,12 +206,21 @@ See [Nix Setup](./nix-setup.md) for more details. ## Uninstalling +### Git installs + ```bash hermes uninstall ``` The uninstaller gives you the option to keep your configuration files (`~/.hermes/`) for a future reinstall. +### pip installs + +```bash +pip uninstall hermes-agent +rm -rf ~/.hermes # Optional — keep if you plan to reinstall +``` + ### Manual Uninstall ```bash diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index aa12f431b62e..3b5b7d2e925a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -76,7 +76,7 @@ hermes [global-options] [subcommand/options] | `hermes profile` | Manage profiles — multiple isolated Hermes instances. | | `hermes completion` | Print shell completion scripts (bash/zsh/fish). | | `hermes version` | Show version information. | -| `hermes update` | Pull latest code and reinstall dependencies. `--check` prints commit diff without pulling; `--backup` takes a pre-pull `HERMES_HOME` snapshot. | +| `hermes update` | Pull latest code and reinstall dependencies (git installs), or check PyPI and `pip install --upgrade` (pip installs). `--check` previews without installing; `--backup` takes a pre-pull `HERMES_HOME` snapshot. | | `hermes uninstall` | Remove Hermes from the system. | ## `hermes chat` @@ -1188,6 +1188,8 @@ hermes update [--check] [--backup] [--restart-gateway] Pulls the latest `hermes-agent` code and reinstalls dependencies in your venv, then re-runs the post-install hooks (MCP servers, skills sync, completion install). Safe to run on a live install. +**pip installs:** `hermes update` detects pip-based installations automatically — it queries PyPI for the latest release and runs `pip install --upgrade hermes-agent` instead of `git pull`. PyPI releases track tagged versions (major/minor releases), not every commit on `main`. Use `--check` to see if a newer PyPI release is available without installing. + | Option | Description | |--------|-------------| | `--check` | Print the current commit and the latest `origin/main` commit side by side, and exit 0 if in sync or 1 if behind. Does not pull, install, or restart anything. | From 47c0efe1c08ba6f0a70d07b7f353e1ad71e69678 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:47:43 +0000 Subject: [PATCH 29/48] refactor: DRY cleanup from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dep_ensure.py: use get_hermes_home() instead of hand-rolled env var - dep_ensure.py: add "chrome" to browser name list (was inconsistent with browser_tool.py) - main.py _cmd_update_check: use detect_install_method() directly instead of redundant .git check - main.py _cmd_update_pip: build command list directly instead of fragile split() on display string - banner.py: rename _check_via_pypi → check_via_pypi (cross-module public API) --- hermes_cli/banner.py | 4 +-- hermes_cli/dep_ensure.py | 6 ++-- hermes_cli/main.py | 39 ++++++++++++---------- tests/hermes_cli/test_banner_pip_update.py | 24 ++++++------- tests/hermes_cli/test_update_check.py | 2 +- 5 files changed, 39 insertions(+), 36 deletions(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 077ee41f0a27..ef592beb7fdf 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -199,7 +199,7 @@ def _fetch_pypi_latest(package: str = "hermes-agent") -> Optional[str]: return None -def _check_via_pypi() -> Optional[int]: +def check_via_pypi() -> Optional[int]: """Compare installed version against PyPI latest. Returns 0 if up-to-date, 1 if behind, None on failure. @@ -255,7 +255,7 @@ def check_for_updates() -> Optional[int]: if not (repo_dir / ".git").exists(): repo_dir = hermes_home / "hermes-agent" if not (repo_dir / ".git").exists(): - behind = _check_via_pypi() + behind = check_via_pypi() else: behind = _check_via_local_git(repo_dir) diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index c06fc6db80a8..3312726c36d1 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -41,15 +41,15 @@ _DEP_DESCRIPTIONS = { def _has_system_browser() -> bool: - for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"): + for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"): if shutil.which(name): return True return False def _has_hermes_agent_browser() -> bool: - hermes_home = os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")) - return (Path(hermes_home) / "node_modules" / ".bin" / "agent-browser").is_file() + from hermes_constants import get_hermes_home + return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file() def _find_install_script( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 121b77b0f912..41c4a23f9328 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7419,21 +7419,24 @@ def _finalize_update_output(state): def _cmd_update_check(): """Implement ``hermes update --check``: fetch and report without installing.""" + from hermes_cli.config import detect_install_method + method = detect_install_method(PROJECT_ROOT) + if method == "pip": + from hermes_cli.config import recommended_update_command + from hermes_cli.banner import check_via_pypi + result = check_via_pypi() + if result is None: + print("✗ Could not reach PyPI to check for updates.") + sys.exit(1) + elif result == 0: + print("✓ Already up to date.") + else: + print("⚕ Update available on PyPI.") + print(f" Run '{recommended_update_command()}' to install.") + return + git_dir = PROJECT_ROOT / ".git" if not git_dir.exists(): - from hermes_cli.config import detect_install_method, recommended_update_command - if detect_install_method(PROJECT_ROOT) == "pip": - from hermes_cli.banner import _check_via_pypi - result = _check_via_pypi() - if result is None: - print("✗ Could not reach PyPI to check for updates.") - sys.exit(1) - elif result == 0: - print("✓ Already up to date.") - else: - print(f"⚕ Update available on PyPI.") - print(f" Run '{recommended_update_command()}' to install.") - return print("✗ Not a git repository — cannot check for updates.") sys.exit(1) @@ -7712,15 +7715,15 @@ def cmd_update(args): def _cmd_update_pip(args): """Update Hermes via pip (for PyPI installs).""" from hermes_cli import __version__ - from hermes_cli.config import recommended_update_command_for_method print(f"→ Current version: {__version__}") print("→ Checking PyPI for updates...") - cmd_str = recommended_update_command_for_method("pip") - cmd = cmd_str.split() - if cmd[0] == "pip": - cmd = [sys.executable, "-m", "pip"] + cmd[1:] + uv = shutil.which("uv") + if uv: + cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] + else: + cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] print(f"→ Running: {' '.join(cmd)}") result = subprocess.run(cmd) diff --git a/tests/hermes_cli/test_banner_pip_update.py b/tests/hermes_cli/test_banner_pip_update.py index a0e9266f6987..205c97488a90 100644 --- a/tests/hermes_cli/test_banner_pip_update.py +++ b/tests/hermes_cli/test_banner_pip_update.py @@ -1,29 +1,29 @@ from unittest.mock import patch -def test_check_via_pypi_detects_update(): - """_check_via_pypi returns 1 when PyPI has newer version.""" - from hermes_cli.banner import _check_via_pypi +def testcheck_via_pypi_detects_update(): + """check_via_pypi returns 1 when PyPI has newer version.""" + from hermes_cli.banner import check_via_pypi with patch("hermes_cli.banner.VERSION", "0.12.0"): with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): - result = _check_via_pypi() + result = check_via_pypi() assert result == 1 -def test_check_via_pypi_up_to_date(): - """_check_via_pypi returns 0 when versions match.""" - from hermes_cli.banner import _check_via_pypi +def testcheck_via_pypi_up_to_date(): + """check_via_pypi returns 0 when versions match.""" + from hermes_cli.banner import check_via_pypi with patch("hermes_cli.banner.VERSION", "0.13.0"): with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): - result = _check_via_pypi() + result = check_via_pypi() assert result == 0 -def test_check_via_pypi_network_failure(): - """_check_via_pypi returns None on network error.""" - from hermes_cli.banner import _check_via_pypi +def testcheck_via_pypi_network_failure(): + """check_via_pypi returns None on network error.""" + from hermes_cli.banner import check_via_pypi with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None): - result = _check_via_pypi() + result = check_via_pypi() assert result is None diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 92cd2d2e14cc..8a68d6a178d0 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -70,7 +70,7 @@ def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): monkeypatch.setattr(banner, "__file__", str(fake_banner)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) with patch("hermes_cli.banner.subprocess.run") as mock_run: - with patch("hermes_cli.banner._check_via_pypi", return_value=0): + with patch("hermes_cli.banner.check_via_pypi", return_value=0): result = banner.check_for_updates() assert result == 0 mock_run.assert_not_called() From a480d345e63b114e9de1e9ceed746b7b9e21f0cb Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 15 May 2026 13:49:58 +0000 Subject: [PATCH 30/48] docs: add hermes postinstall to installation + quickstart, fix update --check description - installation.md: add tip about `hermes postinstall` for upfront dep install - quickstart.md: show `hermes postinstall` in pip install flow - updating.md: fix --check description to mention PyPI path for pip installs --- website/docs/getting-started/installation.md | 4 ++++ website/docs/getting-started/quickstart.md | 1 + website/docs/getting-started/updating.md | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 14bd95151c12..a88f4c8bd1cc 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -27,6 +27,10 @@ hermes setup # interactive wizard — configures your LLM provider and API key hermes # start chatting ``` +:::tip Optional: install everything upfront +`hermes postinstall` installs Node.js, browser engines, ripgrep, and ffmpeg in one shot — then runs the setup wizard. Use this if you want the full experience (TUI, browser tools, voice) without waiting for lazy installs on first use. +::: + :::tip If you have [uv](https://docs.astral.sh/uv/) installed, `uv pip install hermes-agent` is faster. ::: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 341618c614ce..80eaf3589ca2 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -52,6 +52,7 @@ Pick the row that matches your goal: ```bash pip install hermes-agent +hermes postinstall # optional: installs Node.js, browser, ripgrep, ffmpeg + runs setup ``` PyPI releases track tagged versions (major/minor releases), not every commit on `main`. For bleeding-edge, use Option B. diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 83b3eb3221cc..d4ced41a4d71 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -49,7 +49,7 @@ When you run `hermes update`, the following steps occur: ### Preview-only: `hermes update --check` -Want to know if you're behind `origin/main` before actually pulling? Run `hermes update --check` — it fetches, prints your local commit and the latest remote commit side-by-side, and exits `0` if in sync or `1` if behind. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update". +Want to know if an update is available before pulling? Run `hermes update --check` — for git installs it fetches and compares commits against `origin/main`; for pip installs it queries PyPI for the latest release. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update". ### Full pre-update backup: `--backup` From 233d4170cf7b6421939d4ae2d7adc8f3466c347f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:45:59 -0700 Subject: [PATCH 31/48] docs(xai): link OAuth-over-SSH guide from xAI provider surfaces (#26610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #26592. The new docs/guides/oauth-over-ssh.md page was linked from the two SSH-specific sections of the xAI Grok OAuth guide but was missing from the surfaces a user is more likely to hit first: - guides/xai-grok-oauth.md 'See Also' — add the SSH guide at the top with a short qualifier so remote users notice it before clicking through. - integrations/providers.md xAI Grok OAuth callout — append the SSH guide link alongside the existing xAI OAuth guide link. - user-guide/configuration.md xai-oauth tip — same. Docs build: zero warnings on touched files. --- website/docs/guides/xai-grok-oauth.md | 1 + website/docs/integrations/providers.md | 2 +- website/docs/user-guide/configuration.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 95167a2430cd..67d31c929ad9 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -221,6 +221,7 @@ This clears both the singleton OAuth entry in `auth.json` and any credential-poo ## See Also +- [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) — required reading if Hermes is on a different machine than your browser - [AI Providers reference](../integrations/providers.md) - [Environment Variables](../reference/environment-variables.md) - [Configuration](../user-guide/configuration.md) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index e7b2e5ab86d9..248d17c5fac7 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -331,7 +331,7 @@ When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoin xAI is wired through the Responses API (`codex_responses` transport) for automatic reasoning support on Grok 4 models — no `reasoning_effort` parameter needed, the server reasons by default. Set `XAI_API_KEY` in `~/.hermes/.env` and pick xAI in `hermes model`, or drop `grok` as a shortcut into `/model grok-4-1-fast-reasoning`. -SuperGrok subscribers can sign in with browser OAuth instead of using an API key — pick **xAI Grok OAuth (SuperGrok Subscription)** in `hermes model`, or run `hermes auth add xai-oauth`. The same OAuth bearer token is automatically reused by direct-to-xAI tools (TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md) for the full flow. +SuperGrok subscribers can sign in with browser OAuth instead of using an API key — pick **xAI Grok OAuth (SuperGrok Subscription)** in `hermes model`, or run `hermes auth add xai-oauth`. The same OAuth bearer token is automatically reused by direct-to-xAI tools (TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md) for the full flow — and if Hermes runs on a remote host, also see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md) for the required `ssh -L` tunnel. When using xAI as a provider (any base URL containing `x.ai`), Hermes automatically enables prompt caching by sending the `x-grok-conv-id` header with every API request. This routes requests to the same server within a conversation session, allowing xAI's infrastructure to reuse cached system prompts and conversation history. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d529c8af6879..77e5d74ad42e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -820,7 +820,7 @@ Available providers for auxiliary tasks: `auto`, `main`, plus any provider in th ::: :::tip xAI Grok OAuth -`xai-oauth` logs in via browser OAuth for SuperGrok subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok Subscription)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md). +`xai-oauth` logs in via browser OAuth for SuperGrok subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok Subscription)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md), and if Hermes is on a remote host see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md). ::: :::warning `"main"` is for auxiliary tasks only From 887ba1fb03d78f8922b32e7d17dfb1e0998d9315 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:47:30 -0700 Subject: [PATCH 32/48] ci: reject PRs with no common ancestor on main (#26611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches the failure mode that produced #25045: a contributor PR whose branch had been disconnected from main's history (likely an accidental 'git checkout --orphan' or '.git/' re-init). GitHub's merge UI does not refuse merges of unrelated histories, so the PR landed cleanly with its intended one-file change but its parent-less root commit (413990c94) got grafted into main as a second root. The merge resolution itself was correct — main's content won for every conflicting file — but ~1500 files' worth of git blame collapsed onto that single commit. Implementation: 'git merge-base origin/main HEAD' exits non-zero and prints nothing when the two commits share no ancestor. Check both conditions and fail with a clear message + recovery steps. Verified: against the historic state of PR #25045 (base 5d90386ba, head 1149e75db), 'git merge-base' returns empty with exit 1, so the new check would have rejected it. --- .github/workflows/history-check.yml | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/history-check.yml diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml new file mode 100644 index 000000000000..bd66f19404ec --- /dev/null +++ b/.github/workflows/history-check.yml @@ -0,0 +1,58 @@ +name: History Check + +# Rejects PRs whose branch has no common ancestor with main. +# +# In May 2026 PR #25045 was merged from a branch that had been disconnected +# from main's history (likely an accidental `git checkout --orphan` or +# `.git/` re-init). GitHub's merge UI does not refuse merges of unrelated +# histories, so the PR landed cleanly with the intended one-file change — +# but its parent-less root commit (413990c94) got grafted into main as a +# second root, and ~1500 files' worth of `git blame` history collapsed +# onto that single commit. +# +# This check catches the failure mode by requiring `git merge-base` between +# the PR head and main to be non-empty. + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + check-common-ancestor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history both sides for merge-base + + - name: Reject PRs with no common ancestor on main + run: | + # `git merge-base` exits non-zero AND prints nothing when the two + # commits share no ancestor. We check both conditions explicitly + # so the failure message is clear regardless of which signal fires + # first. + if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then + echo "" + echo "::error::This PR has no common ancestor with main." + echo "" + echo "Your branch's history is disconnected from main. Common causes:" + echo " - the branch was created with 'git checkout --orphan'" + echo " - '.git/' was re-initialized at some point during the work" + echo " - the branch was force-pushed from an unrelated repository" + echo "" + echo "Merging an unrelated-history PR grafts a parent-less root commit" + echo "into main and collapses git blame for every file in that snapshot." + echo "Reference: PR #25045 caused this and re-rooted blame on ~1500" + echo "files to a single orphan commit." + echo "" + echo "To fix, rebase your changes onto current main:" + echo " git fetch origin main" + echo " git checkout -b fix-branch origin/main" + echo " # re-apply your changes (cherry-pick, copy files, etc.)" + echo " git push -f origin fix-branch" + exit 1 + fi + echo "::notice::Common ancestor with main: $BASE" From 42070ecefb9e9da3adec6d536d130d9dc3b82560 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:58:23 -0700 Subject: [PATCH 33/48] feat(skills/notion): overhaul for Notion Developer Platform (May 2026) (#26612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills/notion): overhaul for Notion Developer Platform (May 2026) Notion shipped its Developer Platform on May 13, 2026: ntn CLI, Workers, Markdown API, bidirectional webhooks, agent tools. The existing skill only covered curl + integration token CRUD, so it didn't surface any of the new ergonomics — particularly the /markdown endpoints (much easier for agents to consume) and the ntn CLI for headless API + Workers management. This rewrite (v1.0.0 -> v2.0.0): - Splits setup into Path A (HTTP, cross-platform incl. Windows), Path B (ntn CLI on macOS/Linux, with NOTION_API_TOKEN env var for headless), and Path C (Windows fallback — HTTP API or WSL2; native ntn is 'coming soon'). - Keeps the full curl reference (still the only Windows-compatible path). - Adds /markdown endpoints — GET and PATCH page-as-markdown, plus POST /v1/pages with a markdown body param. Agent-friendly, no CLI required. - Adds ntn CLI cheat sheet for raw API shorthand, file uploads, and workspace flags. - Adds Notion Workers section: scaffold, tool/webhook capability shapes, lifecycle commands. Gated on Business/Enterprise plans + macOS/Linux. - Adds Notion-flavored Markdown reference (callouts, toggles, columns, mentions, colors) for the /markdown endpoints. - Adds a 'choose the right path' decision table at the bottom. - Notes the new efficient Notion MCP server as an optional wiring path. Auto-generated docs page regenerated via website/scripts/generate-skill-docs.py. * docs(skills-catalog): update notion description for v2.0.0 --- skills/productivity/notion/SKILL.md | 356 ++++++++++++++++-- website/docs/reference/skills-catalog.md | 2 +- .../productivity/productivity-notion.md | 354 +++++++++++++++-- 3 files changed, 632 insertions(+), 80 deletions(-) diff --git a/skills/productivity/notion/SKILL.md b/skills/productivity/notion/SKILL.md index b645c088f281..83222ffd9384 100644 --- a/skills/productivity/notion/SKILL.md +++ b/skills/productivity/notion/SKILL.md @@ -1,35 +1,158 @@ --- name: notion -description: "Notion API via curl: pages, databases, blocks, search." -version: 1.0.0 +description: "Notion API + ntn CLI: pages, databases, markdown, Workers." +version: 2.0.0 author: community license: MIT platforms: [linux, macos, windows] -metadata: - hermes: - tags: [Notion, Productivity, Notes, Database, API] - homepage: https://developers.notion.com prerequisites: env_vars: [NOTION_API_KEY] +metadata: + hermes: + tags: [Notion, Productivity, Notes, Database, API, CLI, Workers] + homepage: https://developers.notion.com --- -# Notion API +# Notion -Use the Notion API via curl to create, read, update pages, databases (data sources), and blocks. No extra tools needed — just curl and a Notion API key. +Talk to Notion two ways. Same integration token works for both — pick by what's available. -## Prerequisites +◆ **`ntn` CLI** — Notion's official CLI. Shorter syntax, one-line file uploads, required for Workers. macOS + Linux only as of May 2026 (Windows support "coming soon"). **Default when installed.** +◆ **HTTP + curl** — works everywhere including Windows. **Default fallback** when `ntn` isn't installed. + +## Setup + +### 1. Get an integration token (required for both paths) 1. Create an integration at https://notion.so/my-integrations 2. Copy the API key (starts with `ntn_` or `secret_`) -3. Store it in `~/.hermes/.env`: +3. Store in `~/.hermes/.env`: ``` NOTION_API_KEY=ntn_your_key_here ``` -4. **Important:** Share target pages/databases with your integration in Notion (click "..." → "Connect to" → your integration name) +4. **Share target pages/databases with the integration** in Notion: page menu `...` → `Connect to` → your integration name. Without this, the API returns 404 for that page even though it exists. + +### 2. Install `ntn` (preferred path on macOS / Linux) + +```bash +# Recommended +curl -fsSL https://ntn.dev | bash + +# Or via npm (needs Node 22+, npm 10+) +npm install --global ntn + +ntn --version # verify +``` + +**Skip `ntn login` — use the integration token instead.** This works headlessly, no browser needed: +```bash +export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN +export NOTION_KEYRING=0 # don't try to use the OS keychain +``` + +Add those exports to your shell profile (or to `~/.hermes/.env`) so every session inherits them. + +### 3. Choose path at runtime + +```bash +if command -v ntn >/dev/null 2>&1; then + # use ntn +else + # fall back to curl +fi +``` + +Windows users: skip step 2 entirely until native `ntn` ships — Path B works fine. If you want CLI ergonomics now, install `ntn` inside WSL2. ## API Basics -All requests use this pattern: +`Notion-Version: 2025-09-03` is required on all HTTP requests. `ntn` handles this for you. In this version, what users call "databases" are called **data sources** in the API. + +## Path A — `ntn` CLI (preferred, macOS / Linux) + +### Raw API calls (shorthand for curl) +```bash +ntn api v1/users # GET +ntn api v1/pages parent[page_id]=abc123 \ # POST with inline body + properties[title][0][text][content]="Notes" +ntn api v1/pages/abc123 -X PATCH archived:=true # PATCH; := is non-string (bool/num/null) +``` + +Syntax notes: +- `key=value` — string fields +- `key[nested]=value` — nested object fields +- `key:=value` — typed assignment (booleans, numbers, null, arrays) + +### Search +```bash +ntn api v1/search query="page title" +``` + +### Read page metadata +```bash +ntn api v1/pages/{page_id} +``` + +### Read page as Markdown (agent-friendly) +```bash +ntn api v1/pages/{page_id}/markdown +``` + +### Read page content as blocks +```bash +ntn api v1/blocks/{page_id}/children +``` + +### Create page from Markdown +```bash +ntn api v1/pages \ + parent[page_id]=xxx \ + properties[title][0][text][content]="Notes from meeting" \ + markdown="# Agenda + +- Q3 roadmap +- Hiring" +``` + +### Patch a page with Markdown +```bash +ntn api v1/pages/{page_id}/markdown -X PATCH \ + markdown="## Update + +Shipped the prototype." +``` + +### Query a database (data source) +```bash +ntn api v1/data_sources/{data_source_id}/query -X POST \ + filter[property]=Status filter[select][equals]=Active +``` + +For complex queries with `sorts`, multiple filter clauses, or compound logic, pipe JSON in: +```bash +echo '{"filter": {"property": "Status", "select": {"equals": "Active"}}, "sorts": [{"property": "Date", "direction": "descending"}]}' | \ + ntn api v1/data_sources/{data_source_id}/query -X POST --json - +``` + +### File uploads (one-liner — biggest CLI win) +```bash +ntn files create < photo.png +ntn files create --external-url https://example.com/photo.png +ntn files list +``` + +Compare to the 3-step HTTP flow (create upload → PUT bytes → reference). + +### Useful env vars +| Var | Effect | +|---|---| +| `NOTION_API_TOKEN` | Auth token (overrides keychain) — set this to your integration token | +| `NOTION_KEYRING=0` | File-based creds at `~/.config/notion/auth.json` instead of OS keychain | +| `NOTION_WORKSPACE_ID` | Skip the workspace picker prompt | + +## Path B — HTTP + curl (cross-platform, default on Windows) + +All requests share this pattern: ```bash curl -s -X GET "https://api.notion.com/v1/..." \ @@ -38,12 +161,9 @@ curl -s -X GET "https://api.notion.com/v1/..." \ -H "Content-Type: application/json" ``` -The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API. - -## Common Operations +On Windows the `curl` shipped with Windows 10+ works as-is. PowerShell users can also use `Invoke-RestMethod`. ### Search - ```bash curl -s -X POST "https://api.notion.com/v1/search" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -52,24 +172,56 @@ curl -s -X POST "https://api.notion.com/v1/search" \ -d '{"query": "page title"}' ``` -### Get Page - +### Read page metadata ```bash curl -s "https://api.notion.com/v1/pages/{page_id}" \ -H "Authorization: Bearer $NOTION_API_KEY" \ -H "Notion-Version: 2025-09-03" ``` -### Get Page Content (blocks) +### Read page as Markdown (agent-friendly) +Easier to feed to a model than block JSON. + +```bash +curl -s "https://api.notion.com/v1/pages/{page_id}/markdown" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" +``` + +### Read page content as blocks (when you need structure) ```bash curl -s "https://api.notion.com/v1/blocks/{page_id}/children" \ -H "Authorization: Bearer $NOTION_API_KEY" \ -H "Notion-Version: 2025-09-03" ``` -### Create Page in a Database +### Create page from Markdown +`POST /v1/pages` accepts a `markdown` body param. + +```bash +curl -s -X POST "https://api.notion.com/v1/pages" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "parent": {"page_id": "xxx"}, + "properties": {"title": [{"text": {"content": "Notes from meeting"}}]}, + "markdown": "# Agenda\n\n- Q3 roadmap\n- Hiring\n\n## Decisions\n- Ship MVP Friday" + }' +``` + +### Patch a page with Markdown +```bash +curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}/markdown" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"markdown": "## Update\n\nShipped the prototype."}' +``` + +### Create page in a database (typed properties) ```bash curl -s -X POST "https://api.notion.com/v1/pages" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -84,8 +236,7 @@ curl -s -X POST "https://api.notion.com/v1/pages" \ }' ``` -### Query a Database - +### Query a database (data source) ```bash curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -97,8 +248,7 @@ curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" }' ``` -### Create a Database - +### Create a database ```bash curl -s -X POST "https://api.notion.com/v1/data_sources" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -115,8 +265,7 @@ curl -s -X POST "https://api.notion.com/v1/data_sources" \ }' ``` -### Update Page Properties - +### Update page properties ```bash curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -125,8 +274,7 @@ curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \ -d '{"properties": {"Status": {"select": {"name": "Done"}}}}' ``` -### Add Content to a Page - +### Append blocks to a page ```bash curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -139,6 +287,21 @@ curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \ }' ``` +### File uploads (3-step flow) +```bash +# 1. Create upload +curl -s -X POST "https://api.notion.com/v1/file_uploads" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"filename": "photo.png", "content_type": "image/png"}' + +# 2. PUT bytes to the upload_url returned above +curl -s -X PUT "{upload_url}" --data-binary @photo.png + +# 3. Reference {file_upload_id} in a page/block payload +``` + ## Property Types Common property formats for database items: @@ -154,19 +317,132 @@ Common property formats for database items: - **Email:** `{"email": "user@example.com"}` - **Relation:** `{"relation": [{"id": "page_id"}]}` -## Key Differences in API Version 2025-09-03 +## API Version 2025-09-03 — Databases vs Data Sources -- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval -- **Two IDs:** Each database has both a `database_id` and a `data_source_id` - - Use `database_id` when creating pages (`parent: {"database_id": "..."}`) - - Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`) -- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id` +- **Databases became data sources.** Use `/data_sources/` endpoints for queries and retrieval. +- **Two IDs per database:** `database_id` and `data_source_id`. + - `database_id` when creating pages: `parent: {"database_id": "..."}` + - `data_source_id` when querying: `POST /v1/data_sources/{id}/query` +- Search returns databases as `"object": "data_source"` with the `data_source_id` field. + +## Notion Workers (advanced, requires `ntn`) + +Workers are TypeScript programs Notion hosts for you. One worker can expose any combination of: +- **Syncs** — pull data from external APIs into a Notion database on a schedule (default 30 min). +- **Tools** — appear as callable tools inside Notion's Custom Agents. +- **Webhooks** — receive HTTP events from external services (GitHub, Stripe, etc.) and act in Notion. + +**Plan / platform gating:** +- CLI works on all plans. **Deploying Workers requires Business or Enterprise.** +- `ntn` is macOS/Linux only as of May 2026. Windows users need WSL2 or to wait for native support. +- Free through August 11, 2026; metered on Notion credits after. + +### Minimal Worker + +```bash +ntn workers new my-worker # scaffold +cd my-worker +# Edit src/index.ts +ntn workers deploy --name my-worker +``` + +`src/index.ts`: +```typescript +import { Worker } from "@notionhq/workers"; + +const worker = new Worker(); +export default worker; + +worker.tool("greet", { + title: "Greet a User", + description: "Returns a friendly greeting", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + execute: async ({ name }) => `Hello, ${name}!`, +}); +``` + +### Webhook capability + +```typescript +worker.webhook("onGithubPush", { + title: "GitHub Push Handler", + execute: async (events, { notion }) => { + for (const event of events) { + // event.body, event.rawBody (for signature verification), event.headers + console.log("got delivery", event.deliveryId); + } + }, +}); +``` + +After deploy: `ntn workers webhooks list` shows the URL Notion generates. Treat that URL as a secret — anyone with it can POST events unless you add signature verification. + +### Worker lifecycle commands + +```bash +ntn workers deploy +ntn workers list +ntn workers exec -d '{"name": "world"}' +ntn workers sync trigger # run a sync now +ntn workers sync pause +ntn workers env set GITHUB_WEBHOOK_SECRET=... +ntn workers runs list # recent invocations +ntn workers runs logs +ntn workers webhooks list +``` + +When asked to build a Worker, scaffold with `ntn workers new`, write the code in `src/index.ts`, set any secrets with `ntn workers env set`, and deploy. Notion's docs at https://developers.notion.com/workers cover the full API surface. + +## Notion-Flavored Markdown (used by `/markdown` endpoints) + +Standard CommonMark plus XML-like tags for Notion-specific blocks. Use **tabs** for indentation. + +**Blocks beyond CommonMark:** +``` + + Ship the MVP by **Friday**. + + +
+Toggle title + Children indented one tab +
+ + + Left side + Right side + + + +``` + +**Inline:** +- Mentions: ``, `Title`, `` +- Underline: `text` +- Color: `text` or block-level `{color="blue"}` on the first line +- Math: inline `$x^2$`, block `$$ ... $$` +- Citations: `[^https://example.com]` + +**Colors:** `gray brown orange yellow green blue purple pink red`, plus `*_bg` variants for backgrounds. + +Headings 5/6 collapse to H4. Multiple `>` lines render as separate quote blocks — use `
` inside a single `>` for multi-line quotes. + +## Choosing the Right Path + +| Task | mac / Linux | Windows | +|---|---|---| +| Read/write pages, search, query databases | `ntn api ...` | curl | +| Read a page for an agent to summarize | `ntn api v1/pages/{id}/markdown` | curl `/markdown` endpoint | +| Upload a file | `ntn files create < file` | 3-step HTTP flow | +| One-off API exploration | `ntn api ...` | curl | +| Build a sync / webhook / agent tool hosted by Notion | `ntn workers ...` | WSL2 + `ntn workers ...` | ## Notes -- Page/database IDs are UUIDs (with or without dashes) -- Rate limit: ~3 requests/second average -- The API cannot set database view filters — that's UI-only -- Use `is_inline: true` when creating data sources to embed them in pages -- Add `-s` flag to curl to suppress progress bars (cleaner output for Hermes) -- Pipe output through `jq` for readable JSON: `... | jq '.results[0].properties'` +- Page/database IDs are UUIDs (with or without dashes — both accepted). +- Rate limit: ~3 requests/second average. The CLI doesn't bypass this. +- The API cannot set database **view** filters — that's UI-only. +- Use `"is_inline": true` when creating data sources to embed them in a page. +- Always pass `-s` to curl to suppress progress bars (cleaner agent output). +- Pipe JSON through `jq` when reading: `... | jq '.results[0].properties'`. +- Notion also ships an MCP server now (`Notion MCP`, ~91% more token-efficient on DB ops than the previous version) — wire it via Hermes' MCP support if you want streaming Notion access from inside a session, but the paths above are enough for most one-shot tasks. diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 8adeb3dcf769..c5b205f521d6 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -144,7 +144,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg | [`linear`](/docs/user-guide/skills/bundled/productivity/productivity-linear) | Linear: manage issues, projects, teams via GraphQL + curl. | `productivity/linear` | | [`maps`](/docs/user-guide/skills/bundled/productivity/productivity-maps) | Geocode, POIs, routes, timezones via OpenStreetMap/OSRM. | `productivity/maps` | | [`nano-pdf`](/docs/user-guide/skills/bundled/productivity/productivity-nano-pdf) | Edit PDF text/typos/titles via nano-pdf CLI (NL prompts). | `productivity/nano-pdf` | -| [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API via curl: pages, databases, blocks, search. | `productivity/notion` | +| [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API + ntn CLI: pages, databases, markdown, Workers. | `productivity/notion` | | [`ocr-and-documents`](/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents) | Extract text from PDFs/scans (pymupdf, marker-pdf). | `productivity/ocr-and-documents` | | [`powerpoint`](/docs/user-guide/skills/bundled/productivity/productivity-powerpoint) | Create, read, edit .pptx decks, slides, notes, templates. | `productivity/powerpoint` | | [`teams-meeting-pipeline`](/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline) | Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions. | `productivity/teams-meeting-pipeline` | diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md index 7e8fab2f2baf..80487d6b88fa 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md @@ -1,14 +1,14 @@ --- -title: "Notion — Notion API via curl: pages, databases, blocks, search" +title: "Notion — Notion API + ntn CLI: pages, databases, markdown, Workers" sidebar_label: "Notion" -description: "Notion API via curl: pages, databases, blocks, search" +description: "Notion API + ntn CLI: pages, databases, markdown, Workers" --- {/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} # Notion -Notion API via curl: pages, databases, blocks, search. +Notion API + ntn CLI: pages, databases, markdown, Workers. ## Skill metadata @@ -16,11 +16,11 @@ Notion API via curl: pages, databases, blocks, search. |---|---| | Source | Bundled (installed by default) | | Path | `skills/productivity/notion` | -| Version | `1.0.0` | +| Version | `2.0.0` | | Author | community | | License | MIT | | Platforms | linux, macos, windows | -| Tags | `Notion`, `Productivity`, `Notes`, `Database`, `API` | +| Tags | `Notion`, `Productivity`, `Notes`, `Database`, `API`, `CLI`, `Workers` | ## Reference: full SKILL.md @@ -28,23 +28,146 @@ Notion API via curl: pages, databases, blocks, search. The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. ::: -# Notion API +# Notion -Use the Notion API via curl to create, read, update pages, databases (data sources), and blocks. No extra tools needed — just curl and a Notion API key. +Talk to Notion two ways. Same integration token works for both — pick by what's available. -## Prerequisites +◆ **`ntn` CLI** — Notion's official CLI. Shorter syntax, one-line file uploads, required for Workers. macOS + Linux only as of May 2026 (Windows support "coming soon"). **Default when installed.** +◆ **HTTP + curl** — works everywhere including Windows. **Default fallback** when `ntn` isn't installed. + +## Setup + +### 1. Get an integration token (required for both paths) 1. Create an integration at https://notion.so/my-integrations 2. Copy the API key (starts with `ntn_` or `secret_`) -3. Store it in `~/.hermes/.env`: +3. Store in `~/.hermes/.env`: ``` NOTION_API_KEY=ntn_your_key_here ``` -4. **Important:** Share target pages/databases with your integration in Notion (click "..." → "Connect to" → your integration name) +4. **Share target pages/databases with the integration** in Notion: page menu `...` → `Connect to` → your integration name. Without this, the API returns 404 for that page even though it exists. + +### 2. Install `ntn` (preferred path on macOS / Linux) + +```bash +# Recommended +curl -fsSL https://ntn.dev | bash + +# Or via npm (needs Node 22+, npm 10+) +npm install --global ntn + +ntn --version # verify +``` + +**Skip `ntn login` — use the integration token instead.** This works headlessly, no browser needed: +```bash +export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN +export NOTION_KEYRING=0 # don't try to use the OS keychain +``` + +Add those exports to your shell profile (or to `~/.hermes/.env`) so every session inherits them. + +### 3. Choose path at runtime + +```bash +if command -v ntn >/dev/null 2>&1; then + # use ntn +else + # fall back to curl +fi +``` + +Windows users: skip step 2 entirely until native `ntn` ships — Path B works fine. If you want CLI ergonomics now, install `ntn` inside WSL2. ## API Basics -All requests use this pattern: +`Notion-Version: 2025-09-03` is required on all HTTP requests. `ntn` handles this for you. In this version, what users call "databases" are called **data sources** in the API. + +## Path A — `ntn` CLI (preferred, macOS / Linux) + +### Raw API calls (shorthand for curl) +```bash +ntn api v1/users # GET +ntn api v1/pages parent[page_id]=abc123 \ # POST with inline body + properties[title][0][text][content]="Notes" +ntn api v1/pages/abc123 -X PATCH archived:=true # PATCH; := is non-string (bool/num/null) +``` + +Syntax notes: +- `key=value` — string fields +- `key[nested]=value` — nested object fields +- `key:=value` — typed assignment (booleans, numbers, null, arrays) + +### Search +```bash +ntn api v1/search query="page title" +``` + +### Read page metadata +```bash +ntn api v1/pages/{page_id} +``` + +### Read page as Markdown (agent-friendly) +```bash +ntn api v1/pages/{page_id}/markdown +``` + +### Read page content as blocks +```bash +ntn api v1/blocks/{page_id}/children +``` + +### Create page from Markdown +```bash +ntn api v1/pages \ + parent[page_id]=xxx \ + properties[title][0][text][content]="Notes from meeting" \ + markdown="# Agenda + +- Q3 roadmap +- Hiring" +``` + +### Patch a page with Markdown +```bash +ntn api v1/pages/{page_id}/markdown -X PATCH \ + markdown="## Update + +Shipped the prototype." +``` + +### Query a database (data source) +```bash +ntn api v1/data_sources/{data_source_id}/query -X POST \ + filter[property]=Status filter[select][equals]=Active +``` + +For complex queries with `sorts`, multiple filter clauses, or compound logic, pipe JSON in: +```bash +echo '{"filter": {"property": "Status", "select": {"equals": "Active"}}, "sorts": [{"property": "Date", "direction": "descending"}]}' | \ + ntn api v1/data_sources/{data_source_id}/query -X POST --json - +``` + +### File uploads (one-liner — biggest CLI win) +```bash +ntn files create < photo.png +ntn files create --external-url https://example.com/photo.png +ntn files list +``` + +Compare to the 3-step HTTP flow (create upload → PUT bytes → reference). + +### Useful env vars +| Var | Effect | +|---|---| +| `NOTION_API_TOKEN` | Auth token (overrides keychain) — set this to your integration token | +| `NOTION_KEYRING=0` | File-based creds at `~/.config/notion/auth.json` instead of OS keychain | +| `NOTION_WORKSPACE_ID` | Skip the workspace picker prompt | + +## Path B — HTTP + curl (cross-platform, default on Windows) + +All requests share this pattern: ```bash curl -s -X GET "https://api.notion.com/v1/..." \ @@ -53,12 +176,9 @@ curl -s -X GET "https://api.notion.com/v1/..." \ -H "Content-Type: application/json" ``` -The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API. - -## Common Operations +On Windows the `curl` shipped with Windows 10+ works as-is. PowerShell users can also use `Invoke-RestMethod`. ### Search - ```bash curl -s -X POST "https://api.notion.com/v1/search" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -67,24 +187,56 @@ curl -s -X POST "https://api.notion.com/v1/search" \ -d '{"query": "page title"}' ``` -### Get Page - +### Read page metadata ```bash curl -s "https://api.notion.com/v1/pages/{page_id}" \ -H "Authorization: Bearer $NOTION_API_KEY" \ -H "Notion-Version: 2025-09-03" ``` -### Get Page Content (blocks) +### Read page as Markdown (agent-friendly) +Easier to feed to a model than block JSON. + +```bash +curl -s "https://api.notion.com/v1/pages/{page_id}/markdown" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" +``` + +### Read page content as blocks (when you need structure) ```bash curl -s "https://api.notion.com/v1/blocks/{page_id}/children" \ -H "Authorization: Bearer $NOTION_API_KEY" \ -H "Notion-Version: 2025-09-03" ``` -### Create Page in a Database +### Create page from Markdown +`POST /v1/pages` accepts a `markdown` body param. + +```bash +curl -s -X POST "https://api.notion.com/v1/pages" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "parent": {"page_id": "xxx"}, + "properties": {"title": [{"text": {"content": "Notes from meeting"}}]}, + "markdown": "# Agenda\n\n- Q3 roadmap\n- Hiring\n\n## Decisions\n- Ship MVP Friday" + }' +``` + +### Patch a page with Markdown +```bash +curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}/markdown" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"markdown": "## Update\n\nShipped the prototype."}' +``` + +### Create page in a database (typed properties) ```bash curl -s -X POST "https://api.notion.com/v1/pages" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -99,8 +251,7 @@ curl -s -X POST "https://api.notion.com/v1/pages" \ }' ``` -### Query a Database - +### Query a database (data source) ```bash curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -112,8 +263,7 @@ curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" }' ``` -### Create a Database - +### Create a database ```bash curl -s -X POST "https://api.notion.com/v1/data_sources" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -130,8 +280,7 @@ curl -s -X POST "https://api.notion.com/v1/data_sources" \ }' ``` -### Update Page Properties - +### Update page properties ```bash curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -140,8 +289,7 @@ curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \ -d '{"properties": {"Status": {"select": {"name": "Done"}}}}' ``` -### Add Content to a Page - +### Append blocks to a page ```bash curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \ -H "Authorization: Bearer $NOTION_API_KEY" \ @@ -154,6 +302,21 @@ curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \ }' ``` +### File uploads (3-step flow) +```bash +# 1. Create upload +curl -s -X POST "https://api.notion.com/v1/file_uploads" \ + -H "Authorization: Bearer $NOTION_API_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"filename": "photo.png", "content_type": "image/png"}' + +# 2. PUT bytes to the upload_url returned above +curl -s -X PUT "{upload_url}" --data-binary @photo.png + +# 3. Reference {file_upload_id} in a page/block payload +``` + ## Property Types Common property formats for database items: @@ -169,19 +332,132 @@ Common property formats for database items: - **Email:** `{"email": "user@example.com"}` - **Relation:** `{"relation": [{"id": "page_id"}]}` -## Key Differences in API Version 2025-09-03 +## API Version 2025-09-03 — Databases vs Data Sources -- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval -- **Two IDs:** Each database has both a `database_id` and a `data_source_id` - - Use `database_id` when creating pages (`parent: {"database_id": "..."}`) - - Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`) -- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id` +- **Databases became data sources.** Use `/data_sources/` endpoints for queries and retrieval. +- **Two IDs per database:** `database_id` and `data_source_id`. + - `database_id` when creating pages: `parent: {"database_id": "..."}` + - `data_source_id` when querying: `POST /v1/data_sources/{id}/query` +- Search returns databases as `"object": "data_source"` with the `data_source_id` field. + +## Notion Workers (advanced, requires `ntn`) + +Workers are TypeScript programs Notion hosts for you. One worker can expose any combination of: +- **Syncs** — pull data from external APIs into a Notion database on a schedule (default 30 min). +- **Tools** — appear as callable tools inside Notion's Custom Agents. +- **Webhooks** — receive HTTP events from external services (GitHub, Stripe, etc.) and act in Notion. + +**Plan / platform gating:** +- CLI works on all plans. **Deploying Workers requires Business or Enterprise.** +- `ntn` is macOS/Linux only as of May 2026. Windows users need WSL2 or to wait for native support. +- Free through August 11, 2026; metered on Notion credits after. + +### Minimal Worker + +```bash +ntn workers new my-worker # scaffold +cd my-worker +# Edit src/index.ts +ntn workers deploy --name my-worker +``` + +`src/index.ts`: +```typescript +import { Worker } from "@notionhq/workers"; + +const worker = new Worker(); +export default worker; + +worker.tool("greet", { + title: "Greet a User", + description: "Returns a friendly greeting", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + execute: async ({ name }) => `Hello, ${name}!`, +}); +``` + +### Webhook capability + +```typescript +worker.webhook("onGithubPush", { + title: "GitHub Push Handler", + execute: async (events, { notion }) => { + for (const event of events) { + // event.body, event.rawBody (for signature verification), event.headers + console.log("got delivery", event.deliveryId); + } + }, +}); +``` + +After deploy: `ntn workers webhooks list` shows the URL Notion generates. Treat that URL as a secret — anyone with it can POST events unless you add signature verification. + +### Worker lifecycle commands + +```bash +ntn workers deploy +ntn workers list +ntn workers exec -d '{"name": "world"}' +ntn workers sync trigger # run a sync now +ntn workers sync pause +ntn workers env set GITHUB_WEBHOOK_SECRET=... +ntn workers runs list # recent invocations +ntn workers runs logs +ntn workers webhooks list +``` + +When asked to build a Worker, scaffold with `ntn workers new`, write the code in `src/index.ts`, set any secrets with `ntn workers env set`, and deploy. Notion's docs at https://developers.notion.com/workers cover the full API surface. + +## Notion-Flavored Markdown (used by `/markdown` endpoints) + +Standard CommonMark plus XML-like tags for Notion-specific blocks. Use **tabs** for indentation. + +**Blocks beyond CommonMark:** +``` + + Ship the MVP by **Friday**. + + +
+Toggle title + Children indented one tab +
+ + + Left side + Right side + + + +``` + +**Inline:** +- Mentions: ``, `Title`, `` +- Underline: `text` +- Color: `text` or block-level `{color="blue"}` on the first line +- Math: inline `$x^2$`, block `$$ ... $$` +- Citations: `[^https://example.com]` + +**Colors:** `gray brown orange yellow green blue purple pink red`, plus `*_bg` variants for backgrounds. + +Headings 5/6 collapse to H4. Multiple `>` lines render as separate quote blocks — use `
` inside a single `>` for multi-line quotes. + +## Choosing the Right Path + +| Task | mac / Linux | Windows | +|---|---|---| +| Read/write pages, search, query databases | `ntn api ...` | curl | +| Read a page for an agent to summarize | `ntn api v1/pages/{id}/markdown` | curl `/markdown` endpoint | +| Upload a file | `ntn files create < file` | 3-step HTTP flow | +| One-off API exploration | `ntn api ...` | curl | +| Build a sync / webhook / agent tool hosted by Notion | `ntn workers ...` | WSL2 + `ntn workers ...` | ## Notes -- Page/database IDs are UUIDs (with or without dashes) -- Rate limit: ~3 requests/second average -- The API cannot set database view filters — that's UI-only -- Use `is_inline: true` when creating data sources to embed them in pages -- Add `-s` flag to curl to suppress progress bars (cleaner output for Hermes) -- Pipe output through `jq` for readable JSON: `... | jq '.results[0].properties'` +- Page/database IDs are UUIDs (with or without dashes — both accepted). +- Rate limit: ~3 requests/second average. The CLI doesn't bypass this. +- The API cannot set database **view** filters — that's UI-only. +- Use `"is_inline": true` when creating data sources to embed them in a page. +- Always pass `-s` to curl to suppress progress bars (cleaner agent output). +- Pipe JSON through `jq` when reading: `... | jq '.results[0].properties'`. +- Notion also ships an MCP server now (`Notion MCP`, ~91% more token-efficient on DB ops than the previous version) — wire it via Hermes' MCP support if you want streaming Notion access from inside a session, but the paths above are enough for most one-shot tasks. From 2d7182f72c398496db60de5c18f8554d7ecc6d82 Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Fri, 15 May 2026 18:53:52 +0300 Subject: [PATCH 34/48] fix(delegate): move heartbeat thread start inside try block to prevent orphan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _heartbeat_thread.start() was called before the try/finally block that contains _heartbeat_stop.set(). If _register_subagent() or any code between .start() and try: raised an exception, the finally block would never run — leaving the heartbeat thread as an orphan that continues calling _touch_activity() on the parent agent, incorrectly resetting gateway timeout counters. Move _heartbeat_thread.start() to be the first statement inside the try block so the finally block always reaches _heartbeat_stop.set() regardless of how the child run completes or fails. Root cause: heartbeat start outside try/finally scope Impact: orphan heartbeat thread incorrectly resets parent gateway timeouts --- tools/delegate_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f4da5127a18b..2cdce9cae644 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1431,7 +1431,6 @@ def _run_single_child( pass _heartbeat_thread = threading.Thread(target=_heartbeat_loop, daemon=True) - _heartbeat_thread.start() # Register the live agent in the module-level registry so the TUI can # target it by subagent_id (kill, pause, status queries). Unregistered @@ -1462,6 +1461,7 @@ def _run_single_child( ) try: + _heartbeat_thread.start() if child_progress_cb: try: child_progress_cb("subagent.start", preview=goal) From 6068363311b861ad0bb411bfffe5958bf8b6d142 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 15:01:09 -0700 Subject: [PATCH 35/48] fix(delegate): guard heartbeat join against unstarted thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the prior commit (start() now inside the try block). If threading.Thread.start() itself raises (OS thread exhaustion under heavy delegation fanout), the finally would call .join() on a never-started thread, which raises RuntimeError("cannot join thread before it is started") — trading one rare bug for another. Thread.ident is None until start() succeeds, so gate the join on it. --- tools/delegate_tool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2cdce9cae644..f3a037c4341a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1836,9 +1836,13 @@ def _run_single_child( finally: # Stop the heartbeat thread so it doesn't keep touching parent activity - # after the child has finished (or failed). + # after the child has finished (or failed). Guard the join: .start() + # now lives inside the try block, so if it raised (OS thread + # exhaustion) the thread was never started and Thread.join() would + # raise RuntimeError. ident is None until start() succeeds. _heartbeat_stop.set() - _heartbeat_thread.join(timeout=5) + if _heartbeat_thread.ident is not None: + _heartbeat_thread.join(timeout=5) # Drop the TUI-facing registry entry. Safe to call even if the # child was never registered (e.g. ID missing on test doubles). From 7fee1f61eb52d1706af04c9606ee1a2e7ef3afc3 Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Fri, 15 May 2026 18:28:45 +0300 Subject: [PATCH 36/48] fix(memory): eliminate TOCTOU race in Windows file lock creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows (msvcrt path), _file_lock() first checked if the lock file existed and wrote it with write_text(), then opened it with open('r+'). Between these two calls, another process could delete the file causing open('r+') to raise FileNotFoundError — uncaught, leaving memory writes to proceed without holding the lock, risking data corruption. Replace the three-line sequence with a single open('a+', ...) call which atomically creates the file if missing or opens it if it exists, closing the TOCTOU window entirely. The existing fd.seek(0) before msvcrt.locking() is preserved and sufficient for correct lock byte positioning. Root cause: TOCTOU between lock_path.write_text() and open('r+') Impact: concurrent memory writes on Windows could corrupt MEMORY.md --- tools/memory_tool.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 236760a464ab..42737f66c4f4 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -156,10 +156,7 @@ class MemoryStore: yield return - if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): - lock_path.write_text(" ", encoding="utf-8") - - fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8") + fd = open(lock_path, "a+", encoding="utf-8") try: if fcntl: fcntl.flock(fd, fcntl.LOCK_EX) From 4aec25bc4411edb4563292cadbd02c365c846286 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 14:58:28 -0700 Subject: [PATCH 37/48] fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9): 1. LocalEnvironment cwd warn spam ============================ Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class `_extract_cwd_from_output` was assigning this verbatim to `self.cwd` without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)` returned False on Windows, triggering: LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk; falling back to '/' so terminal commands keep working. ...on every terminal call. The pre-existing Windows-path translation inside `_run_bash` ran AFTER the safe-cwd check, so it could never prevent the warning. Fix: - New `_msys_to_windows_path` helper (idempotent, no-op off Windows). - `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path is recognized as the real directory it points at. - `LocalEnvironment._update_cwd` and a new override of `_extract_cwd_from_output` translate + validate before mutating `self.cwd`. Stale / non-existent marker paths roll back to the previous cwd instead of clobbering it. - The fallback warning still fires when the directory really is gone (deletion-recovery scenario from #17558 still covered). 2. tirith spawn-failed warn spam ============================= When tirith isn't installed (background install in flight, or marked failed for the day) and the configured path stays as the bare string `tirith`, every `subprocess.run([tirith_path, ...])` raises OSError and logged: tirith spawn failed: [WinError 2] The system cannot find the file specified ...on every command. fail_open=True means behaviour is correct, but the log noise is severe. Fix: - `_warn_once(key, ...)` thread-safe dedupe helper. - Three hot-path warnings (`tirith path resolved to None`, `tirith spawn failed: ...`, `tirith timed out after Ns`) now log once per (exception class, errno) / timeout-value / path-none key. - Dedupe set is cleared on `_clear_install_failed` so a successful install lets a subsequent failure surface again. Tests ===== - `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the MSYS→Windows translator, the resolve fast-path, update_cwd validation, and extract_cwd_from_output rollback. - `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn failures → 1 log line; distinct exc types → 2 lines; timeout dedupe; path-None dedupe). Targeted runs: test_local_env_windows_msys.py 12 passed test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions) test_tirith_security.py 67 passed (63 pre-existing + 4 new) test_base_environment + local_* 37 passed (no regressions) test_local_env_blocklist + neighbours 114 passed Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings in a single short session. --- tests/tools/test_local_env_windows_msys.py | 200 +++++++++++++++++++++ tests/tools/test_tirith_security.py | 117 ++++++++++++ tools/environments/local.py | 85 +++++++-- tools/tirith_security.py | 53 +++++- 4 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 tests/tools/test_local_env_windows_msys.py diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py new file mode 100644 index 000000000000..6987c965af6a --- /dev/null +++ b/tests/tools/test_local_env_windows_msys.py @@ -0,0 +1,200 @@ +"""Tests for the Windows / Git Bash MSYS-path normalization in +``LocalEnvironment``. + +Background +---------- +On Windows, ``pwd -P`` inside Git Bash emits paths like +``/c/Users/NVIDIA``. ``subprocess.Popen(..., cwd=...)`` only accepts +native Windows paths (``C:\\Users\\NVIDIA``), and the validation done +by ``_resolve_safe_cwd`` was also checking the MSYS form against +``os.path.isdir``, which returns ``False`` on Windows. The combined +effect was a warning logged on every single terminal call: + + LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk; + falling back to '/' so terminal commands keep working. + +These tests fake the Windows env on Linux CI by patching ``_IS_WINDOWS`` +and ``os.path.isdir`` so the MSYS path tests as "missing" exactly like +on the real OS. +""" + +import os +from unittest.mock import patch + +import pytest + +from tools.environments import local as local_mod +from tools.environments.local import ( + LocalEnvironment, + _msys_to_windows_path, + _resolve_safe_cwd, +) + + +# --------------------------------------------------------------------------- +# _msys_to_windows_path — pure-function unit tests +# --------------------------------------------------------------------------- + +class TestMsysToWindowsPath: + def test_noop_on_non_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + # On a non-Windows host the function must never rewrite the path + # — POSIX-style paths are real paths there. + assert _msys_to_windows_path("/c/Users/NVIDIA") == "/c/Users/NVIDIA" + assert _msys_to_windows_path("/home/teknium") == "/home/teknium" + + def test_translates_drive_path(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/c/Users/NVIDIA") == r"C:\Users\NVIDIA" + assert _msys_to_windows_path("/d/Projects/foo bar") == r"D:\Projects\foo bar" + + def test_translates_bare_drive_root(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + # Bare "/c" alone should resolve to the drive root. + assert _msys_to_windows_path("/c") == "C:\\" + # Trailing slash on the drive letter is also a root. + assert _msys_to_windows_path("/c/") == "C:\\" + + def test_idempotent_on_already_windows_path(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path(r"C:\Users\NVIDIA") == r"C:\Users\NVIDIA" + + def test_does_not_translate_multi_char_first_segment(self, monkeypatch): + """``/tmp/foo`` and ``/home/x`` must NOT be misread as drive paths + just because they start with ``/`` and a single letter — the regex + only matches when the first segment is exactly one character.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/tmp/foo") == "/tmp/foo" + assert _msys_to_windows_path("/home/x") == "/home/x" + + def test_empty_string(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("") == "" + + +# --------------------------------------------------------------------------- +# _resolve_safe_cwd — Windows fast path +# --------------------------------------------------------------------------- + +class TestResolveSafeCwdWindows: + def test_msys_path_resolves_to_native_when_native_exists( + self, monkeypatch, tmp_path, + ): + """The whole point of this fix: a Git Bash ``/c/Users/x`` value + should resolve to its native equivalent if that native dir exists, + WITHOUT falling back to the temp dir.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + # tmp_path is a real native dir on the test host. Build a fake + # MSYS form pointing at it and prove the resolver finds it. + native = str(tmp_path) + # Construct a synthetic MSYS form for whatever tmp_path is. + # On Linux CI tmp_path is /tmp/... ; the resolver shouldn't even + # try to translate that (regex won't match), so emulate the + # mapping by pointing the translator at the real native dir. + with patch.object( + local_mod, "_msys_to_windows_path", return_value=native + ): + assert _resolve_safe_cwd("/c/whatever") == native + + +# --------------------------------------------------------------------------- +# End-to-end: _update_cwd via marker file (Windows simulation) +# --------------------------------------------------------------------------- + +class TestUpdateCwdWindowsMsys: + def test_marker_file_msys_path_stored_in_native_form( + self, monkeypatch, tmp_path, + ): + """When Git Bash writes ``/c/Users/x`` to the cwd marker file on + Windows, ``_update_cwd`` must translate to native form before + validating and storing — otherwise ``os.path.isdir`` rejects a + perfectly real directory.""" + original = tmp_path / "starting" + original.mkdir() + + # Fake Windows for the test + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + with patch.object( + LocalEnvironment, "init_session", autospec=True, return_value=None + ): + env = LocalEnvironment(cwd=str(original), timeout=10) + + # Pretend Git Bash wrote an MSYS path that maps to tmp_path/"next" + new_dir = tmp_path / "next" + new_dir.mkdir() + + with open(env._cwd_file, "w") as f: + f.write("/c/whatever/from/bash") + + # Translate the synthetic MSYS string to the real native dir. + def fake_translate(p): + if p == "/c/whatever/from/bash": + return str(new_dir) + return p + + with patch.object(local_mod, "_msys_to_windows_path", side_effect=fake_translate): + env._update_cwd({"output": "", "returncode": 0}) + + assert env.cwd == str(new_dir) + + +# --------------------------------------------------------------------------- +# End-to-end: _extract_cwd_from_output rollback when marker is invalid +# --------------------------------------------------------------------------- + +class TestExtractCwdFromOutputWindowsMsys: + def test_stale_msys_marker_does_not_clobber_cwd(self, monkeypatch, tmp_path): + """When the cwd marker in stdout points at a non-existent path, + ``LocalEnvironment._extract_cwd_from_output`` must roll back to + the previous cwd instead of propagating a bad value.""" + original = tmp_path / "starting" + original.mkdir() + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + with patch.object( + LocalEnvironment, "init_session", autospec=True, return_value=None + ): + env = LocalEnvironment(cwd=str(original), timeout=10) + + marker = env._cwd_marker + result = { + "output": f"some command output\n{marker}/c/no/such/path{marker}\n", + "returncode": 0, + } + + # Translation produces a path that doesn't exist on disk → rollback. + with patch.object( + local_mod, + "_msys_to_windows_path", + return_value=str(tmp_path / "definitely-does-not-exist"), + ): + env._extract_cwd_from_output(result) + + assert env.cwd == str(original) + + def test_valid_msys_marker_normalized_to_native(self, monkeypatch, tmp_path): + original = tmp_path / "starting" + original.mkdir() + new_dir = tmp_path / "next" + new_dir.mkdir() + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + with patch.object( + LocalEnvironment, "init_session", autospec=True, return_value=None + ): + env = LocalEnvironment(cwd=str(original), timeout=10) + + marker = env._cwd_marker + result = { + "output": f"x\n{marker}/c/whatever{marker}\n", + "returncode": 0, + } + + with patch.object(local_mod, "_msys_to_windows_path", return_value=str(new_dir)): + env._extract_cwd_from_output(result) + + assert env.cwd == str(new_dir) diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index 20d20ccfa11e..ecaf4f4e639d 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1007,3 +1007,120 @@ class TestHermesHomeIsolation: expected = os.path.join(os.path.expanduser("~"), ".hermes") result = _get_hermes_home() assert result == expected + + +# --------------------------------------------------------------------------- +# Warn-once dedupe (issue: tirith spawn failed spamming on Windows) +# --------------------------------------------------------------------------- + +class TestSpawnWarningDedup: + """When tirith isn't installed yet (background install in flight, or + install marked failed), every terminal command spammed an identical + ``tirith spawn failed: [WinError 2]`` warning to ``errors.log``. The + dedupe set in ``_warn_once`` collapses repeats by ``(exc class, errno)`` + while still surfacing the first occurrence so users see the failure. + """ + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_repeated_spawn_failure_logs_once(self, mock_cfg, mock_run, caplog): + mock_cfg.return_value = { + "tirith_enabled": True, "tirith_path": "tirith", + "tirith_timeout": 5, "tirith_fail_open": True, + } + mock_run.side_effect = FileNotFoundError("[WinError 2]") + # Fresh dedupe state — clear any keys left by other tests. + _tirith_mod._reset_spawn_warning_state() + + with caplog.at_level("WARNING", logger="tools.tirith_security"): + for _ in range(15): + result = check_command_security("echo hi") + # Behavior must remain the same on every call — + # fail-open allow, with the exception captured in summary. + assert result["action"] == "allow" + assert "unavailable" in result["summary"] + + spawn_warnings = [ + rec for rec in caplog.records + if "tirith spawn failed" in rec.message + ] + assert len(spawn_warnings) == 1, ( + f"expected exactly 1 spawn-failed warning across 15 commands, " + f"got {len(spawn_warnings)}: {[r.message for r in spawn_warnings]}" + ) + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_distinct_exception_types_each_log_once(self, mock_cfg, mock_run, caplog): + """``FileNotFoundError`` and ``PermissionError`` are distinct + failure modes and each deserves its own first-occurrence log + line; the dedupe key includes the exception class.""" + mock_cfg.return_value = { + "tirith_enabled": True, "tirith_path": "tirith", + "tirith_timeout": 5, "tirith_fail_open": True, + } + _tirith_mod._reset_spawn_warning_state() + + with caplog.at_level("WARNING", logger="tools.tirith_security"): + mock_run.side_effect = FileNotFoundError("[WinError 2]") + for _ in range(3): + check_command_security("a") + mock_run.side_effect = PermissionError("denied") + for _ in range(3): + check_command_security("b") + + spawn_warnings = [ + rec for rec in caplog.records + if "tirith spawn failed" in rec.message + ] + assert len(spawn_warnings) == 2, ( + f"expected 2 distinct first-occurrence warnings, " + f"got {len(spawn_warnings)}" + ) + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_repeated_timeout_logs_once(self, mock_cfg, mock_run, caplog): + mock_cfg.return_value = { + "tirith_enabled": True, "tirith_path": "tirith", + "tirith_timeout": 5, "tirith_fail_open": True, + } + mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5) + _tirith_mod._reset_spawn_warning_state() + + with caplog.at_level("WARNING", logger="tools.tirith_security"): + for _ in range(10): + result = check_command_security("slow") + assert result["action"] == "allow" + + timeout_warnings = [ + rec for rec in caplog.records + if "tirith timed out" in rec.message + ] + assert len(timeout_warnings) == 1 + + @patch("tools.tirith_security._load_security_config") + def test_path_none_logs_once(self, mock_cfg, caplog): + """``_resolve_tirith_path`` returning ``None`` (explicit path set + but resolver returned None — unusual) should not spam the log + either.""" + mock_cfg.return_value = { + "tirith_enabled": True, "tirith_path": "tirith", + "tirith_timeout": 5, "tirith_fail_open": True, + } + _tirith_mod._reset_spawn_warning_state() + + with patch( + "tools.tirith_security._resolve_tirith_path", return_value=None + ): + with caplog.at_level("WARNING", logger="tools.tirith_security"): + for _ in range(10): + result = check_command_security("echo") + assert result["action"] == "allow" + assert "tirith path unavailable" in result["summary"] + + none_warnings = [ + rec for rec in caplog.records + if "tirith path resolved to None" in rec.message + ] + assert len(none_warnings) == 1 diff --git a/tools/environments/local.py b/tools/environments/local.py index 7aa75a62d0c4..3b9d65449faa 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -18,18 +18,44 @@ _IS_WINDOWS = platform.system() == "Windows" logger = logging.getLogger(__name__) +def _msys_to_windows_path(cwd: str) -> str: + """Translate a Git Bash / MSYS-style POSIX path (``/c/Users/x``) to the + native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and + ``subprocess.Popen(..., cwd=...)`` can find it. + + No-ops on non-Windows hosts or for paths that aren't in MSYS form. + Returns the input unchanged when no translation applies. This is + idempotent — calling it on an already-Windows path returns it as-is. + """ + if not _IS_WINDOWS or not cwd: + return cwd + # Match leading "//" or exactly "/" (bare drive root). + m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd) + if not m: + return cwd + drive = m.group(1).upper() + tail = (m.group(2) or "").replace('/', '\\') + return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape + + def _resolve_safe_cwd(cwd: str) -> str: """Return ``cwd`` if it exists as a directory, else the nearest existing ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the path can't find any existing directory (effectively never on a healthy filesystem, but cheap belt-and-braces). + On Windows, also normalizes Git Bash / MSYS-style POSIX paths + (``/c/Users/x``) to native Windows form before the isdir check so a + perfectly valid ``pwd -P`` result from bash doesn't get rejected as + "missing" (see ``_msys_to_windows_path``). + Used by ``_run_bash`` to recover when the configured cwd is gone — most commonly because a previous tool call deleted its own working directory (issue #17558). Without this guard, ``subprocess.Popen(..., cwd=...)`` raises ``FileNotFoundError`` before bash starts, wedging every subsequent terminal call until the gateway restarts. """ + cwd = _msys_to_windows_path(cwd) if _IS_WINDOWS else cwd if cwd and os.path.isdir(cwd): return cwd parent = os.path.dirname(cwd) if cwd else "" @@ -455,21 +481,27 @@ class LocalEnvironment(BaseEnvironment): # (issue #17558). Popen would otherwise raise FileNotFoundError on # the cwd before bash starts, wedging every subsequent call until the # gateway restarts. + # + # On Windows, ``_resolve_safe_cwd`` also normalises Git Bash-style + # POSIX paths (``/c/Users/...``) to native form so a perfectly valid + # ``pwd -P`` result from bash isn't mistakenly treated as "missing" + # and spammed as a warning on every command. safe_cwd = _resolve_safe_cwd(self.cwd) if safe_cwd != self.cwd: - logger.warning( - "LocalEnvironment cwd %r is missing on disk; " - "falling back to %r so terminal commands keep working.", - self.cwd, - safe_cwd, - ) + # MSYS → Windows translation alone shouldn't surface as a warning + # (it's a benign normalization, not a recovery). Only warn when + # the directory really doesn't exist on disk. + normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd + if safe_cwd != normalized: + logger.warning( + "LocalEnvironment cwd %r is missing on disk; " + "falling back to %r so terminal commands keep working.", + self.cwd, + safe_cwd, + ) self.cwd = safe_cwd - # On Windows, self.cwd may be a Git Bash-style path (/c/Users/...) - # from pwd output. subprocess.Popen needs a native Windows path. _popen_cwd = self.cwd - if _IS_WINDOWS and _popen_cwd and re.match(r'^/[a-zA-Z]/', _popen_cwd): - _popen_cwd = _popen_cwd[1].upper() + ':' + _popen_cwd[2:].replace('/', '\\') proc = subprocess.Popen( args, @@ -571,10 +603,19 @@ class LocalEnvironment(BaseEnvironment): ``pwd -P`` on a deleted cwd can leave a stale value in the marker file, and propagating it would re-wedge the next ``Popen``. The ``_run_bash`` recovery path will resolve a safe fallback if needed. + + On Windows, the value written by Git Bash's ``pwd -P`` is in + MSYS form (``/c/Users/x``). Translate it to native Windows form + before validating with ``os.path.isdir`` and before storing on + ``self.cwd``; otherwise the isdir check rejects every valid + result and ``_run_bash`` later prints a misleading "cwd is + missing" warning on every command. """ try: with open(self._cwd_file, encoding="utf-8") as f: cwd_path = f.read().strip() + if _IS_WINDOWS: + cwd_path = _msys_to_windows_path(cwd_path) if cwd_path and os.path.isdir(cwd_path): self.cwd = cwd_path except (OSError, FileNotFoundError): @@ -583,6 +624,30 @@ class LocalEnvironment(BaseEnvironment): # Still strip the marker from output so it's not visible self._extract_cwd_from_output(result) + def _extract_cwd_from_output(self, result: dict): + """Same semantics as the base class, but on Windows the value + emitted by ``pwd -P`` inside Git Bash is in MSYS form + (``/c/Users/x``). Normalize to native Windows form and validate + the directory exists before assigning to ``self.cwd`` — otherwise + ``_run_bash``'s safe-cwd recovery would warn on every subsequent + command. + + Always defers to the base class for stripping the marker text from + ``result["output"]`` so output formatting is identical. + """ + # Snapshot pre-existing cwd, defer to base for parsing + marker + # stripping, then validate / normalize whatever it assigned. + prev_cwd = self.cwd + super()._extract_cwd_from_output(result) + if self.cwd != prev_cwd: + normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd + if normalized and os.path.isdir(normalized): + self.cwd = normalized + else: + # Stale / non-existent path — keep previous cwd; _run_bash + # will resolve a safe fallback on the next call if needed. + self.cwd = prev_cwd + def cleanup(self): """Clean up temp files.""" for f in (self._snapshot_path, self._cwd_file): diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 350265d33a14..1c79892f424a 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -101,6 +101,34 @@ _install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_ _install_lock = threading.Lock() _install_thread: threading.Thread | None = None +# Warning de-duplication. The spawn/path warnings live in the hot path — +# without this dedupe set, a Windows install where ``tirith`` isn't on PATH +# (e.g. background install thread still running, or install marked failed) +# spams ``tirith spawn failed: [WinError 2]...`` once per terminal command, +# easily filling errors.log with hundreds of identical lines. +_warned_messages: set[str] = set() +_warned_lock = threading.Lock() + + +def _warn_once(key: str, message: str, *args) -> None: + """``logger.warning`` but at-most-once per ``key`` for the process + lifetime. Used to avoid drowning the log when a fail-open tirith + misconfiguration fires on every command.""" + with _warned_lock: + if key in _warned_messages: + return + _warned_messages.add(key) + logger.warning(message, *args) + + +def _reset_spawn_warning_state() -> None: + """Clear the warn-once dedupe set. Called when tirith is freshly + (re)installed so a subsequent failure surfaces again — e.g. user + deletes the binary mid-session. + """ + with _warned_lock: + _warned_messages.clear() + # Disk-persistent failure marker — avoids retry across process restarts _MARKER_TTL = 86400 # 24 hours @@ -168,6 +196,10 @@ def _mark_install_failed(reason: str = ""): def _clear_install_failed(): """Remove the failure marker after successful install.""" + # Reset the warn-once dedupe set so a subsequent failure (e.g. user + # deletes the binary) surfaces in the log again instead of being + # silently suppressed by a stale dedupe key from before the fix. + _reset_spawn_warning_state() try: os.unlink(_failure_marker_path()) except OSError: @@ -632,7 +664,10 @@ def check_command_security(command: str) -> dict: fail_open = cfg["tirith_fail_open"] if tirith_path is None: - logger.warning("tirith path resolved to None; scanning disabled") + _warn_once( + "tirith_path_none", + "tirith path resolved to None; scanning disabled", + ) if fail_open: return {"action": "allow", "findings": [], "summary": "tirith path unavailable"} return {"action": "block", "findings": [], "summary": "tirith path unavailable (fail-closed)"} @@ -646,13 +681,23 @@ def check_command_security(command: str) -> dict: timeout=timeout, ) except OSError as exc: - # Covers FileNotFoundError, PermissionError, exec format error - logger.warning("tirith spawn failed: %s", exc) + # Covers FileNotFoundError, PermissionError, exec format error. + # Dedupe by ``(errno, exc class)`` so a transient failure mode + # surfaces once but doesn't drown the log on every command — + # commonly seen on Windows when the configured path "tirith" + # isn't on PATH yet (background install still running, or + # install marked failed for the day). + spawn_key = f"tirith_spawn_failed:{type(exc).__name__}:{getattr(exc, 'errno', '')}" + _warn_once(spawn_key, "tirith spawn failed: %s", exc) if fail_open: return {"action": "allow", "findings": [], "summary": f"tirith unavailable: {exc}"} return {"action": "block", "findings": [], "summary": f"tirith spawn failed (fail-closed): {exc}"} except subprocess.TimeoutExpired: - logger.warning("tirith timed out after %ds", timeout) + _warn_once( + f"tirith_timeout:{timeout}", + "tirith timed out after %ds", + timeout, + ) if fail_open: return {"action": "allow", "findings": [], "summary": f"tirith timed out ({timeout}s)"} return {"action": "block", "findings": [], "summary": "tirith timed out (fail-closed)"} From 31ba2b0cbcac310f7aa2db3c8885e37f2e2e37fb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 16:35:12 -0700 Subject: [PATCH 38/48] fix(xai-oauth): recover from prelude SSE errors, gate reasoning replay, surface entitlement 403s (#26644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the May 2026 xAI OAuth (SuperGrok / X Premium) rollout failures: - _run_codex_stream: when openai SDK raises RuntimeError("Expected to have received `response.created` before ``"), retry once then fall back to responses.create(stream=True) — same path used for missing-response.completed postlude. Fallback surfaces the real provider error with body+status_code intact. Also fixes #8133 (response.in_progress prelude on custom relays) and #14634 (codex.rate_limits prelude on codex-lb). - _summarize_api_error: when error body matches xAI's entitlement shape, append a one-line hint pointing to https://grok.com and /model. Once-only, applies to both auxiliary warnings and main-loop error surfacing. - _chat_messages_to_responses_input: new is_xai_responses kwarg drops replayed codex_reasoning_items (encrypted_content) before they reach xAI. Also drops reasoning.encrypted_content from the xAI include array. Native Codex behavior unchanged. Grok still reasons natively each turn; coherence rides on visible message text alone. Closes #8133, #14634. --- agent/codex_responses_adapter.py | 27 +- agent/transports/codex.py | 19 +- run_agent.py | 81 +++- .../agent/transports/test_codex_transport.py | 21 +- .../test_codex_xai_oauth_recovery.py | 351 ++++++++++++++++++ 5 files changed, 481 insertions(+), 18 deletions(-) create mode 100644 tests/run_agent/test_codex_xai_oauth_recovery.py diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 00345f054e81..6fe9dc5bc649 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -244,8 +244,21 @@ def _normalize_responses_message_status(value: Any, *, default: str = "completed return default -def _chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Convert internal chat-style messages to Responses input items.""" +def _chat_messages_to_responses_input( + messages: List[Dict[str, Any]], + *, + is_xai_responses: bool = False, +) -> List[Dict[str, Any]]: + """Convert internal chat-style messages to Responses input items. + + ``is_xai_responses=True`` strips ``encrypted_content`` from replayed + reasoning items. xAI's OAuth/SuperGrok ``/v1/responses`` surface + rejects encrypted reasoning blobs minted by prior turns: the request + streams an ``error`` SSE frame before ``response.created`` and the + OpenAI SDK collapses it into a generic stream-ordering error. Native + Codex (chatgpt.com backend-api) DOES accept replayed encrypted_content + — keep the default off. + """ items: List[Dict[str, Any]] = [] seen_item_ids: set = set() @@ -271,9 +284,17 @@ def _chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Di if role == "assistant": # Replay encrypted reasoning items from previous turns # so the API can maintain coherent reasoning chains. + # + # xAI OAuth (SuperGrok/Premium) rejects replayed + # ``encrypted_content`` reasoning items minted by prior + # turns — see _chat_messages_to_responses_input docstring. + # When ``is_xai_responses`` is set we drop the replay + # entirely; Grok still reasons on each turn server-side, + # we just don't try to thread the prior turn's encrypted + # blob back in. codex_reasoning = msg.get("codex_reasoning_items") has_codex_reasoning = False - if isinstance(codex_reasoning, list): + if isinstance(codex_reasoning, list) and not is_xai_responses: for ri in codex_reasoning: if isinstance(ri, dict) and ri.get("encrypted_content"): item_id = ri.get("id") diff --git a/agent/transports/codex.py b/agent/transports/codex.py index cfd9f1287787..3661ea17a3e6 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -24,7 +24,10 @@ class ResponsesApiTransport(ProviderTransport): def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: """Convert OpenAI chat messages to Responses API input items.""" from agent.codex_responses_adapter import _chat_messages_to_responses_input - return _chat_messages_to_responses_input(messages) + return _chat_messages_to_responses_input( + messages, + is_xai_responses=bool(kwargs.get("is_xai_responses")), + ) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: """Convert OpenAI tool schemas to Responses API function definitions.""" @@ -93,7 +96,10 @@ class ResponsesApiTransport(ProviderTransport): kwargs = { "model": model, "instructions": instructions, - "input": _chat_messages_to_responses_input(payload_messages), + "input": _chat_messages_to_responses_input( + payload_messages, + is_xai_responses=is_xai_responses, + ), "tools": response_tools, "store": False, } @@ -110,7 +116,14 @@ class ResponsesApiTransport(ProviderTransport): if reasoning_enabled and is_xai_responses: from agent.model_metadata import grok_supports_reasoning_effort - kwargs["include"] = ["reasoning.encrypted_content"] + # NOTE: Hermes does NOT ask xAI to return ``reasoning.encrypted_content`` + # any more. xAI's OAuth/SuperGrok ``/v1/responses`` surface rejects + # replayed encrypted reasoning items on turn 2+ — see + # _chat_messages_to_responses_input docstring. Requesting the field + # back would just have us cache something we then must strip. Grok + # still reasons natively each turn; coherence across turns rides on + # the visible message text alone. + kwargs["include"] = [] # xAI rejects `reasoning.effort` on grok-4 / grok-4-fast / grok-3 # / grok-code-fast / grok-4.20-0309-* with HTTP 400 even though # those models reason natively. Only send the effort dial when diff --git a/run_agent.py b/run_agent.py index 7e42beb3eba4..2b20d48ede25 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4966,6 +4966,45 @@ class AIAgent: trajectory = self._convert_to_trajectory_format(messages, user_query, completed) _save_trajectory_to_file(trajectory, self.model, completed) + @staticmethod + def _decorate_xai_entitlement_error(detail: str) -> str: + """Append a friendly hint when xAI's OAuth surface returns an + entitlement-shaped error. + + xAI's ``/v1/responses`` endpoint replies to OAuth tokens that lack a + SuperGrok / X Premium subscription with HTTP 403 carrying a body like:: + + {"code": "The caller does not have permission to execute the + specified operation", "error": "You have either run out of + available resources or do not have an active Grok subscription. + Manage subscriptions at https://grok.com/..."} + + The raw text is useful but the action the user needs to take (subscribe + on grok.com, or switch providers with ``/model``) isn't obvious from + the wire format. Detect the entitlement shape and append a hint. + + Matched once per detail string — won't double-decorate if the upstream + already concatenated the same text. + """ + if not detail: + return detail + lower = detail.lower() + is_entitlement = ( + "do not have an active grok subscription" in lower + or ("out of available resources" in lower and "grok" in lower) + or ("does not have permission" in lower and "grok" in lower) + ) + if not is_entitlement: + return detail + hint = ( + " — xAI OAuth account lacks SuperGrok / X Premium entitlement for " + "this model. Subscribe at https://grok.com or run `/model` to " + "switch providers." + ) + if hint.strip() in detail: + return detail + return f"{detail}{hint}" + @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -4999,12 +5038,12 @@ class AIAgent: if msg: status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return f"{prefix}{msg[:300]}" + return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}") # Fallback: truncate the raw string but give more room than 200 chars status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return f"{prefix}{raw[:500]}" + return AIAgent._decorate_xai_entitlement_error(f"{prefix}{raw[:500]}") def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]: if not key: @@ -7056,18 +7095,48 @@ class AIAgent: except RuntimeError as exc: err_text = str(exc) missing_completed = "response.completed" in err_text - if missing_completed and attempt < max_stream_retries: + # The OpenAI SDK's Responses streaming state machine raises + # ``RuntimeError("Expected to have received `response.created` + # before ``")`` when the first SSE event from the + # server is anything other than ``response.created`` — and it + # discards the event's payload before we can read it. Three + # real-world backends emit a different first frame: + # + # * xAI on grok-4.x OAuth — sends ``error`` (issues + # reported around the May 2026 SuperGrok rollout when + # multi-turn conversations replay encrypted reasoning + # content the OAuth tier rejects) + # * codex-lb relays — send ``codex.rate_limits`` (#14634) + # * custom Responses relays — send ``response.in_progress`` + # (#8133) + # + # In all three cases the underlying byte stream is still + # readable: a non-stream ``responses.create(stream=True)`` + # fallback succeeds and surfaces the real provider error as + # a normal exception with body+status_code attached, which + # ``_summarize_api_error`` can then translate into a useful + # user-facing line. Treat ``response.created`` prelude + # errors the same way we already treat ``response.completed`` + # postlude errors. + prelude_error = ( + "Expected to have received `response.created`" in err_text + or "Expected to have received \"response.created\"" in err_text + ) + if (missing_completed or prelude_error) and attempt < max_stream_retries: logger.debug( - "Responses stream closed before completion (attempt %s/%s); retrying. %s", + "Responses stream %s (attempt %s/%s); retrying. %s", + "prelude rejected" if prelude_error else "closed before completion", attempt + 1, max_stream_retries + 1, self._client_log_context(), ) continue - if missing_completed: + if missing_completed or prelude_error: logger.debug( - "Responses stream did not emit response.completed; falling back to create(stream=True). %s", + "Responses stream %s; falling back to create(stream=True). %s err=%s", + "rejected before response.created" if prelude_error else "did not emit response.completed", self._client_log_context(), + err_text, ) return self._run_codex_create_stream_fallback(api_kwargs, client=active_client) raise diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index ad70167b09fb..822518237906 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -194,9 +194,16 @@ class TestCodexBuildKwargs: is_xai_responses=True, reasoning_config={"effort": "high"}, ) - # xAI Responses must receive both encrypted reasoning content and the effort + # xAI Responses receives reasoning.effort on the allowlisted models. assert kw.get("reasoning") == {"effort": "high"} - assert "reasoning.encrypted_content" in kw.get("include", []) + # As of May 2026 we deliberately do NOT request + # reasoning.encrypted_content back from xAI — the OAuth/SuperGrok + # surface rejects replayed encrypted reasoning items on turn 2+ + # (the multi-turn "Expected to have received response.created + # before error" failure). Grok still reasons natively each turn; + # we just don't try to thread the prior turn's encrypted blob back + # in. See tests/run_agent/test_codex_xai_oauth_recovery.py. + assert "reasoning.encrypted_content" not in kw.get("include", []) def test_xai_reasoning_disabled_no_reasoning_key(self, transport): messages = [{"role": "user", "content": "Hi"}] @@ -222,8 +229,9 @@ class TestCodexBuildKwargs: # api.x.ai 400s with "Model X does not support parameter reasoningEffort" # on grok-4 / grok-4-fast / grok-3 / grok-code-fast / grok-4.20-0309-*. # Those models reason natively but don't expose the dial. The transport - # must omit the `reasoning` key for them while keeping the encrypted - # reasoning content include so we can capture native reasoning tokens. + # must omit the `reasoning` key for them. As of May 2026 we also no + # longer request ``reasoning.encrypted_content`` back from xAI on ANY + # model — see test_xai_reasoning_effort_passed for the rationale. def test_xai_grok_4_omits_reasoning_effort(self, transport): """grok-4 / grok-4-0709 reject reasoning.effort with HTTP 400.""" @@ -237,8 +245,9 @@ class TestCodexBuildKwargs: assert "reasoning" not in kw, ( f"{model} must not receive a reasoning key (xAI rejects it)" ) - # Still capture native reasoning tokens - assert "reasoning.encrypted_content" in kw.get("include", []) + # We no longer ask xAI for encrypted_content back (see comment + # above) — verify the include list is empty. + assert "reasoning.encrypted_content" not in kw.get("include", []) def test_xai_grok_4_fast_omits_reasoning_effort(self, transport): """grok-4-fast and grok-4-1-fast variants reject reasoning.effort.""" diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py new file mode 100644 index 000000000000..0f3603d2ca77 --- /dev/null +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -0,0 +1,351 @@ +"""Regression tests for the May 2026 xAI OAuth (SuperGrok / X Premium) bugs. + +Three distinct failure modes the user community hit during rollout: + +1. ``RuntimeError("Expected to have received `response.created` before + `error`")`` on multi-turn xAI OAuth conversations. The OpenAI SDK's + Responses streaming state machine collapses an upstream ``error`` SSE + frame into a generic stream-ordering error. ``_run_codex_stream`` + now treats this the same way it already treats the missing + ``response.completed`` postlude — fall back to a non-stream + ``responses.create(stream=True)`` which surfaces the real provider + error. Also closes #8133 (``response.in_progress`` prelude on custom + relays) and #14634 (``codex.rate_limits`` prelude on codex-lb). + +2. The HTTP 403 entitlement error xAI returns when an OAuth token lacks + SuperGrok / X Premium ("You have either run out of available + resources or do not have an active Grok subscription") used to read + as a confusing wall of JSON. ``_summarize_api_error`` now appends a + one-line hint pointing the user at https://grok.com and ``/model``. + +3. Multi-turn replay of ``codex_reasoning_items`` (with + ``encrypted_content``) is now suppressed for ``is_xai_responses=True`` + in ``_chat_messages_to_responses_input``. xAI's OAuth/SuperGrok + surface rejects replayed encrypted reasoning items; Grok still + reasons natively each turn, so coherence rides on visible message + text. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fix A: prelude error fallback +# --------------------------------------------------------------------------- + + +def _make_codex_agent(): + """Build a minimal AIAgent wired for codex_responses streaming tests.""" + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.x.ai/v1", + model="grok-4.3", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "codex_responses" + agent.provider = "xai-oauth" + agent._interrupt_requested = False + return agent + + +@pytest.mark.parametrize( + "prelude_event_type", + [ + "error", # xAI OAuth multi-turn + "codex.rate_limits", # codex-lb relays (#14634) + "response.in_progress", # custom Responses relays (#8133) + ], +) +def test_codex_stream_prelude_error_falls_back_to_create_stream(prelude_event_type): + """The SDK's prelude RuntimeError must trigger the non-stream fallback. + + When the first SSE event isn't ``response.created``, openai-python + raises RuntimeError before our event loop sees anything. We must + detect that, retry once, then fall back to ``create(stream=True)`` + which surfaces the real provider error or a real response. + """ + agent = _make_codex_agent() + + prelude_error = RuntimeError( + f"Expected to have received `response.created` before `{prelude_event_type}`" + ) + + mock_client = MagicMock() + mock_client.responses.stream.side_effect = prelude_error + + fallback_response = SimpleNamespace( + output=[SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="fallback ok")], + )], + status="completed", + ) + + with patch.object( + agent, "_run_codex_create_stream_fallback", return_value=fallback_response + ) as mock_fallback: + result = agent._run_codex_stream({}, client=mock_client) + + assert result is fallback_response + mock_fallback.assert_called_once_with({}, client=mock_client) + + +def test_codex_stream_prelude_error_retries_once_before_fallback(): + """The retry path must fire one extra stream attempt before falling back.""" + agent = _make_codex_agent() + + call_count = {"n": 0} + + def stream_side_effect(**kwargs): + call_count["n"] += 1 + raise RuntimeError( + "Expected to have received `response.created` before `error`" + ) + + mock_client = MagicMock() + mock_client.responses.stream.side_effect = stream_side_effect + + fallback_response = SimpleNamespace(output=[], status="completed") + with patch.object( + agent, "_run_codex_create_stream_fallback", return_value=fallback_response + ) as mock_fallback: + agent._run_codex_stream({}, client=mock_client) + + # max_stream_retries=1 → one retry + final attempt → 2 stream calls, + # THEN the fallback path runs. + assert call_count["n"] == 2 + mock_fallback.assert_called_once() + + +def test_codex_stream_unrelated_runtimeerror_still_raises(): + """RuntimeErrors that aren't prelude/postlude shape must propagate.""" + agent = _make_codex_agent() + + mock_client = MagicMock() + mock_client.responses.stream.side_effect = RuntimeError("something else broke") + + with patch.object(agent, "_run_codex_create_stream_fallback") as mock_fallback: + with pytest.raises(RuntimeError, match="something else broke"): + agent._run_codex_stream({}, client=mock_client) + + mock_fallback.assert_not_called() + + +def test_codex_stream_postlude_error_still_falls_back(): + """Existing ``response.completed`` fallback must not regress.""" + agent = _make_codex_agent() + + mock_client = MagicMock() + mock_client.responses.stream.side_effect = RuntimeError( + "Didn't receive a `response.completed` event." + ) + + fallback_response = SimpleNamespace(output=[], status="completed") + with patch.object( + agent, "_run_codex_create_stream_fallback", return_value=fallback_response + ) as mock_fallback: + result = agent._run_codex_stream({}, client=mock_client) + + assert result is fallback_response + mock_fallback.assert_called_once() + + +# --------------------------------------------------------------------------- +# Fix B: friendly entitlement message +# --------------------------------------------------------------------------- + + +def test_summarize_api_error_decorates_xai_entitlement_403(): + """xAI's OAuth 403 must end with the subscribe-or-switch hint.""" + from run_agent import AIAgent + + error = RuntimeError( + "HTTP 403: Error code: 403 - {'code': 'The caller does not have permission " + "to execute the specified operation', 'error': 'You have either run out of " + "available resources or do not have an active Grok subscription. Manage " + "subscriptions at https://grok.com'}" + ) + summary = AIAgent._summarize_api_error(error) + assert "do not have an active Grok subscription" in summary + assert "SuperGrok" in summary + assert "/model" in summary + assert "https://grok.com" in summary + + +def test_summarize_api_error_decorates_xai_body_message(): + """SDK-style error with structured body must also get the hint.""" + from run_agent import AIAgent + + class _XaiErr(Exception): + status_code = 403 + body = { + "error": { + "message": ( + "You have either run out of available resources or do " + "not have an active Grok subscription. Manage at " + "https://grok.com" + ) + } + } + + summary = AIAgent._summarize_api_error(_XaiErr("403")) + assert "HTTP 403" in summary + assert "SuperGrok / X Premium" in summary + + +def test_summarize_api_error_idempotent_for_entitlement_hint(): + """Decorating twice must not double up the hint.""" + from run_agent import AIAgent + + raw = "HTTP 403: do not have an active Grok subscription" + once = AIAgent._decorate_xai_entitlement_error(raw) + twice = AIAgent._decorate_xai_entitlement_error(once) + assert once == twice + + +def test_summarize_api_error_passes_through_unrelated_errors(): + """Non-xAI / non-entitlement errors must not be touched.""" + from run_agent import AIAgent + + error = RuntimeError("HTTP 500: upstream is sad") + summary = AIAgent._summarize_api_error(error) + assert "SuperGrok" not in summary + assert "grok.com" not in summary + assert "upstream is sad" in summary + + +# --------------------------------------------------------------------------- +# Fix C: reasoning replay gating for xai-oauth +# --------------------------------------------------------------------------- + + +def _assistant_msg_with_encrypted_reasoning(text="hi from grok", encrypted="enc_blob"): + return { + "role": "assistant", + "content": text, + "codex_reasoning_items": [ + { + "type": "reasoning", + "id": "rs_xai_001", + "encrypted_content": encrypted, + "summary": [], + } + ], + } + + +def test_codex_reasoning_replay_default_includes_encrypted_content(): + """Native Codex backend (default) must still replay encrypted reasoning.""" + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + msgs = [ + {"role": "user", "content": "hi"}, + _assistant_msg_with_encrypted_reasoning(), + {"role": "user", "content": "what's your name?"}, + ] + + items = _chat_messages_to_responses_input(msgs) + reasoning = [it for it in items if it.get("type") == "reasoning"] + assert len(reasoning) == 1 + assert reasoning[0]["encrypted_content"] == "enc_blob" + + +def test_codex_reasoning_replay_stripped_for_xai_oauth(): + """xAI OAuth surface must NOT receive replayed encrypted reasoning.""" + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + msgs = [ + {"role": "user", "content": "hi"}, + _assistant_msg_with_encrypted_reasoning(), + {"role": "user", "content": "what's your name?"}, + ] + + items = _chat_messages_to_responses_input(msgs, is_xai_responses=True) + reasoning = [it for it in items if it.get("type") == "reasoning"] + assert reasoning == [] + + # The assistant's visible text must still survive — coherence across + # turns rides on the message text alone. + assistant_items = [ + it for it in items + if it.get("role") == "assistant" or it.get("type") == "message" + ] + assert assistant_items, "assistant message must still be present" + + +def test_codex_transport_xai_request_omits_encrypted_content_include(): + """Verify the xAI ``include`` array no longer requests encrypted reasoning.""" + from agent.transports.codex import ResponsesApiTransport + + transport = ResponsesApiTransport() + kwargs = transport.build_kwargs( + model="grok-4.3", + messages=[ + {"role": "system", "content": "you are a helpful assistant"}, + {"role": "user", "content": "hi"}, + ], + tools=None, + instructions="you are a helpful assistant", + reasoning_config={"enabled": True, "effort": "medium"}, + is_xai_responses=True, + ) + # Without this gate, xAI would echo back encrypted_content blobs we'd + # then store in codex_reasoning_items and replay next turn — which is + # exactly the multi-turn failure mode we're closing. + assert kwargs["include"] == [] + + +def test_codex_transport_xai_strips_replayed_reasoning_in_input(): + """End-to-end: build_kwargs on xai-oauth must strip prior reasoning.""" + from agent.transports.codex import ResponsesApiTransport + + transport = ResponsesApiTransport() + kwargs = transport.build_kwargs( + model="grok-4.3", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + _assistant_msg_with_encrypted_reasoning(text="hi from grok"), + {"role": "user", "content": "what's your name?"}, + ], + tools=None, + instructions="sys", + reasoning_config={"enabled": True, "effort": "medium"}, + is_xai_responses=True, + ) + input_items = kwargs["input"] + reasoning_items = [it for it in input_items if it.get("type") == "reasoning"] + assert reasoning_items == [] + + +def test_codex_transport_native_codex_still_replays_reasoning_in_input(): + """Regression guard: openai-codex must keep the existing replay path.""" + from agent.transports.codex import ResponsesApiTransport + + transport = ResponsesApiTransport() + kwargs = transport.build_kwargs( + model="gpt-5-codex", + messages=[ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + _assistant_msg_with_encrypted_reasoning(text="hi from codex"), + {"role": "user", "content": "next"}, + ], + tools=None, + instructions="sys", + reasoning_config={"enabled": True, "effort": "medium"}, + is_xai_responses=False, + ) + input_items = kwargs["input"] + reasoning_items = [it for it in input_items if it.get("type") == "reasoning"] + assert len(reasoning_items) == 1 + assert reasoning_items[0]["encrypted_content"] == "enc_blob" + # Native Codex still asks for encrypted_content back. + assert "reasoning.encrypted_content" in kwargs.get("include", []) From 068c24f8a4203e86de32b0d84ccaf047e8cd6ef7 Mon Sep 17 00:00:00 2001 From: twebefy Date: Sat, 25 Apr 2026 00:46:10 +0800 Subject: [PATCH 39/48] feat(deepseek): add thinking.type + reasoning_effort mapping for DeepSeek API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek's thinking mode requires both: - extra_body.thinking.type: "enabled" to activate thinking mode - top-level reasoning_effort: "max" or "high" to control depth Previously, the ChatCompletionsTransport only handled Kimi's thinking mode — DeepSeek was left unmapped, so reasoning_effort config was silently dropped. This patch: 1. Adds is_deepseek: bool to the Params dataclass, detected by base_url matching api.deepseek.com 2. Maps Hermes effort levels (xhigh/max → "max", low/medium/high → themselves) to the top-level reasoning_effort parameter 3. Sets extra_body.thinking.type alongside the effort 4. Strips reasoning_content from assistant messages sent back to DeepSeek, preventing 400 errors when thinking was enabled --- agent/transports/chat_completions.py | 20 ++++++++++++++++++++ run_agent.py | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 7edb69e42c74..1ae584e9159a 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -189,6 +189,7 @@ class ChatCompletionsTransport(ProviderTransport): is_kimi: bool is_tokenhub: bool is_lmstudio: bool + is_deepseek: bool is_custom_provider: bool ollama_num_ctx: int | None # Provider routing @@ -348,6 +349,25 @@ class ChatCompletionsTransport(ProviderTransport): "type": "enabled" if _kimi_thinking_enabled else "disabled", } + # DeepSeek extra_body.thinking + top-level reasoning_effort + is_deepseek = params.get("is_deepseek", False) + if is_deepseek: + _ds_thinking_enabled = True + if reasoning_config and isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + _ds_thinking_enabled = False + extra_body["thinking"] = { + "type": "enabled" if _ds_thinking_enabled else "disabled", + } + # DeepSeek effort: low/medium→high, high→high, xhigh/max→max + if _ds_thinking_enabled and reasoning_config: + _e = (reasoning_config.get("effort") or "").strip().lower() + if _e in ("xhigh", "max"): + api_kwargs["reasoning_effort"] = "max" + elif _e in ("low", "medium", "high"): + api_kwargs["reasoning_effort"] = _e + # If no effort configured, don't set it → DeepSeek defaults to high + # Reasoning. LM Studio is handled above via top-level reasoning_effort, # so skip emitting extra_body.reasoning for it. if params.get("supports_reasoning", False) and not params.get("is_lmstudio", False): diff --git a/run_agent.py b/run_agent.py index 2b20d48ede25..c9aa3157170f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9798,6 +9798,7 @@ class AIAgent: ) _is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com") _is_lmstudio = (self.provider or "").strip().lower() == "lmstudio" + _is_deepseek = base_url_host_matches(self.base_url, "api.deepseek.com") # Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE # sentinel (temperature omitted entirely), a numeric override, or None. @@ -9909,6 +9910,7 @@ class AIAgent: is_kimi=_is_kimi, is_tokenhub=_is_tokenhub, is_lmstudio=_is_lmstudio, + is_deepseek=_is_deepseek, is_custom_provider=self.provider == "custom", ollama_num_ctx=self._ollama_num_ctx, provider_preferences=_prefs or None, @@ -10368,6 +10370,11 @@ class AIAgent: # context compaction). Don't pass null to the API. api_msg.pop("reasoning_content", None) + # DeepSeek: strip reasoning_content on all assistant messages so the API + # doesn't return 400 when the model was invoked with thinking enabled. + if base_url_host_matches(self.base_url, "api.deepseek.com"): + api_msg.pop("reasoning_content", None) + @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: """Strip Codex Responses API fields from tool_calls for strict providers. From cd9470f41638bd515db096cd934c463205790110 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 16:39:18 -0700 Subject: [PATCH 40/48] fix(deepseek): wire thinking-mode via DeepSeekProfile, not legacy fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked PR #15251 from @tw2818 correctly identified the DeepSeek 400 root cause but placed the fix in the legacy fallback path of `build_kwargs`, which DeepSeek never reaches — DeepSeek has a registered ProviderProfile and goes through `_build_kwargs_from_profile` instead. The legacy-path block was therefore dead code. This commit pivots the fix to where it actually fires: - New `DeepSeekProfile` in `plugins/model-providers/deepseek/__init__.py` overrides `build_api_kwargs_extras` to emit DeepSeek's expected wire format (mirrors `KimiProfile`): {"reasoning_effort": "", "extra_body": {"thinking": {"type": "enabled" | "disabled"}}} - Model gating: only `deepseek-v4-*` and `deepseek-reasoner` emit thinking control. `deepseek-chat` (V3) is untouched — current behavior. - Effort mapping: low/medium/high passthrough, xhigh/max → max, unset → omitted (DeepSeek server applies its own default). - Revert the legacy-path additions from PR #15251 — they were dead code, and the `_copy_reasoning_content_for_api` strip block specifically would have nullified the existing reasoning_content padding machinery (`_needs_deepseek_tool_reasoning` → space-pad on replay) that the active provider already relies on for replay correctness. - Unit tests pin the wire-shape contract and the model gating rules (26 tests, all passing). Existing transport + provider profile suites (321 tests) continue to pass. - AUTHOR_MAP: map twebefy@gmail.com → tw2818 for release notes credit. Closes #15700, #17212, #17825. Co-authored-by: tw2818 --- agent/transports/chat_completions.py | 20 -- plugins/model-providers/deepseek/__init__.py | 83 +++++++- run_agent.py | 7 - scripts/release.py | 1 + .../model_providers/test_deepseek_profile.py | 184 ++++++++++++++++++ 5 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 tests/plugins/model_providers/test_deepseek_profile.py diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 1ae584e9159a..7edb69e42c74 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -189,7 +189,6 @@ class ChatCompletionsTransport(ProviderTransport): is_kimi: bool is_tokenhub: bool is_lmstudio: bool - is_deepseek: bool is_custom_provider: bool ollama_num_ctx: int | None # Provider routing @@ -349,25 +348,6 @@ class ChatCompletionsTransport(ProviderTransport): "type": "enabled" if _kimi_thinking_enabled else "disabled", } - # DeepSeek extra_body.thinking + top-level reasoning_effort - is_deepseek = params.get("is_deepseek", False) - if is_deepseek: - _ds_thinking_enabled = True - if reasoning_config and isinstance(reasoning_config, dict): - if reasoning_config.get("enabled") is False: - _ds_thinking_enabled = False - extra_body["thinking"] = { - "type": "enabled" if _ds_thinking_enabled else "disabled", - } - # DeepSeek effort: low/medium→high, high→high, xhigh/max→max - if _ds_thinking_enabled and reasoning_config: - _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in ("xhigh", "max"): - api_kwargs["reasoning_effort"] = "max" - elif _e in ("low", "medium", "high"): - api_kwargs["reasoning_effort"] = _e - # If no effort configured, don't set it → DeepSeek defaults to high - # Reasoning. LM Studio is handled above via top-level reasoning_effort, # so skip emitting extra_body.reasoning for it. if params.get("supports_reasoning", False) and not params.get("is_lmstudio", False): diff --git a/plugins/model-providers/deepseek/__init__.py b/plugins/model-providers/deepseek/__init__.py index 59d738f50fbc..f67146df113c 100644 --- a/plugins/model-providers/deepseek/__init__.py +++ b/plugins/model-providers/deepseek/__init__.py @@ -1,9 +1,88 @@ -"""DeepSeek provider profile.""" +"""DeepSeek provider profile. + +DeepSeek's V4 family (and the legacy ``deepseek-reasoner``) defaults to +thinking-mode ON when ``extra_body.thinking`` is unset. The API then returns +``reasoning_content`` and starts enforcing the contract that subsequent turns +echo it back; combined with how Hermes replays history this lands on the +notorious HTTP 400 ``reasoning_content must be passed back`` error after the +first tool call (#15700, #17212, #17825). + +This profile overrides :meth:`build_api_kwargs_extras` to mirror the Kimi / +Moonshot wire shape that DeepSeek's OpenAI-compat endpoint expects: + + {"reasoning_effort": "", + "extra_body": {"thinking": {"type": "enabled" | "disabled"}}} + +Non-thinking models (only ``deepseek-chat`` today, which is V3) are left as +no-ops so we don't perturb the V3 wire format. +""" + +from __future__ import annotations + +from typing import Any from providers import register_provider from providers.base import ProviderProfile -deepseek = ProviderProfile( + +def _model_supports_thinking(model: str | None) -> bool: + """DeepSeek thinking-capable model families. + + Currently covers the V4 family (``deepseek-v4-pro``, ``deepseek-v4-flash``, + and any future ``deepseek-v4-*`` variants) and the legacy + ``deepseek-reasoner`` (R1). ``deepseek-chat`` is V3 with no thinking mode. + """ + m = (model or "").strip().lower() + if not m: + return False + if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"): + # deepseek-v4-*, deepseek-v5-*, etc. — every V4+ generation has + # thinking. v3 explicitly excluded. + return True + if m == "deepseek-reasoner": + return True + return False + + +class DeepSeekProfile(ProviderProfile): + """DeepSeek — extra_body.thinking + top-level reasoning_effort.""" + + def build_api_kwargs_extras( + self, *, reasoning_config: dict | None = None, model: str | None = None, **context + ) -> tuple[dict[str, Any], dict[str, Any]]: + extra_body: dict[str, Any] = {} + top_level: dict[str, Any] = {} + + if not _model_supports_thinking(model): + # V3 / unknown — leave wire format untouched, current behavior. + return extra_body, top_level + + # Determine enabled/disabled. Default is enabled to match DeepSeek's + # API default; the API requires this to be set explicitly to avoid the + # reasoning_content echo trap on subsequent turns. + enabled = True + if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False: + enabled = False + + extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"} + + if not enabled: + return extra_body, top_level + + # Effort mapping. Pass low/medium/high through; xhigh/max → max. + # When no effort is set we omit reasoning_effort so DeepSeek applies + # its server default (currently high). + if isinstance(reasoning_config, dict): + effort = (reasoning_config.get("effort") or "").strip().lower() + if effort in ("xhigh", "max"): + top_level["reasoning_effort"] = "max" + elif effort in ("low", "medium", "high"): + top_level["reasoning_effort"] = effort + + return extra_body, top_level + + +deepseek = DeepSeekProfile( name="deepseek", aliases=("deepseek-chat",), env_vars=("DEEPSEEK_API_KEY",), diff --git a/run_agent.py b/run_agent.py index c9aa3157170f..2b20d48ede25 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9798,7 +9798,6 @@ class AIAgent: ) _is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com") _is_lmstudio = (self.provider or "").strip().lower() == "lmstudio" - _is_deepseek = base_url_host_matches(self.base_url, "api.deepseek.com") # Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE # sentinel (temperature omitted entirely), a numeric override, or None. @@ -9910,7 +9909,6 @@ class AIAgent: is_kimi=_is_kimi, is_tokenhub=_is_tokenhub, is_lmstudio=_is_lmstudio, - is_deepseek=_is_deepseek, is_custom_provider=self.provider == "custom", ollama_num_ctx=self._ollama_num_ctx, provider_preferences=_prefs or None, @@ -10370,11 +10368,6 @@ class AIAgent: # context compaction). Don't pass null to the API. api_msg.pop("reasoning_content", None) - # DeepSeek: strip reasoning_content on all assistant messages so the API - # doesn't return 400 when the model was invoked with thinking enabled. - if base_url_host_matches(self.base_url, "api.deepseek.com"): - api_msg.pop("reasoning_content", None) - @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: """Strip Codex Responses API fields from tool_calls for strict providers. diff --git a/scripts/release.py b/scripts/release.py index aafa626329e4..6084e0754c01 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -73,6 +73,7 @@ AUTHOR_MAP = { "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", "421774554@qq.com": "wuli666", + "twebefy@gmail.com": "tw2818", "harish.kukreja@gmail.com": "counterposition", "korkyzer@gmail.com": "Korkyzer", "1046611633@qq.com": "zhengyn0001", diff --git a/tests/plugins/model_providers/test_deepseek_profile.py b/tests/plugins/model_providers/test_deepseek_profile.py new file mode 100644 index 000000000000..c53e70070a81 --- /dev/null +++ b/tests/plugins/model_providers/test_deepseek_profile.py @@ -0,0 +1,184 @@ +"""Unit tests for the DeepSeek provider profile's thinking-mode wiring. + +DeepSeek V4 (and the legacy ``deepseek-reasoner``) expects every request to +carry an explicit ``extra_body.thinking`` parameter. Omitting it makes the +server default to thinking-mode ON, which then enforces the +``reasoning_content``-must-be-echoed-back contract on subsequent turns and +breaks the conversation with HTTP 400 (#15700, #17212, #17825). + +These tests pin the profile's wire-shape contract so DeepSeek requests stay +correctly shaped without going live. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def deepseek_profile(): + """Resolve the registered DeepSeek profile. + + Going through ``providers.get_provider_profile`` keeps the test honest — + if someone later replaces the registered class with a plain + ``ProviderProfile``, every assertion below collapses. + """ + # ``model_tools`` triggers plugin discovery on import, which is what + # registers the DeepSeek profile in the global provider registry. + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("deepseek") + assert profile is not None, "deepseek provider profile must be registered" + return profile + + +class TestDeepSeekThinkingWireShape: + """``build_api_kwargs_extras`` produces DeepSeek's exact wire format.""" + + def test_v4_pro_default_enables_thinking_without_effort(self, deepseek_profile): + """No reasoning_config → thinking enabled, server picks default effort.""" + extra_body, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config=None, model="deepseek-v4-pro" + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {} + + def test_v4_pro_enabled_with_high_effort(self, deepseek_profile): + extra_body, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model="deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": "high"} + + @pytest.mark.parametrize("effort", ["low", "medium", "high"]) + def test_standard_efforts_pass_through(self, deepseek_profile, effort): + _, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, + model="deepseek-v4-pro", + ) + assert top_level == {"reasoning_effort": effort} + + @pytest.mark.parametrize("effort", ["xhigh", "max", "MAX", " Max "]) + def test_xhigh_and_max_normalize_to_max(self, deepseek_profile, effort): + _, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, + model="deepseek-v4-pro", + ) + assert top_level == {"reasoning_effort": "max"} + + def test_explicitly_disabled_sends_disabled_marker(self, deepseek_profile): + """``reasoning_config.enabled=False`` → ``thinking.type=disabled``. + + The crucial bit is that the parameter is *sent* at all — DeepSeek + defaults to thinking-on when ``thinking`` is absent. + """ + extra_body, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": False}, model="deepseek-v4-pro" + ) + assert extra_body == {"thinking": {"type": "disabled"}} + # No effort when disabled — DeepSeek rejects it. + assert top_level == {} + + def test_disabled_ignores_effort_field(self, deepseek_profile): + """Effort silently dropped when thinking is off.""" + _, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": False, "effort": "high"}, + model="deepseek-v4-pro", + ) + assert top_level == {} + + def test_unknown_effort_omits_top_level(self, deepseek_profile): + """Garbage effort → omit reasoning_effort so DeepSeek applies its default.""" + _, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "garbage"}, + model="deepseek-v4-pro", + ) + assert top_level == {} + + def test_empty_effort_omits_top_level(self, deepseek_profile): + _, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": ""}, + model="deepseek-v4-pro", + ) + assert top_level == {} + + +class TestDeepSeekModelGating: + """V4 family + ``deepseek-reasoner`` get thinking; V3 stays untouched.""" + + @pytest.mark.parametrize( + "model", + [ + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-future-variant", + "deepseek-reasoner", + "DEEPSEEK-V4-PRO", # case-insensitive + ], + ) + def test_thinking_capable_models_emit_thinking(self, deepseek_profile, model): + extra_body, _ = deepseek_profile.build_api_kwargs_extras( + reasoning_config=None, model=model + ) + assert extra_body == {"thinking": {"type": "enabled"}} + + @pytest.mark.parametrize( + "model", + [ + "deepseek-chat", # V3 alias + "deepseek-v3-0324", # explicit V3 + "deepseek-v3.1", # V3 minor revisions + "", # bare/unknown + None, # missing + "deepseek-unknown", # unrecognized + ], + ) + def test_non_thinking_models_emit_nothing(self, deepseek_profile, model): + extra_body, top_level = deepseek_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, model=model + ) + assert extra_body == {} + assert top_level == {} + + +class TestDeepSeekFullKwargsIntegration: + """End-to-end: the transport's full kwargs match DeepSeek's live wire format. + + The live test harness in ``tests/run_agent/test_deepseek_v4_thinking_live.py`` + sends ``{"reasoning_effort": "high", "extra_body": {"thinking": {"type": + "enabled"}}}``. Confirm the transport produces that exact shape when wired + through the registered DeepSeek profile. + """ + + def test_full_kwargs_match_live_wire_shape(self, deepseek_profile): + from agent.transports.chat_completions import ChatCompletionsTransport + + kwargs = ChatCompletionsTransport().build_kwargs( + model="deepseek-v4-pro", + messages=[{"role": "user", "content": "ping"}], + tools=None, + provider_profile=deepseek_profile, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.deepseek.com/v1", + provider_name="deepseek", + ) + assert kwargs["model"] == "deepseek-v4-pro" + assert kwargs["reasoning_effort"] == "high" + assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}} + + def test_v3_chat_full_kwargs_omit_thinking(self, deepseek_profile): + from agent.transports.chat_completions import ChatCompletionsTransport + + kwargs = ChatCompletionsTransport().build_kwargs( + model="deepseek-chat", + messages=[{"role": "user", "content": "ping"}], + tools=None, + provider_profile=deepseek_profile, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.deepseek.com/v1", + provider_name="deepseek", + ) + assert "reasoning_effort" not in kwargs + assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {}) From dc4cde278ba0523c01c2c29988e59a567a19ef22 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 17:04:30 -0700 Subject: [PATCH 41/48] feat(docs): show per-skill pages in the left sidebar (#26646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Individual skill pages (e.g. /docs/user-guide/skills/bundled/productivity/notion) had no sidebar rendered — the sidebar config only listed the two catalog index pages. That was an intentional choice from an earlier 'too many entries would drown product docs' concern, but the effect is that a user landing on any skill page (via search, share link, or the catalog table) loses navigation entirely and can't see related skills. Wire build_sidebar_items() (which was already computed and discarded) back into the sidebar. Structure: Skills ├── Bundled skills catalog (catalog table, was already there) ├── Optional skills catalog (catalog table, was already there) ├── Bundled │ ├── apple/ │ │ ├── apple-apple-notes │ │ └── ... │ └── ... (one collapsed category per skill category) └── Optional └── ... (same) Categories are collapsed by default so the top-level Skills entry doesn't explode visually. Users browsing one skill see siblings in the same category; the catalogs remain the at-a-glance entry point. Also includes drift the regen script naturally produces on top of current main: - creative-comfyui v5.0.0 → v5.1.0 page (author + new ref file) - devops-kanban-worker SKILL.md updates - new pages for optional skills that lacked generated docs: hyperliquid, finance-stocks, software-development/rest-graphql-debug - updated optional-skills-catalog row for those Validation: - npx docusaurus build (en locale) succeeded — only pre-existing warnings - inspected built productivity-notion/index.html: sidebar tree present, sibling productivity skills (airtable, linear, etc.) all linked --- .../docs/reference/optional-skills-catalog.md | 8 + .../bundled/creative/creative-comfyui.md | 10 +- .../bundled/devops/devops-kanban-worker.md | 23 + .../blockchain/blockchain-hyperliquid.md | 228 ++++++++ .../skills/optional/finance/finance-stocks.md | 112 ++++ ...software-development-rest-graphql-debug.md | 531 ++++++++++++++++++ website/scripts/generate-skill-docs.py | 94 +++- website/sidebars.ts | 438 +++++++++++++++ 8 files changed, 1411 insertions(+), 33 deletions(-) create mode 100644 website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md create mode 100644 website/docs/user-guide/skills/optional/finance/finance-stocks.md create mode 100644 website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 8c4c2f364322..d5839f846d1c 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -39,6 +39,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| | [**evm**](/docs/user-guide/skills/optional/blockchain/blockchain-evm) | Read-only EVM client: wallets, tokens, gas across 8 chains. | +| [**hyperliquid**](/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid) | Hyperliquid market data, account history, trade review. | | [**solana**](/docs/user-guide/skills/optional/blockchain/blockchain-solana) | Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. | ## communication @@ -88,6 +89,7 @@ hermes skills uninstall | [**lbo-model**](/docs/user-guide/skills/optional/finance/finance-lbo-model) | Build leveraged buyout models in Excel — sources & uses, debt schedule, cash sweep, exit multiple, IRR/MOIC sensitivity. Pairs with excel-author. Use for PE screening, sponsor-case valuation, or illustrative LBO in a pitch. | | [**merger-model**](/docs/user-guide/skills/optional/finance/finance-merger-model) | Build accretion/dilution (merger) models in Excel — pro-forma P&L, synergies, financing mix, EPS impact. Pairs with excel-author. Use for M&A pitches, board materials, or deal evaluation. | | [**pptx-author**](/docs/user-guide/skills/optional/finance/finance-pptx-author) | Build PowerPoint decks headless with python-pptx. Pairs with excel-author for model-backed decks where every number traces to a workbook cell. Use for pitch decks, IC memos, earnings notes. | +| [**stocks**](/docs/user-guide/skills/optional/finance/finance-stocks) | Stock quotes, history, search, compare, crypto via Yahoo. | ## health @@ -176,6 +178,12 @@ hermes skills uninstall | [**oss-forensics**](/docs/user-guide/skills/optional/security/security-oss-forensics) | Supply chain investigation, evidence recovery, and forensic analysis for GitHub repositories. Covers deleted commit recovery, force-push detection, IOC extraction, multi-source evidence collection, hypothesis formation/validation, and st... | | [**sherlock**](/docs/user-guide/skills/optional/security/security-sherlock) | OSINT username search across 400+ social networks. Hunt down social media accounts by username. | +## software-development + +| Skill | Description | +|-------|-------------| +| [**rest-graphql-debug**](/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug) | Debug REST/GraphQL APIs: status codes, auth, schemas, repro. | + ## web-development | Skill | Description | diff --git a/website/docs/user-guide/skills/bundled/creative/creative-comfyui.md b/website/docs/user-guide/skills/bundled/creative/creative-comfyui.md index 7877e174c7a1..38610be8b83e 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-comfyui.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-comfyui.md @@ -16,8 +16,8 @@ Generate images, video, and audio with ComfyUI — install, launch, manage nodes |---|---| | Source | Bundled (installed by default) | | Path | `skills/creative/comfyui` | -| Version | `5.0.0` | -| Author | ['kshitijk4poor', 'alt-glitch'] | +| Version | `5.1.0` | +| Author | ['kshitijk4poor', 'alt-glitch', 'purzbeats'] | | License | MIT | | Platforms | macos, linux, windows | | Tags | `comfyui`, `image-generation`, `stable-diffusion`, `flux`, `sd3`, `wan-video`, `hunyuan-video`, `creative`, `generative-ai`, `video-generation` | @@ -42,6 +42,12 @@ for workflow execution. - `official-cli.md` — every `comfy ...` command, with flags - `rest-api.md` — REST + WebSocket endpoints (local + cloud), payload schemas - `workflow-format.md` — API-format JSON, common node types, param mapping +- `template-integrity.md` — converting `comfyui-workflow-templates` from + editor format to API format: Reroute bypass, dotted dynamic-input keys + (`values.a`, `resize_type.width`), Cloud quirks (302 redirect, 1 concurrent + free-tier job, 1080p VRAM ceiling), Discord-compatible ffmpeg stitch. + Authored by [@purzbeats](https://github.com/purzbeats). Load this whenever + you're starting from an official template. **Scripts (`scripts/`):** diff --git a/website/docs/user-guide/skills/bundled/devops/devops-kanban-worker.md b/website/docs/user-guide/skills/bundled/devops/devops-kanban-worker.md index dac9de9f174a..28d51c178873 100644 --- a/website/docs/user-guide/skills/bundled/devops/devops-kanban-worker.md +++ b/website/docs/user-guide/skills/bundled/devops/devops-kanban-worker.md @@ -65,6 +65,29 @@ kanban_complete( ) ``` +**Coding task that needs human review (review-required):** + +For most code-changing tasks, the work isn't truly *done* until a human reviewer has eyes on it. Block instead of complete, with `reason` prefixed `review-required: ` so the dashboard surfaces the row as needing review. Drop the structured metadata (changed files, test counts, diff/PR url) into a comment first, since `kanban_block` only carries the human-readable reason — comments are the durable annotation channel. Reviewer either approves and runs `hermes kanban unblock ` (which re-spawns you with the comment thread for any follow-ups) or asks for changes via another comment. + +```python +import json + +kanban_comment( + body="review-required handoff:\n" + json.dumps({ + "changed_files": ["rate_limiter.py", "tests/test_rate_limiter.py"], + "tests_run": 14, + "tests_passed": 14, + "diff_path": "/path/to/worktree", # or PR url if pushed + "decisions": ["user_id primary, IP fallback for unauthenticated requests"], + }, indent=2), +) +kanban_block( + reason="review-required: rate limiter shipped, 14/14 tests pass — needs eyes on the user_id/IP fallback choice before merging", +) +``` + +Use `kanban_complete` only when the task is genuinely terminal — e.g. a one-line typo fix, a docs change with no functional consequences, or a research task where the artifact IS the writeup itself. + **Research task:** ```python kanban_complete( diff --git a/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md new file mode 100644 index 000000000000..8651bc979f66 --- /dev/null +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md @@ -0,0 +1,228 @@ +--- +title: "Hyperliquid — Hyperliquid market data, account history, trade review" +sidebar_label: "Hyperliquid" +description: "Hyperliquid market data, account history, trade review" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Hyperliquid + +Hyperliquid market data, account history, trade review. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/blockchain/hyperliquid` | +| Path | `optional-skills/blockchain/hyperliquid` | +| Version | `0.1.0` | +| Author | Hugo Sequier (Hugo-SEQUIER), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `Hyperliquid`, `Blockchain`, `Crypto`, `Trading`, `Perpetuals`, `Spot`, `DeFi` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Hyperliquid Skill + +Query Hyperliquid market and account data through the public `/info` endpoint. +Read-only — no API key, no signing, no order placement. + +12 commands: `dexs`, `markets`, `spots`, `candles`, `funding`, `l2`, `state`, +`spot-balances`, `fills`, `orders`, `review`, `export`. Stdlib only +(`urllib`, `json`, `argparse`). + +--- + +## When to Use + +- User asks for Hyperliquid perp or spot market data, candles, funding, or L2 book +- User wants to inspect a wallet's perp positions, spot balances, fills, or orders +- User wants a post-trade review combining recent fills with market context +- User wants to inspect builder-deployed perp dexs or HIP-3 markets +- User wants a normalized JSON export of candles + funding for backtesting prep + +--- + +## Prerequisites + +Stdlib only — no external packages, no API key. + +The script reads `~/.hermes/.env` for two optional defaults: + +- `HYPERLIQUID_API_URL` — defaults to `https://api.hyperliquid.xyz`. Set to + `https://api.hyperliquid-testnet.xyz` for testnet. +- `HYPERLIQUID_USER_ADDRESS` — default address for `state`, `spot-balances`, + `fills`, `orders`, and `review`. If unset, pass the address as the first + positional argument. + +A project `.env` in the current working directory is honored as a dev fallback. + +Helper script: `~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py` + +--- + +## How to Run + +Invoke through the `terminal` tool: + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py [args] +``` + +Add `--json` to any command for machine-readable output. + +--- + +## Quick Reference + +```bash +hyperliquid_client.py dexs +hyperliquid_client.py markets [--dex DEX] [--limit N] [--sort volume|oi|funding_abs|change_abs|name] +hyperliquid_client.py spots [--limit N] +hyperliquid_client.py candles [--interval 1h] [--hours 24] [--limit N] +hyperliquid_client.py funding [--hours 72] [--limit N] +hyperliquid_client.py l2 [--levels N] +hyperliquid_client.py state [address] [--dex DEX] +hyperliquid_client.py spot-balances [address] [--limit N] +hyperliquid_client.py fills [address] [--hours N] [--limit N] [--aggregate-by-time] +hyperliquid_client.py orders [address] [--limit N] +hyperliquid_client.py review [address] [--coin COIN] [--hours N] [--fills N] +hyperliquid_client.py export [--interval 1h] [--hours N] [--output PATH] +``` + +For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is +optional when `HYPERLIQUID_USER_ADDRESS` is set in `~/.hermes/.env`. + +--- + +## Procedure + +### 1. Discover DEXs and Markets + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py dexs + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 15 --sort volume + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spots --limit 15 +``` + +- `--dex` only applies to perp endpoints; omit for the first perp dex. +- Spot pairs may show as `PURR/USDC` or aliases like `@107`. +- HIP-3 markets prefix the coin with the dex, e.g. `mydex:BTC`. + +### 2. Pull Historical Market Data + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + candles BTC --interval 1h --hours 72 --limit 48 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + funding BTC --hours 168 --limit 30 +``` + +Time-range endpoints paginate. For larger windows, repeat with a later +`startTime` or use `export` (below). + +### 3. Inspect Live Order Book + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + l2 BTC --levels 10 +``` + +Use when asked about book depth, near-term liquidity, or potential market +impact of a large order. + +### 4. Review an Account + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + state 0xabc... + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spot-balances +``` + +`state` returns perp positions; `spot-balances` returns spot inventory. +Use these for "how are my positions?", "what am I holding?", "how much is +withdrawable?". + +### 5. Review Fills and Orders + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + fills 0xabc... --hours 72 --limit 25 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + orders --limit 25 +``` + +### 6. Generate a Trade Review + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review 0xabc... --hours 72 --fills 50 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review --coin BTC --hours 168 +``` + +Reports realized PnL, fees, win/loss counts, coin breakdowns, market trend +and average funding for each traded perp, plus heuristics (fee drag, +concentration, counter-trend losses). + +For deeper post-trade analysis: start with `review` to find problem coins +or windows → pull `fills` and `orders` for that period → pull `candles` +and `funding` for each traded coin → judge decision quality separately +from outcome quality. + +### 7. Export a Reusable Dataset + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 1h --hours 168 --output ./btc-1h-7d.json + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 15m --hours 72 --end-time-ms 1760000000000 +``` + +Output JSON contains: schema version, source metadata, exact time window, +normalized candle rows, normalized funding rows, summary stats. Use +`--end-time-ms` for reproducible windows. + +--- + +## Pitfalls + +- Public info endpoints are rate-limited. Large historical queries may + return capped windows; iterate with later `startTime` values. +- `fills --hours ...` uses `userFillsByTime`, which only exposes a + recent rolling window — not full archive history. +- `historicalOrders` returns recent orders only; not a full export. +- The `review` command is heuristic. It cannot reconstruct intent, + order placement quality, or true slippage from fills alone. +- The `export` command writes a normalized dataset, not a backtest + engine. You still need your own slippage/fill model. +- Spot aliases like `@107` are valid identifiers even when the UI shows + a friendlier name. +- `l2` is a point-in-time snapshot, not a time series. + +--- + +## Verification + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 5 +``` + +Should print the top Hyperliquid perp markets by 24h notional volume. diff --git a/website/docs/user-guide/skills/optional/finance/finance-stocks.md b/website/docs/user-guide/skills/optional/finance/finance-stocks.md new file mode 100644 index 000000000000..7c43dea3065e --- /dev/null +++ b/website/docs/user-guide/skills/optional/finance/finance-stocks.md @@ -0,0 +1,112 @@ +--- +title: "Stocks — Stock quotes, history, search, compare, crypto via Yahoo" +sidebar_label: "Stocks" +description: "Stock quotes, history, search, compare, crypto via Yahoo" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Stocks + +Stock quotes, history, search, compare, crypto via Yahoo. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/finance/stocks` | +| Path | `optional-skills/finance/stocks` | +| Version | `0.1.0` | +| Author | Mibay (Mibayy), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `Stocks`, `Finance`, `Market`, `Crypto`, `Investing` | +| Related skills | [`dcf-model`](/docs/user-guide/skills/optional/finance/finance-dcf-model), [`comps-analysis`](/docs/user-guide/skills/optional/finance/finance-comps-analysis), [`lbo-model`](/docs/user-guide/skills/optional/finance/finance-lbo-model) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Stocks Skill + +Read-only market data via Yahoo Finance. Five commands: `quote`, `search`, +`history`, `compare`, `crypto`. Python stdlib only — no API key, no pip +installs. Yahoo's endpoint is unofficial and may rate-limit or change. + +## When to Use + +- User asks for a current stock price (AAPL, TSLA, MSFT, ...) +- User wants to look up a ticker by company name +- User wants OHLCV history or performance over a date range +- User wants to compare several tickers side by side +- User asks for a crypto price (BTC, ETH, SOL, ...) + +## Prerequisites + +Python 3.8+ stdlib only. Optional: set `ALPHA_VANTAGE_KEY` to enrich +`market_cap`, `pe_ratio`, and 52-week levels when Yahoo's crumb-protected +fields come back null. Free key: https://www.alphavantage.co/support/#api-key + +## How to Run + +Invoke through the `terminal` tool. Once installed: + +``` +SCRIPT=~/.hermes/skills/finance/stocks/scripts/stocks_client.py +python3 $SCRIPT quote AAPL +``` + +All output is JSON on stdout — pipe through `jq` if you want to slice it. + +## Quick Reference + +``` +python3 $SCRIPT quote AAPL +python3 $SCRIPT quote AAPL MSFT GOOGL TSLA +python3 $SCRIPT search "Tesla" +python3 $SCRIPT history NVDA --range 6mo +python3 $SCRIPT compare AAPL MSFT GOOGL +python3 $SCRIPT crypto BTC ETH SOL +``` + +## Commands + +### `quote SYMBOL [SYMBOL2 ...]` + +Current price, change, change%, volume, 52-week high/low. + +### `search QUERY` + +Find tickers by company name. Returns top 5: symbol, name, exchange, type. + +### `history SYMBOL [--range RANGE]` + +Daily OHLCV plus stats (min, max, avg, total return %). Ranges: `1mo`, +`3mo`, `6mo`, `1y`, `5y`. Default: `1mo`. + +### `compare SYMBOL1 SYMBOL2 [...]` + +Side-by-side: price, change%, 52-week performance. + +### `crypto SYMBOL [SYMBOL2 ...]` + +Crypto prices. Pass `BTC` (the script appends `-USD` automatically). + +## Pitfalls + +- Yahoo Finance's API is unofficial. Endpoints can change or rate-limit + without notice — if requests start failing, that's why. +- `market_cap` and `pe_ratio` may return null on `quote` when Yahoo's + crumb session isn't established. Set `ALPHA_VANTAGE_KEY` to backfill. +- Add a small delay between bulk requests to avoid rate-limiting. +- This is read-only — no order placement, no account integration. + +## Verification + +``` +python3 ~/.hermes/skills/finance/stocks/scripts/stocks_client.py quote AAPL +``` + +Returns a JSON object with `symbol: "AAPL"` and a numeric `price` field. diff --git a/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md new file mode 100644 index 000000000000..0698d855f5f5 --- /dev/null +++ b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md @@ -0,0 +1,531 @@ +--- +title: "Rest Graphql Debug — Debug REST/GraphQL APIs: status codes, auth, schemas, repro" +sidebar_label: "Rest Graphql Debug" +description: "Debug REST/GraphQL APIs: status codes, auth, schemas, repro" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Rest Graphql Debug + +Debug REST/GraphQL APIs: status codes, auth, schemas, repro. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/software-development/rest-graphql-debug` | +| Path | `optional-skills/software-development/rest-graphql-debug` | +| Version | `1.2.0` | +| Author | eren-karakus0 | +| License | MIT | +| Tags | `api`, `rest`, `graphql`, `http`, `debugging`, `testing`, `curl`, `integration` | +| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`test-driven-development`](/docs/user-guide/skills/bundled/software-development/software-development-test-driven-development) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# API Testing & Debugging + +Drive REST and GraphQL diagnosis through Hermes tools — `terminal` for `curl`, `execute_code` for Python `requests`, `web_extract` for vendor docs. Isolate the failing layer before guessing at the fix. + +## When to Use + +- API returns unexpected status or body +- Auth fails (401/403 after token refresh, OAuth, API key) +- Works in Postman but fails in code +- Webhook / callback integration debugging +- Building or reviewing API integration tests +- Rate limiting or pagination issues + +Skip for UI rendering, DB query tuning, or DNS/firewall infra (escalate). + +## Core Principle + +**Isolate the layer, then fix.** A 200 OK can hide broken data. A 500 can mask a one-character auth typo. Walk the chain in order; never skip a step. + +``` +1. Connectivity → can we reach the host at all? +1.5 Timeouts → connect-slow vs read-slow? +2. TLS/SSL → cert valid and trusted? +3. Auth → credentials correct and unexpired? +4. Request format → payload shape match server expectations? +5. Response parse → does our code accept what came back? +6. Semantics → does the data mean what we assume? +``` + +## 5-Minute Quickstart + +### REST via terminal + +```python +# Verbose request/response exchange +terminal('curl -v https://api.example.com/users/1') + +# POST with JSON +terminal("""curl -X POST https://api.example.com/users \\ + -H 'Content-Type: application/json' \\ + -H "Authorization: Bearer $TOKEN" \\ + -d '{"name":"test","email":"test@example.com"}'""") + +# Headers only +terminal('curl -sI https://api.example.com/health') + +# Pretty-print JSON +terminal('curl -s https://api.example.com/users | python3 -m json.tool') +``` + +### GraphQL via terminal + +```python +terminal("""curl -X POST https://api.example.com/graphql \\ + -H 'Content-Type: application/json' \\ + -H "Authorization: Bearer $TOKEN" \\ + -d '{"query":"{ user(id: 1) { name email } }"}'""") +``` + +**GraphQL gotcha:** servers often return HTTP 200 even when the query failed. Always inspect the `errors` field regardless of status code: + +```python +execute_code(''' +import os, requests +resp = requests.post( + "https://api.example.com/graphql", + json={"query": "{ user(id: 1) { name email } }"}, + headers={"Authorization": f"Bearer {os.environ['TOKEN']}"}, + timeout=10, +) +data = resp.json() +if data.get("errors"): + for err in data["errors"]: + print(f"GraphQL error: {err['message']} (path: {err.get('path')})") +print(data.get("data")) +''') +``` + +### Python (requests) via execute_code + +```python +execute_code(''' +import requests +resp = requests.get( + "https://api.example.com/users/1", + headers={"Authorization": "Bearer "}, + timeout=(3.05, 30), # (connect, read) +) +print(resp.status_code, dict(resp.headers)) +print(resp.text[:500]) +''') +``` + +## Layered Debug Flow + +### Step 1 — Connectivity + +```python +terminal('nslookup api.example.com') +terminal('curl -v --connect-timeout 5 https://api.example.com/health') +``` + +Failures: DNS not resolving, firewall, VPN required, proxy missing. + +### Step 1.5 — Timeouts + +Distinguish *can't reach* from *reaches but slow*: + +```python +terminal('''curl -w "dns:%{time_namelookup}s connect:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\\n" \\ + -o /dev/null -s https://api.example.com/endpoint''') +``` + +In Python, always pass a tuple timeout — `requests` has no default and will hang forever: + +```python +execute_code(''' +import requests +from requests.exceptions import ConnectTimeout, ReadTimeout +try: + requests.get(url, timeout=(3.05, 30)) +except ConnectTimeout: + print("Cannot reach host — DNS, firewall, VPN") +except ReadTimeout: + print("Connected but server is slow") +''') +``` + +Diagnosis: high `time_connect` is network/firewall; high `time_starttransfer` with low `time_connect` is a slow server. + +### Step 2 — TLS/SSL + +```python +terminal('curl -vI https://api.example.com 2>&1 | grep -E "SSL|subject|expire|issuer"') +``` + +Failures: expired cert, self-signed, hostname mismatch, missing CA bundle. Use `-k` only for ad-hoc debug, never in code. + +### Step 3 — Authentication + +```python +# Token validity check +terminal('curl -s -o /dev/null -w "%{http_code}\\n" -H "Authorization: Bearer $TOKEN" https://api.example.com/me') + +# Decode JWT exp claim — handles base64url padding correctly +execute_code(''' +import json, base64, os +tok = os.environ["TOKEN"] +payload = tok.split(".")[1] +payload += "=" * (-len(payload) % 4) +print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2)) +''') +``` + +Checklist: +- Token expired? (`exp` claim in JWT) +- Right scheme? Bearer vs Basic vs Token vs `X-Api-Key` +- Right environment? Staging key on prod is a classic +- API key in header vs query param (`?api_key=…`)? + +### Step 4 — Request Format + +```python +terminal("""curl -v -X POST https://api.example.com/endpoint \\ + -H 'Content-Type: application/json' \\ + -d '{"key":"value"}' 2>&1""") +``` + +**Content-Type / body mismatch — the silent 415/400:** + +```python +# WRONG — data= sends form-encoded, header lies +requests.post(url, data='{"k":"v"}', headers={"Content-Type": "application/json"}) + +# RIGHT — json= auto-sets header AND serializes +requests.post(url, json={"k": "v"}) + +# WRONG — Accept says XML, code calls .json() +requests.get(url, headers={"Accept": "text/xml"}) + +# RIGHT — let requests build multipart with boundary +requests.post(url, files={"file": open("doc.pdf", "rb")}) +``` + +Common: form-encoded vs JSON, missing required fields, wrong HTTP method, unencoded query params. + +### Step 5 — Response Parsing + +Always inspect content-type before calling `.json()`: + +```python +execute_code(''' +import requests +resp = requests.post(url, json=payload, timeout=10) +print(f"status={resp.status_code}") +print(f"headers={dict(resp.headers)}") +ct = resp.headers.get("Content-Type", "") +if "application/json" in ct: + print(resp.json()) +else: + print(f"unexpected content-type {ct!r}, body={resp.text[:500]!r}") +''') +``` + +Failures: HTML error page where JSON expected, empty body, wrong charset. + +### Step 6 — Semantic Validation + +Parsed cleanly — but is the data *correct*? + +- Does `"status": "active"` mean what your code thinks? +- ID in response matches the one requested? +- Timestamps in expected timezone? +- Pagination returning all results, or just page 1? + +## HTTP Status Playbook + +### 401 Unauthorized — credentials missing or invalid + +1. `Authorization` header actually present? (`curl -v` to confirm) +2. Token correct and unexpired? +3. Right auth scheme? (`Bearer` vs `Basic` vs `Token`) +4. Some APIs use query param (`?api_key=…`) instead of header. + +### 403 Forbidden — authenticated but not authorized + +1. Token has the required scopes/permissions? +2. Resource owned by a different account? +3. IP allowlist blocking you? +4. CORS in browser? (check `Access-Control-Allow-Origin`) + +### 404 Not Found — resource doesn't exist or URL is wrong + +1. Path correct? (trailing slash, typo, version prefix) +2. Resource ID exists? +3. Right API version (`/v1/` vs `/v2/`)? +4. Right base URL (staging vs prod)? + +### 409 Conflict — state collision + +1. Resource already exists (duplicate create)? +2. Stale `ETag` / `If-Match`? +3. Concurrent modification by another process? + +### 422 Unprocessable Entity — valid JSON, invalid data + +The error body usually names the bad fields. Check: +- Field types (string vs int, date format) +- Required vs optional +- Enum values inside the allowed set + +### 429 Too Many Requests — rate limited + +Check `Retry-After` and `X-RateLimit-*` headers. Exponential backoff: + +```python +execute_code(''' +import time, requests + +def with_backoff(method, url, **kwargs): + for attempt in range(5): + resp = requests.request(method, url, **kwargs) + if resp.status_code != 429: + return resp + wait = int(resp.headers.get("Retry-After", 2 ** attempt)) + time.sleep(wait) + return resp +''') +``` + +### 5xx — server-side, usually not your fault + +- **500** — server bug. Capture correlation ID, file with provider. +- **502** — upstream down. Backoff + retry. +- **503** — overloaded / maintenance. Check status page. +- **504** — upstream timeout. Reduce payload or raise timeout. + +For all 5xx: backoff with jitter, alert on persistence. + +## Pagination & Idempotency + +**Pagination.** Verify you're getting *all* results. Look for `next_cursor`, `next_page`, `total_count`. Two patterns: +- Offset (`?limit=100&offset=200`) — simple, can skip items if data shifts. +- Cursor (`?cursor=abc123`) — preferred for live or large datasets. + +**Idempotency.** For non-idempotent operations (POST), send `Idempotency-Key: ` so retries don't double-charge / double-create. Mandatory for payments and orders. + +## Contract Validation + +Catch schema drift before it hits production: + +```python +execute_code(''' +import requests + +def validate_user(data: dict) -> list[str]: + errors = [] + required = {"id": int, "email": str, "created_at": str} + for field, expected in required.items(): + if field not in data: + errors.append(f"missing field: {field}") + elif not isinstance(data[field], expected): + errors.append(f"{field}: want {expected.__name__}, got {type(data[field]).__name__}") + return errors + +resp = requests.get(f"{BASE}/users/1", headers=HEADERS, timeout=10) +issues = validate_user(resp.json()) +if issues: + print(f"contract violations: {issues}") +''') +``` + +Run after API upgrades, when integrating new third parties, or in CI smoke tests. + +## Correlation IDs + +Always capture the provider's request ID — fastest path to vendor support: + +```python +execute_code(''' +import requests +resp = requests.post(url, json=payload, headers=headers, timeout=10) +request_id = ( + resp.headers.get("X-Request-Id") + or resp.headers.get("X-Trace-Id") + or resp.headers.get("CF-Ray") # Cloudflare +) +if resp.status_code >= 400: + print(f"failed status={resp.status_code} req_id={request_id} ts={resp.headers.get('Date')}") +''') +``` + +**Vendor bug-report template:** + +``` +Endpoint: POST /api/v1/orders +Request ID: req_abc123xyz +Timestamp: 2026-03-17T14:30:00Z +Status: 500 +Expected: 201 with order object +Actual: 500 {"error":"internal server error"} +Repro: curl -X POST … (auth: ) +``` + +## Regression Test Template + +Drop this into `tests/` and run via `terminal('pytest tests/test_api_smoke.py -v')`: + +```python +import os, requests, pytest + +BASE_URL = os.environ.get("API_BASE_URL", "https://api.example.com") +TOKEN = os.environ.get("API_TOKEN", "") +HEADERS = {"Authorization": f"Bearer {TOKEN}"} + +class TestAPISmoke: + def test_health(self): + resp = requests.get(f"{BASE_URL}/health", timeout=5) + assert resp.status_code == 200 + + def test_list_users_returns_array(self): + resp = requests.get(f"{BASE_URL}/users", headers=HEADERS, timeout=10) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data.get("data", data), list) + + def test_get_user_required_fields(self): + resp = requests.get(f"{BASE_URL}/users/1", headers=HEADERS, timeout=10) + assert resp.status_code in (200, 404) + if resp.status_code == 200: + user = resp.json() + assert "id" in user and "email" in user + + def test_invalid_auth_returns_401(self): + resp = requests.get( + f"{BASE_URL}/users", + headers={"Authorization": "Bearer invalid-token"}, + timeout=10, + ) + assert resp.status_code == 401 +``` + +## Security + +### Token handling +- Never log full tokens. Redact: `Bearer `. +- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `~/.hermes/.env`. +- Rotate immediately if a token surfaces in logs, error messages, or git history. + +### Safe logging + +```python +def redact_auth(headers: dict) -> dict: + sensitive = {"authorization", "x-api-key", "cookie", "set-cookie"} + return {k: ("" if k.lower() in sensitive else v) for k, v in headers.items()} +``` + +### Leak checklist + +- [ ] **Credentials in URLs.** API keys in query strings end up in server logs, browser history, referrer headers — use headers. +- [ ] **PII in error responses.** `404 on /users/123` shouldn't reveal whether the user exists (enumeration). +- [ ] **Stack traces in prod.** 500s shouldn't leak file paths, framework versions. +- [ ] **Internal hostnames/IPs.** `10.x.x.x`, `internal-api.corp.local` in error bodies. +- [ ] **Tokens echoed back.** Some APIs include the auth token in error details. Verify they don't. +- [ ] **Verbose `Server` / `X-Powered-By`.** Stack-info leaks. Note for security review. + +## Hermes Tool Patterns + +### terminal — for curl, dig, openssl + +```python +terminal('curl -sI https://api.example.com') +terminal('openssl s_client -connect api.example.com:443 -servername api.example.com /dev/null | openssl x509 -noout -dates') +``` + +### execute_code — for multi-step Python flows + +When debugging spans auth → fetch → paginate → validate, use `execute_code`. Variables persist for the script, results print to stdout, no risk of token spam in your context: + +```python +execute_code(''' +import os, requests + +token = os.environ["API_TOKEN"] +base = "https://api.example.com" +H = {"Authorization": f"Bearer {token}"} + +# 1. auth +me = requests.get(f"{base}/me", headers=H, timeout=10) +print(f"auth {me.status_code}") + +# 2. paginate +all_users, cursor = [], None +while True: + params = {"cursor": cursor} if cursor else {} + r = requests.get(f"{base}/users", headers=H, params=params, timeout=10) + body = r.json() + all_users.extend(body["data"]) + cursor = body.get("next_cursor") + if not cursor: + break +print(f"users={len(all_users)}") +''') +``` + +### web_extract — for vendor API docs + +Pull the spec for the endpoint you're debugging instead of guessing: + +```python +web_extract(urls=["https://docs.example.com/api/v1/users"]) +``` + +### delegate_task — for full CRUD test sweeps + +```python +delegate_task( + goal="Test all CRUD endpoints for /api/v1/users", + context=""" +Follow the rest-graphql-debug skill (optional-skills/software-development/rest-graphql-debug). +Base URL: https://api.example.com +Auth: Bearer token from API_TOKEN env var. + +For each verb (POST, GET, PATCH, DELETE): + - happy path: assert status + response schema + - error cases: 400, 404, 422 + - log a repro curl for any failure (redact tokens) + +Output: pass/fail per endpoint + correlation IDs for failures. +""", + toolsets=["terminal", "file"], +) +``` + +## Output Format + +When reporting findings: + +``` +## Finding +Endpoint: POST /api/v1/users +Status: 422 Unprocessable Entity +Req ID: req_abc123xyz + +## Repro +curl -X POST https://api.example.com/api/v1/users \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"name":"test"}' + +## Root Cause +Missing required field `email`. Server validation rejects before processing. + +## Fix +-d '{"name":"test","email":"test@example.com"}' +``` + +## Related + +- `systematic-debugging` — once the failing API layer is isolated, root-cause your code +- `test-driven-development` — write the regression test before shipping the fix diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py index d55c6e55c31f..2a0694a61c88 100755 --- a/website/scripts/generate-skill-docs.py +++ b/website/scripts/generate-skill-docs.py @@ -622,38 +622,70 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> } -def write_sidebar(entries): - # The per-skill pages (`build_sidebar_items(entries)`) are still generated - # as standalone docs under `website/docs/user-guide/skills/{bundled,optional}/` - # and reachable via the catalog pages in Reference — but we intentionally - # do NOT explode them into the left sidebar. Two hundred-plus skill entries - # drown the actual product docs and make the site feel overwhelming to - # first-time visitors. - # - # Sidebar now shows: - # Skills - # ├── Bundled catalog → (link to reference/skills-catalog) - # └── Optional catalog → (link to reference/optional-skills-catalog) - # - # The catalog pages are auto-regenerated tables with a link to every skill. - # Individual skill pages (including the two formerly hand-written guides, - # godmode and google-workspace) are still reachable at their URLs and are - # linked from the catalog tables and from the Skills overview page — they - # just aren't promoted in the left sidebar, because there's no principled - # rule for which skills would get promoted and which wouldn't. - _ = build_sidebar_items(entries) # still called for any side effects / validation +def _render_sidebar_item(item: Any, indent: int) -> list[str]: + """Render one sidebar item (string doc id, or category dict) as ts lines.""" + pad = " " * indent + lines: list[str] = [] + if isinstance(item, str): + lines.append(f"{pad}'{item}',") + return lines + # category dict + lines.append(f"{pad}{{") + lines.append(f"{pad} type: 'category',") + lines.append(f"{pad} label: '{item['label']}',") + if item.get("collapsed", True): + lines.append(f"{pad} collapsed: true,") + lines.append(f"{pad} items: [") + for child in item.get("items", []): + lines.extend(_render_sidebar_item(child, indent + 4)) + lines.append(f"{pad} ],") + lines.append(f"{pad}}},") + return lines - skills_subtree = ( - " {\n" - " type: 'category',\n" - " label: 'Skills',\n" - " collapsed: true,\n" - " items: [\n" - " 'reference/skills-catalog',\n" - " 'reference/optional-skills-catalog',\n" - " ],\n" - " },\n" - ) + +def write_sidebar(entries): + # Sidebar layout: + # Skills + # ├── reference/skills-catalog + # ├── reference/optional-skills-catalog + # ├── Bundled + # │ ├── apple/ + # │ │ ├── apple-apple-notes + # │ │ └── ... + # │ └── ... + # └── Optional + # └── ... + # + # The two catalog index pages stay at the top of the Skills section so + # the at-a-glance table view is one click away, and the per-category + # subtrees give individual skill pages real sidebar navigation when + # users land on them directly. + tree = build_sidebar_items(entries) + + skills_block: list[dict[str, Any]] = [ + { + "label": "Bundled", + "collapsed": True, + "items": tree["bundled_categories"], + }, + { + "label": "Optional", + "collapsed": True, + "items": tree["optional_categories"], + }, + ] + skills_items: list[Any] = [ + "reference/skills-catalog", + "reference/optional-skills-catalog", + *skills_block, + ] + + skills_top = { + "label": "Skills", + "collapsed": True, + "items": skills_items, + } + skills_subtree = "\n".join(_render_sidebar_item(skills_top, 8)) + "\n" sidebar_path = REPO / "website" / "sidebars.ts" text = sidebar_path.read_text(encoding="utf-8") diff --git a/website/sidebars.ts b/website/sidebars.ts index f0a0658c3bfd..fe7b741eb2eb 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -113,6 +113,444 @@ const sidebars: SidebarsConfig = { items: [ 'reference/skills-catalog', 'reference/optional-skills-catalog', + { + type: 'category', + label: 'Bundled', + collapsed: true, + items: [ + { + type: 'category', + label: 'apple', + collapsed: true, + items: [ + 'user-guide/skills/bundled/apple/apple-apple-notes', + 'user-guide/skills/bundled/apple/apple-apple-reminders', + 'user-guide/skills/bundled/apple/apple-findmy', + 'user-guide/skills/bundled/apple/apple-imessage', + 'user-guide/skills/bundled/apple/apple-macos-computer-use', + ], + }, + { + type: 'category', + label: 'autonomous-ai-agents', + collapsed: true, + items: [ + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code', + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex', + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent', + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode', + ], + }, + { + type: 'category', + label: 'creative', + collapsed: true, + items: [ + 'user-guide/skills/bundled/creative/creative-architecture-diagram', + 'user-guide/skills/bundled/creative/creative-ascii-art', + 'user-guide/skills/bundled/creative/creative-ascii-video', + 'user-guide/skills/bundled/creative/creative-baoyu-comic', + 'user-guide/skills/bundled/creative/creative-baoyu-infographic', + 'user-guide/skills/bundled/creative/creative-claude-design', + 'user-guide/skills/bundled/creative/creative-comfyui', + 'user-guide/skills/bundled/creative/creative-creative-ideation', + 'user-guide/skills/bundled/creative/creative-design-md', + 'user-guide/skills/bundled/creative/creative-excalidraw', + 'user-guide/skills/bundled/creative/creative-humanizer', + 'user-guide/skills/bundled/creative/creative-manim-video', + 'user-guide/skills/bundled/creative/creative-p5js', + 'user-guide/skills/bundled/creative/creative-pixel-art', + 'user-guide/skills/bundled/creative/creative-popular-web-designs', + 'user-guide/skills/bundled/creative/creative-pretext', + 'user-guide/skills/bundled/creative/creative-sketch', + 'user-guide/skills/bundled/creative/creative-songwriting-and-ai-music', + 'user-guide/skills/bundled/creative/creative-touchdesigner-mcp', + ], + }, + { + type: 'category', + label: 'data-science', + collapsed: true, + items: [ + 'user-guide/skills/bundled/data-science/data-science-jupyter-live-kernel', + ], + }, + { + type: 'category', + label: 'devops', + collapsed: true, + items: [ + 'user-guide/skills/bundled/devops/devops-kanban-orchestrator', + 'user-guide/skills/bundled/devops/devops-kanban-worker', + 'user-guide/skills/bundled/devops/devops-webhook-subscriptions', + ], + }, + { + type: 'category', + label: 'dogfood', + collapsed: true, + items: [ + 'user-guide/skills/bundled/dogfood/dogfood-dogfood', + ], + }, + { + type: 'category', + label: 'email', + collapsed: true, + items: [ + 'user-guide/skills/bundled/email/email-himalaya', + ], + }, + { + type: 'category', + label: 'gaming', + collapsed: true, + items: [ + 'user-guide/skills/bundled/gaming/gaming-minecraft-modpack-server', + 'user-guide/skills/bundled/gaming/gaming-pokemon-player', + ], + }, + { + type: 'category', + label: 'github', + collapsed: true, + items: [ + 'user-guide/skills/bundled/github/github-codebase-inspection', + 'user-guide/skills/bundled/github/github-github-auth', + 'user-guide/skills/bundled/github/github-github-code-review', + 'user-guide/skills/bundled/github/github-github-issues', + 'user-guide/skills/bundled/github/github-github-pr-workflow', + 'user-guide/skills/bundled/github/github-github-repo-management', + ], + }, + { + type: 'category', + label: 'mcp', + collapsed: true, + items: [ + 'user-guide/skills/bundled/mcp/mcp-native-mcp', + ], + }, + { + type: 'category', + label: 'media', + collapsed: true, + items: [ + 'user-guide/skills/bundled/media/media-gif-search', + 'user-guide/skills/bundled/media/media-heartmula', + 'user-guide/skills/bundled/media/media-songsee', + 'user-guide/skills/bundled/media/media-spotify', + 'user-guide/skills/bundled/media/media-youtube-content', + ], + }, + { + type: 'category', + label: 'mlops', + collapsed: true, + items: [ + 'user-guide/skills/bundled/mlops/mlops-models-audiocraft', + 'user-guide/skills/bundled/mlops/mlops-research-dspy', + 'user-guide/skills/bundled/mlops/mlops-huggingface-hub', + 'user-guide/skills/bundled/mlops/mlops-inference-llama-cpp', + 'user-guide/skills/bundled/mlops/mlops-evaluation-lm-evaluation-harness', + 'user-guide/skills/bundled/mlops/mlops-inference-obliteratus', + 'user-guide/skills/bundled/mlops/mlops-models-segment-anything', + 'user-guide/skills/bundled/mlops/mlops-inference-vllm', + 'user-guide/skills/bundled/mlops/mlops-evaluation-weights-and-biases', + ], + }, + { + type: 'category', + label: 'note-taking', + collapsed: true, + items: [ + 'user-guide/skills/bundled/note-taking/note-taking-obsidian', + ], + }, + { + type: 'category', + label: 'productivity', + collapsed: true, + items: [ + 'user-guide/skills/bundled/productivity/productivity-airtable', + 'user-guide/skills/bundled/productivity/productivity-google-workspace', + 'user-guide/skills/bundled/productivity/productivity-linear', + 'user-guide/skills/bundled/productivity/productivity-maps', + 'user-guide/skills/bundled/productivity/productivity-nano-pdf', + 'user-guide/skills/bundled/productivity/productivity-notion', + 'user-guide/skills/bundled/productivity/productivity-ocr-and-documents', + 'user-guide/skills/bundled/productivity/productivity-powerpoint', + 'user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline', + ], + }, + { + type: 'category', + label: 'red-teaming', + collapsed: true, + items: [ + 'user-guide/skills/bundled/red-teaming/red-teaming-godmode', + ], + }, + { + type: 'category', + label: 'research', + collapsed: true, + items: [ + 'user-guide/skills/bundled/research/research-arxiv', + 'user-guide/skills/bundled/research/research-blogwatcher', + 'user-guide/skills/bundled/research/research-llm-wiki', + 'user-guide/skills/bundled/research/research-polymarket', + 'user-guide/skills/bundled/research/research-research-paper-writing', + ], + }, + { + type: 'category', + label: 'smart-home', + collapsed: true, + items: [ + 'user-guide/skills/bundled/smart-home/smart-home-openhue', + ], + }, + { + type: 'category', + label: 'social-media', + collapsed: true, + items: [ + 'user-guide/skills/bundled/social-media/social-media-xurl', + ], + }, + { + type: 'category', + label: 'software-development', + collapsed: true, + items: [ + 'user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands', + 'user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring', + 'user-guide/skills/bundled/software-development/software-development-node-inspect-debugger', + 'user-guide/skills/bundled/software-development/software-development-plan', + 'user-guide/skills/bundled/software-development/software-development-python-debugpy', + 'user-guide/skills/bundled/software-development/software-development-requesting-code-review', + 'user-guide/skills/bundled/software-development/software-development-spike', + 'user-guide/skills/bundled/software-development/software-development-subagent-driven-development', + 'user-guide/skills/bundled/software-development/software-development-systematic-debugging', + 'user-guide/skills/bundled/software-development/software-development-test-driven-development', + 'user-guide/skills/bundled/software-development/software-development-writing-plans', + ], + }, + { + type: 'category', + label: 'yuanbao', + collapsed: true, + items: [ + 'user-guide/skills/bundled/yuanbao/yuanbao-yuanbao', + ], + }, + ], + }, + { + type: 'category', + label: 'Optional', + collapsed: true, + items: [ + { + type: 'category', + label: 'autonomous-ai-agents', + collapsed: true, + items: [ + 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox', + 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho', + ], + }, + { + type: 'category', + label: 'blockchain', + collapsed: true, + items: [ + 'user-guide/skills/optional/blockchain/blockchain-evm', + 'user-guide/skills/optional/blockchain/blockchain-hyperliquid', + 'user-guide/skills/optional/blockchain/blockchain-solana', + ], + }, + { + type: 'category', + label: 'communication', + collapsed: true, + items: [ + 'user-guide/skills/optional/communication/communication-one-three-one-rule', + ], + }, + { + type: 'category', + label: 'creative', + collapsed: true, + items: [ + 'user-guide/skills/optional/creative/creative-blender-mcp', + 'user-guide/skills/optional/creative/creative-concept-diagrams', + 'user-guide/skills/optional/creative/creative-hyperframes', + 'user-guide/skills/optional/creative/creative-kanban-video-orchestrator', + 'user-guide/skills/optional/creative/creative-meme-generation', + ], + }, + { + type: 'category', + label: 'devops', + collapsed: true, + items: [ + 'user-guide/skills/optional/devops/devops-cli', + 'user-guide/skills/optional/devops/devops-docker-management', + 'user-guide/skills/optional/devops/devops-watchers', + ], + }, + { + type: 'category', + label: 'dogfood', + collapsed: true, + items: [ + 'user-guide/skills/optional/dogfood/dogfood-adversarial-ux-test', + ], + }, + { + type: 'category', + label: 'email', + collapsed: true, + items: [ + 'user-guide/skills/optional/email/email-agentmail', + ], + }, + { + type: 'category', + label: 'finance', + collapsed: true, + items: [ + 'user-guide/skills/optional/finance/finance-3-statement-model', + 'user-guide/skills/optional/finance/finance-comps-analysis', + 'user-guide/skills/optional/finance/finance-dcf-model', + 'user-guide/skills/optional/finance/finance-excel-author', + 'user-guide/skills/optional/finance/finance-lbo-model', + 'user-guide/skills/optional/finance/finance-merger-model', + 'user-guide/skills/optional/finance/finance-pptx-author', + 'user-guide/skills/optional/finance/finance-stocks', + ], + }, + { + type: 'category', + label: 'health', + collapsed: true, + items: [ + 'user-guide/skills/optional/health/health-fitness-nutrition', + 'user-guide/skills/optional/health/health-neuroskill-bci', + ], + }, + { + type: 'category', + label: 'mcp', + collapsed: true, + items: [ + 'user-guide/skills/optional/mcp/mcp-fastmcp', + 'user-guide/skills/optional/mcp/mcp-mcporter', + ], + }, + { + type: 'category', + label: 'migration', + collapsed: true, + items: [ + 'user-guide/skills/optional/migration/migration-openclaw-migration', + ], + }, + { + type: 'category', + label: 'mlops', + collapsed: true, + items: [ + 'user-guide/skills/optional/mlops/mlops-accelerate', + 'user-guide/skills/optional/mlops/mlops-training-axolotl', + 'user-guide/skills/optional/mlops/mlops-chroma', + 'user-guide/skills/optional/mlops/mlops-clip', + 'user-guide/skills/optional/mlops/mlops-faiss', + 'user-guide/skills/optional/mlops/mlops-flash-attention', + 'user-guide/skills/optional/mlops/mlops-guidance', + 'user-guide/skills/optional/mlops/mlops-huggingface-tokenizers', + 'user-guide/skills/optional/mlops/mlops-instructor', + 'user-guide/skills/optional/mlops/mlops-lambda-labs', + 'user-guide/skills/optional/mlops/mlops-llava', + 'user-guide/skills/optional/mlops/mlops-modal', + 'user-guide/skills/optional/mlops/mlops-nemo-curator', + 'user-guide/skills/optional/mlops/mlops-inference-outlines', + 'user-guide/skills/optional/mlops/mlops-peft', + 'user-guide/skills/optional/mlops/mlops-pinecone', + 'user-guide/skills/optional/mlops/mlops-pytorch-fsdp', + 'user-guide/skills/optional/mlops/mlops-pytorch-lightning', + 'user-guide/skills/optional/mlops/mlops-qdrant', + 'user-guide/skills/optional/mlops/mlops-saelens', + 'user-guide/skills/optional/mlops/mlops-simpo', + 'user-guide/skills/optional/mlops/mlops-slime', + 'user-guide/skills/optional/mlops/mlops-stable-diffusion', + 'user-guide/skills/optional/mlops/mlops-tensorrt-llm', + 'user-guide/skills/optional/mlops/mlops-torchtitan', + 'user-guide/skills/optional/mlops/mlops-training-trl-fine-tuning', + 'user-guide/skills/optional/mlops/mlops-training-unsloth', + 'user-guide/skills/optional/mlops/mlops-whisper', + ], + }, + { + type: 'category', + label: 'productivity', + collapsed: true, + items: [ + 'user-guide/skills/optional/productivity/productivity-canvas', + 'user-guide/skills/optional/productivity/productivity-here-now', + 'user-guide/skills/optional/productivity/productivity-memento-flashcards', + 'user-guide/skills/optional/productivity/productivity-shop-app', + 'user-guide/skills/optional/productivity/productivity-shopify', + 'user-guide/skills/optional/productivity/productivity-siyuan', + 'user-guide/skills/optional/productivity/productivity-telephony', + ], + }, + { + type: 'category', + label: 'research', + collapsed: true, + items: [ + 'user-guide/skills/optional/research/research-bioinformatics', + 'user-guide/skills/optional/research/research-domain-intel', + 'user-guide/skills/optional/research/research-drug-discovery', + 'user-guide/skills/optional/research/research-duckduckgo-search', + 'user-guide/skills/optional/research/research-gitnexus-explorer', + 'user-guide/skills/optional/research/research-parallel-cli', + 'user-guide/skills/optional/research/research-qmd', + 'user-guide/skills/optional/research/research-scrapling', + 'user-guide/skills/optional/research/research-searxng-search', + ], + }, + { + type: 'category', + label: 'security', + collapsed: true, + items: [ + 'user-guide/skills/optional/security/security-1password', + 'user-guide/skills/optional/security/security-oss-forensics', + 'user-guide/skills/optional/security/security-sherlock', + ], + }, + { + type: 'category', + label: 'software-development', + collapsed: true, + items: [ + 'user-guide/skills/optional/software-development/software-development-rest-graphql-debug', + ], + }, + { + type: 'category', + label: 'web-development', + collapsed: true, + items: [ + 'user-guide/skills/optional/web-development/web-development-page-agent', + ], + }, + ], + }, ], }, ], From ce0e189d3e7185d6c8c6af924a1df23e17c6f85c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 17:11:06 -0700 Subject: [PATCH 42/48] fix(xai-oauth): break entitlement-403 credential-refresh loop, bump grok-4.3 context to 1M (#26664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don Piedro's 18-minute hang on grok-4.3 traced to two issues PR #26644 didn't cover: - _recover_with_credential_pool classifies 403 as FailoverReason.auth and calls pool.try_refresh_current(). For xAI OAuth on an unsubscribed account, refresh succeeds (mints a new token from the same account) but the next API call 403s with the same entitlement error. Result: infinite refresh → retry → 403 loop until Ctrl+C (1133s in Don's log). New _is_entitlement_failure(error_context, status_code) detects the subscription-shape body ("do not have an active Grok subscription" / "out of available resources" + grok / "does not have permission" + grok) and short-circuits recovery so _summarize_api_error surfaces PR #26644's friendly hint. - grok-4.3 resolved to 256k via the grok-4 catch-all in DEFAULT_CONTEXT_LENGTHS. Per docs.x.ai/developers/models/grok-4.3 the model ships with 1M context. Add explicit grok-4.3 entry before the grok-4 fallback (longest-first substring matching ensures grok-4.3 and grok-4.3-latest both land on the new value). Tests: 8 new (23 total in test_codex_xai_oauth_recovery.py). E2E verified Don's 100-iteration loop bails out with 0 refresh calls while genuine auth failures still refresh once and recover. --- agent/model_metadata.py | 1 + run_agent.py | 56 ++++++ .../test_codex_xai_oauth_recovery.py | 190 ++++++++++++++++++ 3 files changed, 247 insertions(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index a10a01e3cc25..41e229416c9d 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -213,6 +213,7 @@ DEFAULT_CONTEXT_LENGTHS = { "grok-2-vision": 8192, # grok-2-vision, -1212, -latest "grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning "grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309 + "grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai "grok-4": 256000, # grok-4, grok-4-0709 "grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast "grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest diff --git a/run_agent.py b/run_agent.py index 2b20d48ede25..da47ca84e342 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4966,6 +4966,44 @@ class AIAgent: trajectory = self._convert_to_trajectory_format(messages, user_query, completed) _save_trajectory_to_file(trajectory, self.model, completed) + @staticmethod + def _is_entitlement_failure( + error_context: Optional[Dict[str, Any]], + status_code: Optional[int], + ) -> bool: + """Detect subscription/entitlement 403s that masquerade as auth failures. + + Returned True only when the body text matches a known entitlement + shape AND the status is 401/403. Refreshing an OAuth token cannot + fix an unsubscribed account, so callers should surface the error + instead of looping the credential pool. + + Current matches: + * xAI OAuth: "do not have an active Grok subscription" / + "out of available resources" / "does not have permission" + "grok" + + Extend here for new providers as we discover them (Anthropic's + Claude Max OAuth entitlement errors look distinct enough today that + the existing 1M-context-beta branch handles them; revisit if other + subscription tiers start producing the same loop signature). + """ + if status_code not in (401, 403, None): + return False + if not isinstance(error_context, dict): + return False + message = str(error_context.get("message") or "").lower() + reason = str(error_context.get("reason") or "").lower() + haystack = f"{message} {reason}" + if not haystack.strip(): + return False + if "do not have an active grok subscription" in haystack: + return True + if "out of available resources" in haystack and "grok" in haystack: + return True + if "does not have permission" in haystack and "grok" in haystack: + return True + return False + @staticmethod def _decorate_xai_entitlement_error(detail: str) -> str: """Append a friendly hint when xAI's OAuth surface returns an @@ -7551,6 +7589,24 @@ class AIAgent: return False, True if effective_reason == FailoverReason.auth: + # Subscription/entitlement 403s look like auth failures on the + # wire but refresh cannot fix them — the OAuth token is + # already valid; the account simply lacks the entitlement + # (e.g. xAI OAuth without SuperGrok/X Premium for grok-4.3). + # Without this guard, ``try_refresh_current()`` keeps minting + # fresh tokens against the same unsubscribed account and the + # main agent loop spins re-issuing the same 403 until the + # user Ctrl+C's. Surface the error instead so the friendly + # entitlement hint from ``_summarize_api_error`` can land. + if self._is_entitlement_failure(error_context, status_code): + logger.info( + "Credential %s — entitlement-shaped 403 from %s; " + "skipping pool refresh (account lacks subscription, " + "not a transient auth failure).", + status_code if status_code is not None else "auth", + self.provider or "provider", + ) + return False, has_retried_429 refreshed = pool.try_refresh_current() if refreshed is not None: logger.info(f"Credential auth failure — refreshed pool entry {getattr(refreshed, 'id', '?')}") diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 0f3603d2ca77..7c675f222252 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -349,3 +349,193 @@ def test_codex_transport_native_codex_still_replays_reasoning_in_input(): assert reasoning_items[0]["encrypted_content"] == "enc_blob" # Native Codex still asks for encrypted_content back. assert "reasoning.encrypted_content" in kwargs.get("include", []) + + +# --------------------------------------------------------------------------- +# Fix D: entitlement 403 must NOT trigger credential-pool refresh loop +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "message", + [ + # The exact wire text RaidenTyler and Don Piedro captured. + "You have either run out of available resources or do not have an " + "active Grok subscription. Manage at https://grok.com", + # Permission-style variant from the same 403 body. + "The caller does not have permission to execute the specified " + "operation for grok-4.3", + ], +) +def test_is_entitlement_failure_matches_real_xai_bodies(message): + from run_agent import AIAgent + + assert AIAgent._is_entitlement_failure( + {"message": message, "reason": "permission_denied"}, + 403, + ) + + +def test_is_entitlement_failure_false_for_status_other_than_401_403(): + """200/429/500 must never be classified as entitlement, even if body matches.""" + from run_agent import AIAgent + + body = { + "message": "do not have an active Grok subscription", + } + assert not AIAgent._is_entitlement_failure(body, 500) + assert not AIAgent._is_entitlement_failure(body, 429) + assert not AIAgent._is_entitlement_failure(body, 200) + + +def test_is_entitlement_failure_false_for_unrelated_auth_errors(): + """A real auth failure (expired token, wrong key) must keep refreshing.""" + from run_agent import AIAgent + + # Generic Anthropic-style auth failure + assert not AIAgent._is_entitlement_failure( + {"message": "Invalid API key", "reason": "authentication_error"}, + 401, + ) + # OAuth token expired + assert not AIAgent._is_entitlement_failure( + {"message": "Token has expired", "reason": "unauthorized"}, + 401, + ) + # Empty context + assert not AIAgent._is_entitlement_failure({}, 401) + assert not AIAgent._is_entitlement_failure(None, 401) + + +def test_recover_with_credential_pool_skips_refresh_on_entitlement_403(): + """The recovery path must NOT call pool.try_refresh_current() on entitlement 403. + + Before the fix, an unsubscribed xAI OAuth account would burn the agent + loop indefinitely: refresh → 403 → refresh → 403, infinitely. With + the entitlement guard, recovery returns False so the error surfaces + normally with the friendly hint from _summarize_api_error. + """ + from run_agent import AIAgent + from agent.error_classifier import FailoverReason + + agent = _make_codex_agent() + + # Wire a fake credential pool that records refresh attempts. + refresh_calls = {"n": 0} + + class _FakePool: + def try_refresh_current(self): + refresh_calls["n"] += 1 + return MagicMock(id="should_not_be_called") + + def mark_exhausted_and_rotate(self, **_kwargs): + return None + + def has_available(self): + return False + + agent._credential_pool = _FakePool() + + error_context = { + "reason": "The caller does not have permission to execute the specified operation", + "message": "You have either run out of available resources or do not have an " + "active Grok subscription. Manage at https://grok.com", + } + + recovered, _retried_429 = agent._recover_with_credential_pool( + status_code=403, + has_retried_429=False, + classified_reason=FailoverReason.auth, + error_context=error_context, + ) + + assert recovered is False, "Entitlement 403 must surface, not silently recover" + assert refresh_calls["n"] == 0, "try_refresh_current must NOT be called on entitlement 403" + + +def test_recover_with_credential_pool_still_refreshes_genuine_auth_failure(): + """Regression guard: legitimate auth errors must still trigger refresh.""" + from run_agent import AIAgent + from agent.error_classifier import FailoverReason + + agent = _make_codex_agent() + + refresh_calls = {"n": 0} + + class _FakePool: + def try_refresh_current(self): + refresh_calls["n"] += 1 + # Return a fake refreshed entry — semantically "refresh worked" + entry = MagicMock() + entry.id = "entry_refreshed" + return entry + + def mark_exhausted_and_rotate(self, **_kwargs): + return None + + def has_available(self): + return False + + agent._credential_pool = _FakePool() + # _swap_credential is called by the recovery path — stub it out + agent._swap_credential = MagicMock() + + error_context = { + "reason": "authentication_error", + "message": "Invalid API key", + } + + recovered, _retried_429 = agent._recover_with_credential_pool( + status_code=401, + has_retried_429=False, + classified_reason=FailoverReason.auth, + error_context=error_context, + ) + + assert recovered is True, "Genuine auth failure must still recover via refresh" + assert refresh_calls["n"] == 1 + + +# --------------------------------------------------------------------------- +# Fix E: grok-4.3 context length must be 1M, not 256K +# --------------------------------------------------------------------------- + + +def test_grok_4_3_context_length_is_1m(): + """grok-4.3 ships with 1M context per docs.x.ai/developers/models/grok-4.3. + + Hermes' substring-match fallback used to return 256k (from the + "grok-4" catch-all) which under-reported the model's real capacity. + """ + from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS + + # The entry exists with the expected value. + assert DEFAULT_CONTEXT_LENGTHS["grok-4.3"] == 1_000_000 + + # And longest-first substring matching resolves grok-4.3 and + # grok-4.3-latest to the new value, NOT the grok-4 catch-all. + for slug in ("grok-4.3", "grok-4.3-latest"): + matched_key = max( + (k for k in DEFAULT_CONTEXT_LENGTHS if k in slug.lower()), + key=len, + ) + assert matched_key == "grok-4.3", ( + f"Expected longest-first match to land on grok-4.3 for {slug}, " + f"got {matched_key}" + ) + assert DEFAULT_CONTEXT_LENGTHS[matched_key] == 1_000_000 + + +def test_grok_4_still_resolves_to_256k(): + """Regression guard: grok-4 (non-.3) must still resolve to 256k.""" + from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS + + for slug in ("grok-4", "grok-4-0709"): + matched_key = max( + (k for k in DEFAULT_CONTEXT_LENGTHS if k in slug.lower()), + key=len, + ) + # grok-4-0709 contains "grok-4" but not "grok-4.3"; matched key + # must be "grok-4" (or a more specific variant family if one is + # ever added). The 256k contract must hold. + assert DEFAULT_CONTEXT_LENGTHS[matched_key] == 256_000 From 9818b9a1acb915971d835d1faa85949e9f7a87a5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 17:15:22 -0700 Subject: [PATCH 43/48] fix(xai-oauth): rewrite entitlement-403 hint to not accuse subscribers (#26666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #26644 confidently told users "xAI OAuth account lacks SuperGrok / X Premium entitlement" on any 403 from xAI's permission-denied surface. But that body is returned for at least four distinct causes that Hermes cannot distinguish from the wire: * Account has no Grok subscription at all * Account has SuperGrok but the tier doesn't include the requested model (e.g. grok-4.3 needs SuperGrok Heavy) * Monthly quota for the subscribed tier is exhausted * SuperGrok is active but the API access add-on isn't enabled Don Piedro pushed back that he IS subscribed yet still hit this. Picking the worst-case interpretation ("you're not subscribed") reads as wrong and insulting to subscribers, and points them at a fix they already did. New wording lists all 4 possibilities and points at https://grok.com/?_s=usage where the user can check which applies. The detection logic and credential-pool short-circuit (PR #26664) are unchanged — only the user-facing wording is rephrased. --- run_agent.py | 42 ++++++++++++------ .../test_codex_xai_oauth_recovery.py | 44 +++++++++++++++++-- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/run_agent.py b/run_agent.py index da47ca84e342..da05e7e82393 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5006,23 +5006,35 @@ class AIAgent: @staticmethod def _decorate_xai_entitlement_error(detail: str) -> str: - """Append a friendly hint when xAI's OAuth surface returns an - entitlement-shaped error. + """Append a neutral hint when xAI's OAuth surface returns the + permission-denied 403. - xAI's ``/v1/responses`` endpoint replies to OAuth tokens that lack a - SuperGrok / X Premium subscription with HTTP 403 carrying a body like:: + xAI's ``/v1/responses`` endpoint replies to several distinct failure + modes with the SAME body:: {"code": "The caller does not have permission to execute the specified operation", "error": "You have either run out of available resources or do not have an active Grok subscription. - Manage subscriptions at https://grok.com/..."} + Manage subscriptions at https://grok.com/?_s=usage or subscribe + at https://grok.com/supergrok"} - The raw text is useful but the action the user needs to take (subscribe - on grok.com, or switch providers with ``/model``) isn't obvious from - the wire format. Detect the entitlement shape and append a hint. + That body covers at least four real causes we cannot distinguish + without more info from xAI: - Matched once per detail string — won't double-decorate if the upstream - already concatenated the same text. + * Account has no Grok subscription at all + * Account has SuperGrok but the tier doesn't include the requested + model (e.g. grok-4.3 needs SuperGrok Heavy) + * Monthly quota for the subscribed tier is exhausted (the + ``?_s=usage`` URL hints at this) + * SuperGrok is active but the API access add-on isn't enabled + + Picking one ("you're not subscribed") is wrong for the other three + and reads as insulting to subscribers. Surface the raw xAI text + verbatim and point at https://grok.com/?_s=usage where the user + can see WHICH of those four it is. + + Matched once per detail string — won't double-decorate if the + upstream already concatenated the same text. """ if not detail: return detail @@ -5035,11 +5047,15 @@ class AIAgent: if not is_entitlement: return detail hint = ( - " — xAI OAuth account lacks SuperGrok / X Premium entitlement for " - "this model. Subscribe at https://grok.com or run `/model` to " + " — xAI rejected the request on this OAuth account. Could be a " + "missing subscription, a tier that doesn't include this model, an " + "exhausted quota, or API access not enabled. Check " + "https://grok.com/?_s=usage to see which, or run `/model` to " "switch providers." ) - if hint.strip() in detail: + # Idempotency: detect prior decoration by a substring unique to the + # hint (not present in xAI's own body text). + if "Could be a missing subscription" in detail: return detail return f"{detail}{hint}" diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 7c675f222252..c64f46eea092 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -163,7 +163,12 @@ def test_codex_stream_postlude_error_still_falls_back(): def test_summarize_api_error_decorates_xai_entitlement_403(): - """xAI's OAuth 403 must end with the subscribe-or-switch hint.""" + """xAI's OAuth 403 must end with the neutral 4-cause hint. + + Wording is deliberately ambiguous because xAI returns the SAME body for: + no subscription, wrong tier, exhausted quota, or API access not enabled. + Picking one (e.g. "you're not subscribed") would insult subscribers. + """ from run_agent import AIAgent error = RuntimeError( @@ -173,10 +178,39 @@ def test_summarize_api_error_decorates_xai_entitlement_403(): "subscriptions at https://grok.com'}" ) summary = AIAgent._summarize_api_error(error) + # The original xAI text must survive — it's still useful diagnostic info. assert "do not have an active Grok subscription" in summary - assert "SuperGrok" in summary + # The hint must NOT confidently assert "lacks subscription"; it must + # acknowledge the 4 possible causes. + assert "Could be a missing subscription" in summary + assert "tier that doesn't include this model" in summary + assert "exhausted quota" in summary + assert "API access not enabled" in summary + # The hint must point at the usage page where the user can verify which. + assert "https://grok.com/?_s=usage" in summary + # Switching providers is still a valid escape hatch. assert "/model" in summary - assert "https://grok.com" in summary + + +def test_summarize_api_error_does_not_accuse_subscribers(): + """Hint must not confidently say the user has no subscription. + + Don Piedro reported his subscription is active. The hint must not + contradict him — it must list all 4 possible causes and let him + check which one applies. + """ + from run_agent import AIAgent + + error = RuntimeError( + "HTTP 403: do not have an active Grok subscription" + ) + summary = AIAgent._summarize_api_error(error) + # MUST NOT contain language that assumes the user is unsubscribed. + assert "lacks SuperGrok" not in summary + assert "lacks subscription" not in summary + assert "your account doesn't have" not in summary.lower() + # MUST contain the neutral framing. + assert "Could be" in summary or "could be" in summary def test_summarize_api_error_decorates_xai_body_message(): @@ -197,7 +231,7 @@ def test_summarize_api_error_decorates_xai_body_message(): summary = AIAgent._summarize_api_error(_XaiErr("403")) assert "HTTP 403" in summary - assert "SuperGrok / X Premium" in summary + assert "Could be a missing subscription" in summary def test_summarize_api_error_idempotent_for_entitlement_hint(): @@ -208,6 +242,8 @@ def test_summarize_api_error_idempotent_for_entitlement_hint(): once = AIAgent._decorate_xai_entitlement_error(raw) twice = AIAgent._decorate_xai_entitlement_error(once) assert once == twice + # Sanity: the hint did fire on the first pass. + assert "Could be a missing subscription" in once def test_summarize_api_error_passes_through_unrelated_errors(): From 6784c80794bfd3cc40aae7f7d9f1a59876de7799 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 17:23:33 -0700 Subject: [PATCH 44/48] fix(xai-oauth): lead entitlement-403 hint with X Premium+ gotcha (#26672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1 confusing cause of the xAI 403 (per Teknium): X Premium+ subscribers see Grok inside the X app and assume API access is included. It is NOT — only standalone SuperGrok subscribers can use xai-oauth with Hermes today. Without calling this out, every Premium+ user hits the 403 with no idea why. PR #26666's neutral 4-cause list was correct but buried the most common cause. Lead with the Premium+ gotcha, then list the other possibilities (no subscription, wrong tier, exhausted quota) as fallbacks. Same neutral framing — does not accuse anyone of being unsubscribed. --- run_agent.py | 38 ++++++++-------- .../test_codex_xai_oauth_recovery.py | 44 ++++++++++--------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/run_agent.py b/run_agent.py index da05e7e82393..85c1128d68e5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5018,20 +5018,21 @@ class AIAgent: Manage subscriptions at https://grok.com/?_s=usage or subscribe at https://grok.com/supergrok"} - That body covers at least four real causes we cannot distinguish - without more info from xAI: + That body covers several real causes we cannot distinguish without + more info from xAI. The most common (and least obvious) one is + that **X Premium+ does NOT include API access** — only standalone + SuperGrok subscribers can use Hermes against xai-oauth. Lots of + users see Grok in their X app, assume it works here too, and hit + this 403 with no idea why. Lead the hint with that. - * Account has no Grok subscription at all - * Account has SuperGrok but the tier doesn't include the requested - model (e.g. grok-4.3 needs SuperGrok Heavy) - * Monthly quota for the subscribed tier is exhausted (the - ``?_s=usage`` URL hints at this) - * SuperGrok is active but the API access add-on isn't enabled + Other possible causes: + * No Grok subscription at all + * SuperGrok tier doesn't include the requested model (e.g. + grok-4.3 may need a higher tier) + * Monthly quota exhausted (the ``?_s=usage`` URL hints at this) - Picking one ("you're not subscribed") is wrong for the other three - and reads as insulting to subscribers. Surface the raw xAI text - verbatim and point at https://grok.com/?_s=usage where the user - can see WHICH of those four it is. + Surface the raw xAI text verbatim and point at + https://grok.com/?_s=usage where the user can see WHICH applies. Matched once per detail string — won't double-decorate if the upstream already concatenated the same text. @@ -5047,15 +5048,16 @@ class AIAgent: if not is_entitlement: return detail hint = ( - " — xAI rejected the request on this OAuth account. Could be a " - "missing subscription, a tier that doesn't include this model, an " - "exhausted quota, or API access not enabled. Check " - "https://grok.com/?_s=usage to see which, or run `/model` to " - "switch providers." + " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " + "include xAI API access — only standalone SuperGrok subscribers " + "can use this provider. Other possible causes: no Grok " + "subscription, your tier doesn't include this model, or your " + "quota is exhausted. Check https://grok.com/?_s=usage to see " + "which, or run `/model` to switch providers." ) # Idempotency: detect prior decoration by a substring unique to the # hint (not present in xAI's own body text). - if "Could be a missing subscription" in detail: + if "X Premium+ does NOT include" in detail: return detail return f"{detail}{hint}" diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index c64f46eea092..9192d50695bd 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -163,11 +163,13 @@ def test_codex_stream_postlude_error_still_falls_back(): def test_summarize_api_error_decorates_xai_entitlement_403(): - """xAI's OAuth 403 must end with the neutral 4-cause hint. + """xAI's OAuth 403 must surface the X Premium+ gotcha + neutral causes. - Wording is deliberately ambiguous because xAI returns the SAME body for: - no subscription, wrong tier, exhausted quota, or API access not enabled. - Picking one (e.g. "you're not subscribed") would insult subscribers. + Wording deliberately leads with the X Premium+ gotcha because that's + the #1 confusing case: people see Grok in their X app, assume it + works here too, and hit this 403 with no idea API access is a + separate SKU. Other causes (no subscription, wrong tier, exhausted + quota) follow. """ from run_agent import AIAgent @@ -180,13 +182,15 @@ def test_summarize_api_error_decorates_xai_entitlement_403(): summary = AIAgent._summarize_api_error(error) # The original xAI text must survive — it's still useful diagnostic info. assert "do not have an active Grok subscription" in summary - # The hint must NOT confidently assert "lacks subscription"; it must - # acknowledge the 4 possible causes. - assert "Could be a missing subscription" in summary - assert "tier that doesn't include this model" in summary - assert "exhausted quota" in summary - assert "API access not enabled" in summary - # The hint must point at the usage page where the user can verify which. + # The hint MUST lead with the X Premium+ gotcha (most likely cause + # for users who think they're subscribed). + assert "X Premium+ does NOT include" in summary + assert "standalone SuperGrok subscribers" in summary + # Other causes still listed. + assert "no Grok subscription" in summary + assert "tier doesn't include this model" in summary + assert "quota is exhausted" in summary + # The hint must point at the usage page where the user can verify. assert "https://grok.com/?_s=usage" in summary # Switching providers is still a valid escape hatch. assert "/model" in summary @@ -196,8 +200,9 @@ def test_summarize_api_error_does_not_accuse_subscribers(): """Hint must not confidently say the user has no subscription. Don Piedro reported his subscription is active. The hint must not - contradict him — it must list all 4 possible causes and let him - check which one applies. + contradict him — leading with the X Premium+ gotcha gives subscribers + a plausible reason ("oh, I'm on Premium+ not pure SuperGrok") instead + of accusing them of lying about having a subscription. """ from run_agent import AIAgent @@ -205,12 +210,11 @@ def test_summarize_api_error_does_not_accuse_subscribers(): "HTTP 403: do not have an active Grok subscription" ) summary = AIAgent._summarize_api_error(error) - # MUST NOT contain language that assumes the user is unsubscribed. + # MUST NOT contain language that flatly assumes the user is unsubscribed. assert "lacks SuperGrok" not in summary - assert "lacks subscription" not in summary - assert "your account doesn't have" not in summary.lower() - # MUST contain the neutral framing. - assert "Could be" in summary or "could be" in summary + assert "you are not subscribed" not in summary.lower() + # MUST lead with the most-likely-but-non-accusatory cause. + assert "X Premium+ does NOT include" in summary def test_summarize_api_error_decorates_xai_body_message(): @@ -231,7 +235,7 @@ def test_summarize_api_error_decorates_xai_body_message(): summary = AIAgent._summarize_api_error(_XaiErr("403")) assert "HTTP 403" in summary - assert "Could be a missing subscription" in summary + assert "X Premium+ does NOT include" in summary def test_summarize_api_error_idempotent_for_entitlement_hint(): @@ -243,7 +247,7 @@ def test_summarize_api_error_idempotent_for_entitlement_hint(): twice = AIAgent._decorate_xai_entitlement_error(once) assert once == twice # Sanity: the hint did fire on the first pass. - assert "Could be a missing subscription" in once + assert "X Premium+ does NOT include" in once def test_summarize_api_error_passes_through_unrelated_errors(): From 566d8f0d75049e5e4e4e3e3fde7f8c766ae235d6 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 15 May 2026 20:08:24 -0500 Subject: [PATCH 45/48] fix(tui): keep DECSTBM scroll region off bottom row (#26683) Avoid shifting the terminal's last visible row in the alt-screen DECSTBM fast path, which can leave transient scroll bleed/discoloration artifacts around the status lane until a repaint. Add regression tests to preserve the fast path when safe and skip it when the hint touches the bottom row. --- .../hermes-ink/src/ink/log-update.test.ts | 42 +++++++++++++++++++ .../packages/hermes-ink/src/ink/log-update.ts | 5 ++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index 35c99f7e0a22..a11a028e7714 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -42,6 +42,8 @@ const stdoutOnly = (diff: ReturnType) => .map(p => (p as { type: 'stdout'; content: string }).content) .join('') +const hasDecstbm = (text: string) => /\x1b\[\d+;\d+r/.test(text) + describe('LogUpdate.render diff contract', () => { it('emits only changed cells when most rows match', () => { const w = 20 @@ -154,4 +156,44 @@ describe('LogUpdate.render diff contract', () => { expect(diff.some(p => p.type === 'clearTerminal')).toBe(true) expect(stdoutOnly(diff)).toContain('timer2s') }) + + it('keeps DECSTBM fast-path when scroll region stays above bottom row', () => { + const w = 12 + const h = 6 + const prev = mkScreen(w, h) + const next = mkScreen(w, h) + + paint(prev, 1, 'row one') + paint(next, 1, 'row one') + + const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { + ...mkFrame(next, w, h), + scrollHint: { top: 1, bottom: 4, delta: 1 } + } + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(prevFrame, nextFrame, true, true) + + expect(hasDecstbm(stdoutOnly(diff))).toBe(true) + }) + + it('skips DECSTBM when scroll region touches the bottom row', () => { + const w = 12 + const h = 6 + const prev = mkScreen(w, h) + const next = mkScreen(w, h) + + paint(prev, 1, 'row one') + paint(next, 1, 'row one') + + const prevFrame = mkFrame(prev, w, h) + const nextFrame: Frame = { + ...mkFrame(next, w, h), + scrollHint: { top: 1, bottom: 5, delta: 1 } + } + const log = new LogUpdate({ isTTY: true, stylePool }) + const diff = log.render(prevFrame, nextFrame, true, true) + + expect(hasDecstbm(stdoutOnly(diff))).toBe(false) + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts index 9a377c2c6f6b..0f36d4641e7d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -175,7 +175,10 @@ export class LogUpdate { if (altScreen && next.scrollHint && decstbmSafe) { const { top, bottom, delta } = next.scrollHint - if (top >= 0 && bottom < prev.screen.height && bottom < next.screen.height) { + // Keep DECSTBM away from the terminal's last visible row. In alt-screen + // layouts we reserve that lane for status/cursor parking, and scrolling + // it can leave transient ghosting/bleed artifacts until a later repaint. + if (top >= 0 && bottom < prev.screen.height - 1 && bottom < next.screen.height - 1) { shiftRows(prev.screen, top, bottom, delta) scrollPatch = [ { From 006937f7d062f7f1dd830aa16476ce962bd30445 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 15 May 2026 20:19:02 -0500 Subject: [PATCH 46/48] fix(tui): handle timeout/error subagent statuses in /agents (#26687) Accept delegation timeout/error statuses in the TUI subagent model, normalize unknown status strings defensively, and harden /agents overlay rendering/sorting so unknown statuses cannot crash glyph/color lookup. Add regression tests for live event normalization and disk snapshot replay. --- .../createGatewayEventHandler.test.ts | 55 +++++++++++++++++++ .../src/__tests__/spawnHistoryStore.test.ts | 46 ++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 29 ++++++++-- ui-tui/src/app/spawnHistoryStore.ts | 24 +++++++- ui-tui/src/components/agentsOverlay.tsx | 19 +++++-- ui-tui/src/components/thinking.tsx | 6 +- ui-tui/src/gatewayTypes.ts | 4 +- ui-tui/src/types.ts | 4 +- 8 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 ui-tui/src/__tests__/spawnHistoryStore.test.ts diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index d74976d195ed..cd278eecdf93 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -737,6 +737,61 @@ describe('createGatewayEventHandler', () => { expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }]) }) + it('accepts timeout/error subagent terminal statuses and ignores stale live events', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 }, + type: 'subagent.start' + } as any) + onEvent({ + payload: { goal: 'timeout child', status: 'timeout', subagent_id: 'sa-timeout', task_index: 0 }, + type: 'subagent.complete' + } as any) + + expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout') + + // Late start/spawn updates must not clobber terminal timeout/error states. + onEvent({ + payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 }, + type: 'subagent.start' + } as any) + onEvent({ + payload: { goal: 'timeout child', subagent_id: 'sa-timeout', task_index: 0 }, + type: 'subagent.spawn_requested' + } as any) + + expect(getTurnState().subagents.find(s => s.id === 'sa-timeout')?.status).toBe('timeout') + + onEvent({ + payload: { goal: 'error child', subagent_id: 'sa-error', task_index: 1 }, + type: 'subagent.start' + } as any) + onEvent({ + payload: { goal: 'error child', status: 'error', subagent_id: 'sa-error', task_index: 1 }, + type: 'subagent.complete' + } as any) + + expect(getTurnState().subagents.find(s => s.id === 'sa-error')?.status).toBe('error') + }) + + it('normalizes unknown subagent.complete statuses to completed', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { goal: 'weird child', subagent_id: 'sa-weird', task_index: 2 }, + type: 'subagent.start' + } as any) + onEvent({ + payload: { goal: 'weird child', status: 'mystery_status', subagent_id: 'sa-weird', task_index: 2 }, + type: 'subagent.complete' + } as any) + + expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed') + }) + it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => { // Repro for the discord report: ctrl-c interrupts, but late reasoning/tool // events from the still-winding-down agent loop kept populating the UI for diff --git a/ui-tui/src/__tests__/spawnHistoryStore.test.ts b/ui-tui/src/__tests__/spawnHistoryStore.test.ts new file mode 100644 index 000000000000..544280e5c424 --- /dev/null +++ b/ui-tui/src/__tests__/spawnHistoryStore.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { clearSpawnHistory, getSpawnHistory, pushDiskSnapshot } from '../app/spawnHistoryStore.js' + +describe('spawnHistoryStore status normalization', () => { + beforeEach(() => { + clearSpawnHistory() + }) + + it('keeps timeout/error statuses from disk snapshots', () => { + pushDiskSnapshot( + { + finished_at: 1_700_000_001, + label: 'status test', + session_id: 'sess-1', + started_at: 1_700_000_000, + subagents: [ + { goal: 'timeout child', id: 'sa-timeout', index: 0, status: 'timeout' }, + { goal: 'error child', id: 'sa-error', index: 1, status: 'error' } + ] + }, + '/tmp/snap-timeout-error.json' + ) + + const statuses = getSpawnHistory()[0]?.subagents.map(s => s.status) + + expect(statuses).toEqual(['timeout', 'error']) + }) + + it('falls back unknown disk statuses to completed', () => { + pushDiskSnapshot( + { + finished_at: 1_700_000_011, + label: 'unknown status test', + session_id: 'sess-2', + started_at: 1_700_000_010, + subagents: [{ goal: 'mystery child', id: 'sa-unknown', index: 0, status: 'mystery_status' }] + }, + '/tmp/snap-unknown.json' + ) + + const status = getSpawnHistory()[0]?.subagents[0]?.status + + expect(status).toBe('completed') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 555a35e8afed..ca269a131b4c 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -13,7 +13,7 @@ import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { formatToolCall, stripAnsi } from '../lib/text.js' import { fromSkin } from '../theme.js' -import type { Msg, SubagentProgress } from '../types.js' +import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' @@ -54,6 +54,26 @@ const pushThinking = pushUnique(6) const pushNote = pushUnique(6) const pushTool = pushUnique(8) +const KNOWN_SUBAGENT_STATUSES = new Set([ + 'completed', + 'error', + 'failed', + 'interrupted', + 'queued', + 'running', + 'timeout' +]) + +const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => { + if (typeof status !== 'string') { + return fallback + } + + const normalized = status.toLowerCase() as SubagentStatus + + return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback +} + export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { rpc } = ctx.gateway const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session @@ -180,8 +200,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // Terminal statuses are never overwritten by late-arriving live events — // otherwise a stale `subagent.start` / `spawn_requested` can clobber a - // `failed` or `interrupted` terminal state (Copilot review #14045). - const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted' + // terminal state from complete (failed/interrupted/timeout/error). + const isTerminalStatus = (s: SubagentProgress['status']) => + s === 'completed' || s === 'error' || s === 'failed' || s === 'interrupted' || s === 'timeout' const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running') @@ -648,7 +669,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ev.payload, c => ({ durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, - status: ev.payload.status ?? 'completed', + status: normalizeSubagentStatus(ev.payload.status, 'completed'), summary: ev.payload.summary || ev.payload.text || c.summary }), { createIfMissing: false } diff --git a/ui-tui/src/app/spawnHistoryStore.ts b/ui-tui/src/app/spawnHistoryStore.ts index 9adb2b59cd02..ec36148403d2 100644 --- a/ui-tui/src/app/spawnHistoryStore.ts +++ b/ui-tui/src/app/spawnHistoryStore.ts @@ -1,7 +1,7 @@ import { atom } from 'nanostores' import type { SpawnTreeLoadResponse } from '../gatewayTypes.js' -import type { SubagentProgress } from '../types.js' +import type { SubagentProgress, SubagentStatus } from '../types.js' export interface SpawnSnapshot { finishedAt: number @@ -21,6 +21,26 @@ export interface SpawnDiffPair { const HISTORY_LIMIT = 10 +const KNOWN_SUBAGENT_STATUSES = new Set([ + 'completed', + 'error', + 'failed', + 'interrupted', + 'queued', + 'running', + 'timeout' +]) + +const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => { + if (typeof status !== 'string') { + return fallback + } + + const normalized = status.toLowerCase() as SubagentStatus + + return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback +} + export const $spawnHistory = atom([]) export const $spawnDiff = atom(null) @@ -128,7 +148,7 @@ function normaliseSubagent(raw: unknown): SubagentProgress { parentId: s(o.parentId) ?? null, reasoningTokens: n(o.reasoningTokens), startedAt: n(o.startedAt), - status: (s(o.status) as SubagentProgress['status']) ?? 'completed', + status: normalizeSubagentStatus(o.status, 'completed'), summary: s(o.summary), taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1, thinking: (arr(o.thinking) ?? []).filter(x => typeof x === 'string'), diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index a1b349827ccc..497230c39346 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -57,25 +57,33 @@ const FILTER_LABEL: Record = { } const STATUS_RANK: Record = { + error: 0, failed: 0, interrupted: 1, + timeout: 1, running: 2, queued: 3, completed: 4 } +const statusRank = (status: string): number => STATUS_RANK[status as Status] ?? STATUS_RANK.error + const SORT_COMPARATORS: Record number> = { 'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index, 'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools, 'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration, - status: (a, b) => STATUS_RANK[a.item.status] - STATUS_RANK[b.item.status] + status: (a, b) => statusRank(a.item.status) - statusRank(b.item.status) } const FILTER_PREDICATES: Record boolean> = { all: () => true, leaf: n => n.children.length === 0, running: n => n.item.status === 'running' || n.item.status === 'queued', - failed: n => n.item.status === 'failed' || n.item.status === 'interrupted' + failed: n => + n.item.status === 'error' || + n.item.status === 'failed' || + n.item.status === 'interrupted' || + n.item.status === 'timeout' } const STATUS_GLYPH: Record string; glyph: string }> = { @@ -83,7 +91,9 @@ const STATUS_GLYPH: Record string; glyph: string queued: { color: t => t.color.muted, glyph: '○' }, completed: { color: t => t.color.statusGood, glyph: '✓' }, interrupted: { color: t => t.color.warn, glyph: '■' }, - failed: { color: t => t.color.error, glyph: '✗' } + failed: { color: t => t.color.error, glyph: '✗' }, + timeout: { color: t => t.color.warn, glyph: '⌛' }, + error: { color: t => t.color.error, glyph: '⚠' } } // Heatmap palette — cold → hot, resolved against the active theme. @@ -111,7 +121,8 @@ const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ') const cycle = (order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]! const statusGlyph = (item: SubagentProgress, t: Theme) => { - const g = STATUS_GLYPH[item.status] + // Defensive fallback for cross-version snapshots with unknown statuses. + const g = STATUS_GLYPH[item.status] ?? STATUS_GLYPH.error return { color: g.color(t), glyph: g.glyph } } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4204ff56a0fa..6908795f6211 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -327,7 +327,11 @@ function SubagentAccordion({ const aggregate = node.aggregate const statusTone: 'dim' | 'error' | 'warn' = - item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' + item.status === 'error' || item.status === 'failed' + ? 'error' + : item.status === 'interrupted' || item.status === 'timeout' + ? 'warn' + : 'dim' const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' const goalLabel = item.goal || `Subagent ${item.index + 1}` diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8c5cb18b23d8..ab85c39fbddf 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -1,4 +1,4 @@ -import type { SessionInfo, SlashCategory, Usage } from './types.js' +import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js' export interface GatewaySkin { banner_hero?: string @@ -394,7 +394,7 @@ export interface SubagentEventPayload { output_tokens?: number parent_id?: null | string reasoning_tokens?: number - status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' + status?: SubagentStatus subagent_id?: string summary?: string task_count?: number diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 658b9cc13d21..62f580090d23 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -17,6 +17,8 @@ export interface ActivityItem { tone: 'error' | 'info' | 'warn' } +export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' + export interface SubagentProgress { apiCalls?: number costUsd?: number @@ -36,7 +38,7 @@ export interface SubagentProgress { parentId: null | string reasoningTokens?: number startedAt?: number - status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' + status: SubagentStatus summary?: string taskCount: number thinking: string[] From 55c9f32060bbe7eb48bee2b702c157408b468eb2 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 16 May 2026 06:55:56 +0530 Subject: [PATCH 47/48] fix(tui): width-aware markdown table rendering with vertical fallback (#26195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(tui): thread cols through Md/StreamingMd/renderTable, update cache key * feat(tui): three-tier width calc + full-line string rendering in renderTable Replaces the old renderTable (L203-244) with: - Empty table guard - Ragged row normalization - Three-tier column width calculation (ideal → proportional shrink → hard scale) - Rounding remainder distribution - Full-line string rendering (one per row, not per cell) - wrap=truncate-end on all table lines - All cells rendered as plain text via stripInlineMarkup No wrapping or vertical fallback yet — those come in Phase 3 and 4. * feat(tui): wrapCell with grapheme-safe hard-break + multi-line row rendering Adds: - Intl.Segmenter-based grapheme splitting (fallback to [...word]) - wrapCell() for width-correct word wrapping on stripped text - Multi-line row rendering with LineEntry metadata (header/separator/body) - Post-render safety condition (maxLineWidth computed, vertical fallback in Task 4) - Non-wrapping path preserved for tables that fit at ideal widths * feat(tui): vertical key-value fallback with scaled threshold + safety check Wires: - Scaled row-height threshold (numCols<=3: 8, <=6: 5, else: 4) - Post-render safety check (maxLineWidth > available space) - Header-only edge case - Vertical format: bold headers, stripped cell text, clamped separator width - Iterates headers (not rows) for consistent key-value fields on ragged rows * test(tui): pass cols to Md in test helpers, add width-overflow assertions - renderAtWidth now passes cols={columns} to so width-aware code paths are exercised in tests - tableFuzz: every rendered line must fit within allocated width (stringWidth) - tableRepro: separator regex updated to match truncation ellipsis - stringWidth imported from @hermes/ink for CJK-correct assertions * fix(tui): address adversarial review — comment tier 3 budget overshoot, eliminate redundant wrapCell - Add comment on Tier 3 MIN_COL_WIDTH clamp exceeding budget (self-heals via safetyOverflow) - Track tallestBodyRow during allEntries build pass instead of re-wrapping every cell in a second traversal (eliminates O(cells) of redundant stripInlineMarkup+stringWidth) * fix(tui): pass cols to recursive fenced-markdown Md, fix test frame extraction - Thread cols into for fenced markdown blocks (L734) so nested tables use the width-aware renderer instead of max-content path - Fix renderAtWidth helpers to extract final Ink repaint frame instead of concatenating all intermediate frames (REPAINT_RE split) - Add fenced-markdown-table fixture to tableFuzz (exercises the nested path) * chore: remove repro test suites and tmux driver script These were scaffolding for development/reproduction — not needed in the PR. --- ui-tui/src/components/markdown.tsx | 325 +++++++++++++++++--- ui-tui/src/components/messageLine.tsx | 6 +- ui-tui/src/components/streamingMarkdown.tsx | 11 +- 3 files changed, 295 insertions(+), 47 deletions(-) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index ae234eb9ec72..c215cd811bf4 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -200,44 +200,288 @@ export const stripInlineMarkup = (v: string) => .replace(/(? { - // Column widths in *display cells*, not UTF-16 code units. CJK - // glyphs and most emoji render as two cells but `String#length` - // counts them as one, which collapses Chinese / Japanese / Korean - // tables into drift across rows. `stringWidth` (Bun.stringWidth - // fast path + an East-Asian-width-aware fallback, memoised in - // @hermes/ink) returns the actual cell count. - const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) +const SAFETY_MARGIN = 4 +const MIN_COL_WIDTH = 3 +const COL_GAP = 2 // the ' ' between columns +const TABLE_PADDING_LEFT = 2 // paddingLeft={2} on the outer - const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? '')))) +const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => { + // Guard: empty table + if (rows.length === 0 || rows[0]!.length === 0) return null - // Thin divider under the header. Without it tables look like prose - // with extra spacing because the header is just accent-coloured text - // (#15534). We avoid full borders on purpose — column widths come - // from `stringWidth(...)`, so the dividers and the row content stay - // in sync on CJK / emoji tables; tab-style column gaps still read - // cleanly without the boxed look. - const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ') + const cellDisplayWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) - return ( - - {rows.map((row, ri) => ( - - - {widths.map((w, ci) => ( - - - {' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))} - {ci < widths.length - 1 ? ' ' : ''} - - ))} - - {ri === 0 && rows.length > 1 ? ( - - {sep} + // Minimum width: longest word in a cell (to avoid breaking words) + const minCellWidth = (raw: string) => { + const text = stripInlineMarkup(raw) + const words = text.split(/\s+/).filter(w => w.length > 0) + if (words.length === 0) return MIN_COL_WIDTH + return Math.max(...words.map(w => stringWidth(w)), MIN_COL_WIDTH) + } + + const numCols = rows[0]!.length + + // Normalize ragged rows: ensure every row has exactly numCols cells + const normalizedRows = rows.map(row => { + if (row.length >= numCols) return row.slice(0, numCols) + return [...row, ...Array(numCols - row.length).fill('')] + }) + + // Ideal widths: max cell content per column + const idealWidths = normalizedRows[0]!.map((_, ci) => + Math.max(...normalizedRows.map(r => cellDisplayWidth(r[ci] ?? '')), MIN_COL_WIDTH) + ) + + // Min widths: longest word per column + const minWidths = normalizedRows[0]!.map((_, ci) => + Math.max(...normalizedRows.map(r => minCellWidth(r[ci] ?? '')), MIN_COL_WIDTH) + ) + + // Available width: cols minus table padding minus column gaps minus safety. + // transcriptBodyWidth (source of cols) subtracts message gutter + scrollbar, + // but NOT this table's paddingLeft — we subtract it here. + const gapOverhead = (numCols - 1) * COL_GAP + const availableWidth = cols + ? Math.max(cols - TABLE_PADDING_LEFT - gapOverhead - SAFETY_MARGIN, numCols * MIN_COL_WIDTH) + : Infinity + + const totalIdeal = idealWidths.reduce((a, b) => a + b, 0) + const totalMin = minWidths.reduce((a, b) => a + b, 0) + + let columnWidths: number[] + let needsWrap = false + + if (totalIdeal <= availableWidth) { + // Tier 1: everything fits at ideal widths + columnWidths = idealWidths + } else if (totalMin <= availableWidth) { + // Tier 2: proportional shrink — distribute extra space beyond minimums + needsWrap = true + const extraSpace = availableWidth - totalMin + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) + const totalOverflow = overflows.reduce((a, b) => a + b, 0) + if (totalOverflow === 0) { + columnWidths = [...minWidths] + } else { + const rawAlloc = minWidths.map((min, i) => + min + (overflows[i]! / totalOverflow) * extraSpace + ) + columnWidths = rawAlloc.map(v => Math.floor(v)) + // Distribute rounding remainders to columns with largest fractional part + let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) + const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) + .sort((a, b) => b.frac - a.frac) + for (const { i } of fracs) { + if (remainder <= 0) break + columnWidths[i]!++ + remainder-- + } + } + } else { + // Tier 3: even min-widths don't fit — scale proportionally, allow hard breaks. + // NOTE: Math.max(..., MIN_COL_WIDTH) can push total above availableWidth when + // many columns are scaled below 3. This is caught by safetyOverflow → vertical fallback. + needsWrap = true + const scaleFactor = availableWidth / totalMin + const rawAlloc = minWidths.map(w => w * scaleFactor) + columnWidths = rawAlloc.map(v => Math.max(Math.floor(v), MIN_COL_WIDTH)) + let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) + const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) + .sort((a, b) => b.frac - a.frac) + for (const { i } of fracs) { + if (remainder <= 0) break + columnWidths[i]!++ + remainder-- + } + } + + // Grapheme-safe hard-break: prefer Intl.Segmenter, fall back to code-point split + const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' }) + : null + + const graphemes = (s: string): string[] => + segmenter + ? [...segmenter.segment(s)].map((seg: { segment: string }) => seg.segment) + : [...s] + + // Word-wrap plain text to fit within `width` display columns. + // Operates on stripped text for correct width measurement. + const wrapCell = (raw: string, width: number, hard: boolean): string[] => { + const text = stripInlineMarkup(raw) + if (width <= 0) return [text] + if (stringWidth(text) <= width) return [text] + + const words = text.split(/\s+/).filter(w => w.length > 0) + const lines: string[] = [] + let current = '' + let currentWidth = 0 + + for (const word of words) { + const w = stringWidth(word) + if (currentWidth === 0) { + if (hard && w > width) { + for (const ch of graphemes(word)) { + const cw = stringWidth(ch) + if (currentWidth + cw > width && current) { + lines.push(current) + current = '' + currentWidth = 0 + } + current += ch + currentWidth += cw + } + } else { + current = word + currentWidth = w + } + } else if (currentWidth + 1 + w <= width) { + current += ' ' + word + currentWidth += 1 + w + } else { + lines.push(current) + current = word + currentWidth = w + } + } + if (current) lines.push(current) + return lines.length > 0 ? lines : [''] + } + + const isHard = totalMin > availableWidth // tier 3 needs hard word breaks + const sep = columnWidths.map(w => '─'.repeat(Math.max(1, w))).join(' ') + + // When wrapping isn't needed, build single-line strings per row. + // All cells render as plain text via stripInlineMarkup. + // TODO: follow-up — format to ANSI then wrap with wrapAnsi for inline markdown preservation. + // See free-code/src/components/MarkdownTable.tsx L44-L62 for approach. + if (!needsWrap) { + const buildRowString = (row: string[]): string => + row.map((cell, ci) => { + const text = stripInlineMarkup(cell) + const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(text))) + const gap = ci < numCols - 1 ? ' ' : '' + return text + pad + gap + }).join('') + + return ( + + {normalizedRows.map((row, ri) => ( + + + {buildRowString(row)} - ) : null} - + {ri === 0 && normalizedRows.length > 1 ? ( + {sep} + ) : null} + + ))} + + ) + } + + // Wrapping path: build multi-line rows as complete strings. + type LineEntry = { text: string; kind: 'header' | 'separator' | 'body' } + + const buildRowLines = (row: string[]): string[] => { + const cellLines = row.map((cell, ci) => + wrapCell(cell, columnWidths[ci]!, isHard) + ) + const maxLines = Math.max(...cellLines.map(l => l.length), 1) + + const result: string[] = [] + for (let li = 0; li < maxLines; li++) { + let line = '' + for (let ci = 0; ci < numCols; ci++) { + const cl = cellLines[ci] ?? [''] + const cellText = li < cl.length ? cl[li]! : '' + const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(cellText))) + line += cellText + pad + if (ci < numCols - 1) line += ' ' + } + result.push(line) + } + return result + } + + // Build all lines with metadata for styling, tracking tallest body row + const allEntries: LineEntry[] = [] + let tallestBodyRow = 0 + normalizedRows.forEach((row, ri) => { + const kind = ri === 0 ? 'header' as const : 'body' as const + const rowLines = buildRowLines(row) + rowLines.forEach(text => allEntries.push({ text, kind })) + if (ri > 0) tallestBodyRow = Math.max(tallestBodyRow, rowLines.length) + if (ri === 0 && normalizedRows.length > 1) { + allEntries.push({ text: sep, kind: 'separator' }) + } + }) + + // Post-render safety condition: compute max line width. + const maxLineWidth = Math.max(...allEntries.map(e => stringWidth(e.text))) + const safetyOverflow = cols != null && maxLineWidth > cols - TABLE_PADDING_LEFT - SAFETY_MARGIN + + // Scaled vertical threshold — 2-3 col tables stay tabular even with tall cells + const maxRowLinesThreshold = numCols <= 3 ? 8 : numCols <= 6 ? 5 : 4 + + const useVertical = tallestBodyRow > maxRowLinesThreshold || safetyOverflow + + if (useVertical) { + // Edge case: header-only table + if (normalizedRows.length <= 1) { + return ( + + + {normalizedRows[0]!.map(h => stripInlineMarkup(h)).join(' · ')} + + + ) + } + + const headers = normalizedRows[0]! + const dataRows = normalizedRows.slice(1) + const sepWidth = Math.max(1, cols ? Math.min(cols - TABLE_PADDING_LEFT - 1, 40) : 40) + + return ( + + {dataRows.map((row, ri) => ( + + {ri > 0 ? ( + {'─'.repeat(sepWidth)} + ) : null} + {headers.map((header, ci) => { + const cell = row[ci] ?? '' + const label = stripInlineMarkup(header) || `Col ${ci + 1}` + return ( + + {label}: + {' '}{stripInlineMarkup(cell)} + + ) + })} + + ))} + + ) + } + + // Render wrapped horizontal rows — one per visual line. + return ( + + {allEntries.map((entry, i) => ( + + {entry.text} + ))} ) @@ -395,10 +639,10 @@ const cacheSet = (b: Map, key: string, v: ReactNode[]) => { } } -function MdImpl({ compact, t, text }: MdProps) { +function MdImpl({ cols, compact, t, text }: MdProps) { const nodes = useMemo(() => { const bucket = cacheBucket(t) - const cacheKey = `${compact ? '1' : '0'}|${text}` + const cacheKey = `${compact ? '1' : '0'}|${cols ?? ''}|${text}` const cached = cacheGet(bucket, cacheKey) if (cached) { @@ -490,7 +734,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (['md', 'markdown'].includes(lang)) { start('paragraph') - nodes.push() + nodes.push() continue } @@ -785,7 +1029,7 @@ function MdImpl({ compact, t, text }: MdProps) { rows.push(splitRow(lines[i]!)) } - nodes.push(renderTable(key, rows, t)) + nodes.push(renderTable(key, rows, t, cols)) continue } @@ -838,7 +1082,7 @@ function MdImpl({ compact, t, text }: MdProps) { } if (rows.length) { - nodes.push(renderTable(key, rows, t)) + nodes.push(renderTable(key, rows, t, cols)) } continue @@ -852,7 +1096,7 @@ function MdImpl({ compact, t, text }: MdProps) { cacheSet(bucket, cacheKey, nodes) return nodes - }, [compact, t, text]) + }, [cols, compact, t, text]) return {nodes} } @@ -862,6 +1106,7 @@ export const Md = memo(MdImpl) type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null interface MdProps { + cols?: number compact?: boolean t: Theme text: string diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 950b61b4d726..238b551ae974 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -139,13 +139,15 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role === 'assistant') { + const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt) + return isStreaming ? ( // Incremental markdown: split at the last stable block boundary so // only the in-flight tail re-tokenizes per delta. See // streamingMarkdown.tsx for the cost model. - + ) : ( - + ) } diff --git a/ui-tui/src/components/streamingMarkdown.tsx b/ui-tui/src/components/streamingMarkdown.tsx index 1be70b283a86..786a38124611 100644 --- a/ui-tui/src/components/streamingMarkdown.tsx +++ b/ui-tui/src/components/streamingMarkdown.tsx @@ -128,7 +128,7 @@ export const findStableBoundary = (text: string) => { return -1 } -export const StreamingMd = memo(function StreamingMd({ compact, t, text }: StreamingMdProps) { +export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) { const stablePrefixRef = useRef('') // Reset if the text no longer starts with our recorded prefix (defensive; @@ -151,22 +151,23 @@ export const StreamingMd = memo(function StreamingMd({ compact, t, text }: Strea const unstableSuffix = text.slice(stablePrefix.length) if (!stablePrefix) { - return + return } if (!unstableSuffix) { - return + return } return ( - - + + ) }) interface StreamingMdProps { + cols?: number compact?: boolean t: Theme text: string From 86a368d8322b3977bf89b9043818eebc6adf470b Mon Sep 17 00:00:00 2001 From: emozilla Date: Fri, 15 May 2026 22:14:41 -0400 Subject: [PATCH 48/48] remove pip installation method from docs --- website/docs/getting-started/installation.md | 25 -------------------- 1 file changed, 25 deletions(-) diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index a88f4c8bd1cc..10420d8df559 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -10,31 +10,6 @@ Get Hermes Agent up and running in under two minutes with the one-line installer ## Quick Install -### pip (recommended for most users) - -```bash -pip install hermes-agent -``` - -This gives you the full Hermes Agent — CLI, web dashboard, and TUI — with zero external dependencies for core usage. Node.js, browser engines, and other optional tools are bootstrapped lazily on first use (e.g. when you run `hermes --tui` or use browser tools). - -PyPI releases track **tagged versions** (major and minor releases), not every commit on `main`. If you want bleeding-edge changes as they land, use the git install below. - -After installing, run: - -```bash -hermes setup # interactive wizard — configures your LLM provider and API key -hermes # start chatting -``` - -:::tip Optional: install everything upfront -`hermes postinstall` installs Node.js, browser engines, ripgrep, and ffmpeg in one shot — then runs the setup wizard. Use this if you want the full experience (TUI, browser tools, voice) without waiting for lazy installs on first use. -::: - -:::tip -If you have [uv](https://docs.astral.sh/uv/) installed, `uv pip install hermes-agent` is faster. -::: - ### One-Line Installer (Linux / macOS / WSL2) For a git-based install that tracks `main` and gives you the latest changes immediately: