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" diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 4e2fe4748d3a..95477ccf01fc 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -50,6 +50,32 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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: 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/.gitignore b/.gitignore index a01ee177e184..165a1d6814ed 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,7 @@ models-dev-upstream/ .mcp.json opencode.json config/mcporter.json + +hermes_cli/tui_dist/* +hermes_cli/scripts/ +docs/superpowers/* diff --git a/acp_adapter/events.py b/acp_adapter/events.py index 1257f902ebbb..00e940b9ee0d 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,65 @@ 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. + + 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_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", + "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 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)) + + return AgentPlanUpdate(session_update="plan", entries=entries) + + def _send_update( conn: acp.Client, session_id: str, @@ -31,10 +91,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) @@ -168,6 +235,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/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/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/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/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/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/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/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/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/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 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/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 5e8fce8e18de..f9a282a413fb 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 @@ -1989,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 @@ -2179,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. @@ -3612,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 @@ -4736,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: @@ -4757,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: @@ -4837,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, @@ -4851,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): @@ -6450,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) @@ -8697,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 @@ -11217,10 +11407,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 +15004,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 +15045,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 +15220,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 +15318,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 +15399,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 +15540,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 +15549,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 +15576,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 +16549,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 +16610,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/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/hermes_cli/banner.py b/hermes_cli/banner.py index 036412ac072c..ef592beb7fdf 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -175,6 +175,48 @@ 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 + 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 +255,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/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/config.py b/hermes_cli/config.py index 1c0f836787e3..8ad6bc083f92 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: @@ -401,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) diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py new file mode 100644 index 000000000000..3312726c36d1 --- /dev/null +++ b/hermes_cli/dep_ensure.py @@ -0,0 +1,106 @@ +"""Lazy dependency bootstrapper for non-Python runtime deps. + +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 + +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", "chrome"): + if shutil.which(name): + return True + return False + + +def _has_hermes_agent_browser() -> bool: + from hermes_constants import get_hermes_home + return (get_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/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)") 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/hermes_cli/main.py b/hermes_cli/main.py index 1f818790d5a4..875906913444 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1033,6 +1033,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() @@ -1043,6 +1051,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) @@ -1067,6 +1082,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): @@ -1548,14 +1569,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 "" @@ -1645,6 +1670,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 @@ -1673,6 +1704,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": @@ -1703,6 +1739,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") @@ -7399,6 +7453,22 @@ 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(): print("✗ Not a git repository — cannot check for updates.") @@ -7676,6 +7746,28 @@ def cmd_update(args): _finalize_update_output(_update_io_state) +def _cmd_update_pip(args): + """Update Hermes via pip (for PyPI installs).""" + 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 = subprocess.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``.""" @@ -7703,6 +7795,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" @@ -9555,7 +9652,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 @@ -9994,6 +10091,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/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/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/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/pyproject.toml b/pyproject.toml index 24ae545a38e3..abc8940f5862 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -216,7 +216,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/**/*", "scripts/install.sh"] gateway = ["assets/**/*"] acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"] diff --git a/run_agent.py b/run_agent.py index a82c6417ae1f..85c1128d68e5 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: @@ -4954,6 +4966,101 @@ 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 neutral hint when xAI's OAuth surface returns the + permission-denied 403. + + 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/?_s=usage or subscribe + at https://grok.com/supergrok"} + + 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. + + 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) + + 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. + """ + 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 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 "X Premium+ does NOT include" 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. @@ -4987,12 +5094,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: @@ -7044,18 +7151,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 @@ -7334,12 +7471,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"): @@ -7464,6 +7607,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/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/" 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 diff --git a/scripts/release.py b/scripts/release.py index 740b79091b14..6084e0754c01 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", @@ -72,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", @@ -754,6 +756,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", 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/tests/acp/test_events.py b/tests/acp/test_events.py index c9f91a181ed6..ec0b32549da3 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -1,15 +1,19 @@ """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 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 ( + _build_plan_update_from_todo_result, + _send_update, make_message_cb, make_step_cb, make_thinking_cb, @@ -293,6 +297,54 @@ class TestStepCallback: } mock_send.assert_called_once() + 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"])} + 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] + 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", + "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"] + + 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 @@ -325,3 +377,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/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) 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/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/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/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), 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_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/tests/hermes_cli/test_banner_pip_update.py b/tests/hermes_cli/test_banner_pip_update.py new file mode 100644 index 000000000000..205c97488a90 --- /dev/null +++ b/tests/hermes_cli/test_banner_pip_update.py @@ -0,0 +1,35 @@ +from unittest.mock import patch + + +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() + assert result == 1 + + +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() + assert result == 0 + + +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() + 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") 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/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 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_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 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 diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 2bdc9b246215..8a68d6a178d0 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(): 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" 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", {}) 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_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py new file mode 100644 index 000000000000..9192d50695bd --- /dev/null +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -0,0 +1,581 @@ +"""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 surface the X Premium+ gotcha + neutral causes. + + 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 + + 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) + # 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 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 + + +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 — 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 + + error = RuntimeError( + "HTTP 403: do not have an active Grok subscription" + ) + summary = AIAgent._summarize_api_error(error) + # MUST NOT contain language that flatly assumes the user is unsubscribed. + assert "lacks SuperGrok" not 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(): + """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 "X Premium+ does NOT include" 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 + # Sanity: the hint did fire on the first pass. + assert "X Premium+ does NOT include" in once + + +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", []) + + +# --------------------------------------------------------------------------- +# 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 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. 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", 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_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/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/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/browser_tool.py b/tools/browser_tool.py index 575beba6c026..b3eb24ee0441 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: @@ -1702,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: " 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/delegate_tool.py b/tools/delegate_tool.py index f4da5127a18b..f3a037c4341a 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) @@ -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). 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/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/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) 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/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)"} diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 25b732a7c97c..5eff25e6514a 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -91,7 +91,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: 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 = [ { 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/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 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[] diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index c56d92112bb3..0298294e54b8 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -19,13 +19,16 @@ Prefer a native installer? Use the desktop release channel that matches your ris Stable desktop builds ship signed/notarized macOS artifacts and Windows installers with checksum files. -### Linux / macOS / WSL2 +### One-Line CLI Installer (Linux / macOS / WSL2) + +For a git-based install that tracks `main` and gives you the latest changes immediately: +>>>>>>> main ```bash curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` -### Windows (native, PowerShell) — Early Beta +### Windows CLI (native, PowerShell) — Early Beta :::warning Early BETA Native Windows support is **early beta**. It installs and works for the common paths, but hasn't been road-tested as broadly as our POSIX installers. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) when you hit rough edges. For the most battle-tested setup on Windows today, use the Linux/macOS one-liner above inside **WSL2** instead. @@ -76,7 +79,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`. @@ -104,7 +108,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 c53db9bfa5ff..35fbe596173f 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -48,7 +48,16 @@ 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 +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. + +**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..d4ced41a4d71 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: @@ -32,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` @@ -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/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..67d31c929ad9 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. @@ -208,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/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. | 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/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/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 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/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/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. 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 a0fb24b8c507..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', + ], + }, + ], + }, ], }, ], @@ -192,6 +630,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', ],