From 4d0f2bd241694e91d1172194f7a0f73d2d585ba6 Mon Sep 17 00:00:00 2001 From: Cornna <96944678+ymylive@users.noreply.github.com> Date: Thu, 28 May 2026 18:35:16 +0800 Subject: [PATCH 001/541] fix(gateway): use FIFO queue for busy_input_mode pending messages Closes #28503 --- gateway/run.py | 43 +++++++++++- tests/gateway/test_queue_consumption.py | 92 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 3f950685f1cc..917ce2a28cc5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3328,11 +3328,52 @@ class GatewayRunner: except Exception: return False + # Hard cap on per-session pending follow-ups for busy_input_mode=queue + # (and the draining/steer-fallback/subagent-demotion paths that share + # this entry point). Without a cap, a stuck agent + a rapid-fire user + # could grow the overflow list unboundedly. 32 turns of queued + # follow-ups is far beyond any realistic conversational backlog while + # still small enough to never threaten memory. + _BUSY_QUEUE_MAX_PENDING = 32 + def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None: adapter = self.adapters.get(event.source.platform) if not adapter: return - merge_pending_message_event(adapter._pending_messages, session_key, event) + # #28503 — Previously this called ``merge_pending_message_event`` + # with the default ``merge_text=False``, which silently OVERWROTE + # the single pending slot when consecutive text messages arrived + # in ``busy_input_mode: queue``. Route through the FIFO + # infrastructure shared with ``/queue`` so each follow-up gets + # its own turn in arrival order. Photo bursts still merge into + # the head slot via ``merge_pending_message_event`` (album + # semantics); everything else appends to the overflow tail. + pending_slot = getattr(adapter, "_pending_messages", None) + existing = pending_slot.get(session_key) if isinstance(pending_slot, dict) else None + if existing is not None and ( + getattr(existing, "message_type", None) == MessageType.PHOTO + or event.message_type == MessageType.PHOTO + or bool(getattr(existing, "media_urls", None)) + or bool(getattr(event, "media_urls", None)) + ): + # Preserve photo-burst / media-merge semantics for the head slot. + merge_pending_message_event( + adapter._pending_messages, + session_key, + event, + merge_text=event.message_type == MessageType.TEXT, + ) + return + + if self._queue_depth(session_key, adapter=adapter) >= self._BUSY_QUEUE_MAX_PENDING: + logger.warning( + "Dropping busy-mode follow-up for session %s — pending queue at cap (%d).", + session_key, + self._BUSY_QUEUE_MAX_PENDING, + ) + return + + self._enqueue_fifo(session_key, event, adapter) async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool: # --- Authorization gate (#17775) --- diff --git a/tests/gateway/test_queue_consumption.py b/tests/gateway/test_queue_consumption.py index 178d1965af92..792d7b7ea526 100644 --- a/tests/gateway/test_queue_consumption.py +++ b/tests/gateway/test_queue_consumption.py @@ -360,3 +360,95 @@ class TestQueueConsumptionAfterCompletion: e.text for e in runner._queued_events[session_key] ] assert collected == texts + + +class TestBusyInputModeQueueFifo: + """Regression coverage for issue #28503. + + ``busy_input_mode: queue`` rapid follow-ups used to silently overwrite + a single pending slot, losing every message except the last. The + runner's busy/queue/steer-fallback entry point now routes through + the same FIFO infrastructure as ``/queue``, so each follow-up gets + its own turn in arrival order. + """ + + def _make_runner_and_adapter(self): + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner._queued_events = {} + adapter = _StubAdapter() + runner.adapters = {Platform.TELEGRAM: adapter} + return runner, adapter + + def _text_event(self, text: str) -> MessageEvent: + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=f"m-{text}", + ) + + def test_rapid_text_followups_are_queued_in_fifo_order(self): + """Five rapid texts in queue mode must all survive (none silently dropped).""" + runner, adapter = self._make_runner_and_adapter() + session_key = "telegram:user:fifo" + + texts = ["one", "two", "three", "four", "five"] + for text in texts: + runner._queue_or_replace_pending_event(session_key, self._text_event(text)) + + # Head slot keeps the first; overflow keeps the rest in order. + assert adapter._pending_messages[session_key].text == "one" + assert [e.text for e in runner._queued_events[session_key]] == [ + "two", + "three", + "four", + "five", + ] + assert runner._queue_depth(session_key, adapter=adapter) == len(texts) + + def test_queue_respects_bounded_cap(self): + """Beyond the per-session cap, follow-ups are dropped (with a warning).""" + from gateway.run import GatewayRunner + + runner, adapter = self._make_runner_and_adapter() + session_key = "telegram:user:cap" + + cap = GatewayRunner._BUSY_QUEUE_MAX_PENDING + for i in range(cap + 5): + runner._queue_or_replace_pending_event( + session_key, self._text_event(f"msg-{i:03d}") + ) + + # Exactly ``cap`` follow-ups retained (head + cap-1 in overflow). + assert runner._queue_depth(session_key, adapter=adapter) == cap + assert adapter._pending_messages[session_key].text == "msg-000" + # The last accepted overflow item is msg-{cap-1}. + assert runner._queued_events[session_key][-1].text == f"msg-{cap - 1:03d}" + + def test_photo_burst_still_merges_in_head_slot(self): + """Photo bursts must keep album-merge semantics, not split into N turns.""" + runner, adapter = self._make_runner_and_adapter() + session_key = "telegram:user:burst" + + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + for i in range(3): + runner._queue_or_replace_pending_event( + session_key, + MessageEvent( + text="", + message_type=MessageType.PHOTO, + source=source, + message_id=f"p-{i}", + media_urls=[f"http://example.com/{i}.jpg"], + media_types=["image/jpeg"], + ), + ) + + # Single merged head event with all three media URLs. + assert session_key not in runner._queued_events or not runner._queued_events[session_key] + head = adapter._pending_messages[session_key] + assert head.message_type == MessageType.PHOTO + assert len(head.media_urls) == 3 From fec5ca71d8cabb7e770cc6e4a96317a64b970180 Mon Sep 17 00:00:00 2001 From: Cornna <96944678+ymylive@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:24:33 +0800 Subject: [PATCH 002/541] fix: preserve telegram queue fifo during grace window --- gateway/run.py | 15 +++++--- tests/gateway/test_busy_session_ack.py | 52 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 917ce2a28cc5..bd91061d1484 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7902,12 +7902,15 @@ class GatewayRunner: ) adapter = self.adapters.get(source.platform) if adapter: - merge_pending_message_event( - adapter._pending_messages, - _quick_key, - event, - merge_text=True, - ) + if self._busy_input_mode == "queue": + self._enqueue_fifo(_quick_key, event, adapter) + else: + merge_pending_message_event( + adapter._pending_messages, + _quick_key, + event, + merge_text=True, + ) return None running_agent = self._running_agents.get(_quick_key) diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 7fb3d3210c05..c5517c5f638e 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -27,6 +27,7 @@ sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext")) from gateway.platforms.base import ( MessageEvent, MessageType, + Platform, SessionSource, build_session_key, ) @@ -66,6 +67,8 @@ def _make_runner(): runner._busy_text_mode = "interrupt" runner.adapters = {} runner.config = MagicMock() + runner.config.group_sessions_per_user = True + runner.config.thread_sessions_per_user = False runner.session_store = None runner.hooks = MagicMock() runner.hooks.emit = AsyncMock() @@ -119,6 +122,55 @@ class TestBusySessionAck: assert sk not in runner._pending_messages running_agent.interrupt.assert_not_called() + @pytest.mark.asyncio + async def test_telegram_grace_followups_respect_queue_fifo(self, monkeypatch): + """Rapid Telegram text follow-ups in queue mode must not merge.""" + from gateway.run import GatewayRunner + + monkeypatch.setenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0") + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "queue" + runner._queued_events = {} + adapter = _make_adapter() + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="123", + chat_type="dm", + user_id="user1", + ) + sk = build_session_key(source) + runner.adapters[source.platform] = adapter + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "seconds_since_activity": 0.0, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() + + events = [ + MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=f"m-{idx}", + ) + for idx, text in enumerate(("first", "second", "third"), start=1) + ] + + for event in events: + result = await GatewayRunner._handle_message(runner, event) + assert result is None + + assert adapter._pending_messages[sk].text == "first" + assert [event.text for event in runner._queued_events[sk]] == [ + "second", + "third", + ] + agent.interrupt.assert_not_called() + @pytest.mark.asyncio async def test_sends_ack_when_agent_running(self): """First message during busy session should get a status ack.""" From 2e0c9083db8425d6e087ba0af6024406aa513d05 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 3 Jun 2026 11:22:06 -0700 Subject: [PATCH 003/541] feat(middleware): add adaptive execution intercepts Signed-off-by: Bryan Bednarski --- agent/agent_runtime_helpers.py | 200 ++++++++----- agent/conversation_loop.py | 42 ++- agent/tool_executor.py | 289 +++++++++++++++---- docs/middleware/README.md | 251 ++++++++++++++++ hermes_cli/middleware.py | 280 ++++++++++++++++++ hermes_cli/plugins.py | 88 +++++- model_tools.py | 44 ++- plugins/observability/nemo_relay/README.md | 185 ++++++++++++ plugins/observability/nemo_relay/__init__.py | 280 +++++++++++++++++- run_agent.py | 16 +- tests/hermes_cli/test_plugins.py | 111 +++++++ tests/plugins/test_nemo_relay_plugin.py | 239 ++++++++++++++- tests/run_agent/test_run_agent.py | 85 ++++++ tests/test_model_tools.py | 56 ++++ 14 files changed, 2015 insertions(+), 151 deletions(-) create mode 100644 docs/middleware/README.md create mode 100644 hermes_cli/middleware.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 09eccef5f341..2c2233450558 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1619,13 +1619,37 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str, tool_call_id: Optional[str] = None, messages: list = None, - pre_tool_block_checked: bool = False) -> str: + pre_tool_block_checked: bool = False, + skip_tool_request_middleware: bool = False, + tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str: """Invoke a single tool and return the result string. No display logic. Handles both agent-level tools (todo, memory, etc.) and registry-dispatched tools. Used by the concurrent execution path; the sequential path retains its own inline invocation for backward-compatible display handling. """ + if not isinstance(function_args, dict): + function_args = {} + + _tool_middleware_trace = list(tool_request_middleware_trace or []) + try: + from hermes_cli.middleware import apply_tool_request_middleware + + if not skip_tool_request_middleware: + _tool_request_mw = apply_tool_request_middleware( + function_name, + function_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) + function_args = _tool_request_mw.payload + _tool_middleware_trace = _tool_request_mw.trace + except Exception as _mw_err: + logger.debug("tool_request middleware error: %s", _mw_err) + # Check plugin hooks for a block directive before executing anything. block_message: Optional[str] = None if not pre_tool_block_checked: @@ -1639,6 +1663,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i tool_call_id=tool_call_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", + middleware_trace=list(_tool_middleware_trace), ) except Exception: pass @@ -1658,6 +1683,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i status="blocked", error_type="plugin_block", error_message=block_message, + middleware_trace=list(_tool_middleware_trace), ) except Exception: pass @@ -1665,12 +1691,13 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i tool_start_time = time.monotonic() - def _finish_agent_tool(result: Any) -> Any: + def _finish_agent_tool(result: Any, observed_args: Optional[dict] = None) -> Any: + hook_args = observed_args if isinstance(observed_args, dict) else function_args try: from model_tools import _emit_post_tool_call_hook _emit_post_tool_call_hook( function_name=function_name, - function_args=function_args, + function_args=hook_args, result=result, task_id=effective_task_id or "", session_id=getattr(agent, "session_id", "") or "", @@ -1678,89 +1705,116 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", duration_ms=int((time.monotonic() - tool_start_time) * 1000), + middleware_trace=list(_tool_middleware_trace), ) except Exception: pass return result if function_name == "todo": - from tools.todo_tool import todo_tool as _todo_tool - return _finish_agent_tool( - _todo_tool( - todos=function_args.get("todos"), - merge=function_args.get("merge", False), - store=agent._todo_store, + def _execute(next_args: dict) -> Any: + from tools.todo_tool import todo_tool as _todo_tool + return _finish_agent_tool( + _todo_tool( + todos=next_args.get("todos"), + merge=next_args.get("merge", False), + store=agent._todo_store, + ), + next_args, ) - ) elif function_name == "session_search": - session_db = agent._get_session_db_for_recall() - if not session_db: - from hermes_state import format_session_db_unavailable - return _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()})) - from tools.session_search_tool import session_search as _session_search - return _finish_agent_tool( - _session_search( - query=function_args.get("query", ""), - role_filter=function_args.get("role_filter"), - limit=function_args.get("limit", 3), - session_id=function_args.get("session_id"), - around_message_id=function_args.get("around_message_id"), - window=function_args.get("window", 5), - sort=function_args.get("sort"), - db=session_db, - current_session_id=agent.session_id, + def _execute(next_args: dict) -> Any: + session_db = agent._get_session_db_for_recall() + if not session_db: + from hermes_state import format_session_db_unavailable + return _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()}), next_args) + from tools.session_search_tool import session_search as _session_search + return _finish_agent_tool( + _session_search( + query=next_args.get("query", ""), + role_filter=next_args.get("role_filter"), + limit=next_args.get("limit", 3), + session_id=next_args.get("session_id"), + around_message_id=next_args.get("around_message_id"), + window=next_args.get("window", 5), + sort=next_args.get("sort"), + db=session_db, + current_session_id=agent.session_id, + ), + next_args, ) - ) elif function_name == "memory": - target = function_args.get("target", "memory") - from tools.memory_tool import memory_tool as _memory_tool - result = _memory_tool( - action=function_args.get("action"), - target=target, - content=function_args.get("content"), - old_text=function_args.get("old_text"), - store=agent._memory_store, - ) - # Bridge: notify external memory provider of built-in memory writes - if agent._memory_manager and function_args.get("action") in {"add", "replace"}: - try: - agent._memory_manager.on_memory_write( - function_args.get("action", ""), - target, - function_args.get("content", ""), - metadata=agent._build_memory_write_metadata( - task_id=effective_task_id, - tool_call_id=tool_call_id, - ), - ) - except Exception: - pass - return _finish_agent_tool(result) - elif agent._memory_manager and agent._memory_manager.has_tool(function_name): - return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, function_args)) - elif function_name == "clarify": - from tools.clarify_tool import clarify_tool as _clarify_tool - return _finish_agent_tool( - _clarify_tool( - question=function_args.get("question", ""), - choices=function_args.get("choices"), - callback=agent.clarify_callback, + def _execute(next_args: dict) -> Any: + target = next_args.get("target", "memory") + from tools.memory_tool import memory_tool as _memory_tool + result = _memory_tool( + action=next_args.get("action"), + target=target, + content=next_args.get("content"), + old_text=next_args.get("old_text"), + store=agent._memory_store, + ) + # Bridge: notify external memory provider of built-in memory writes + if agent._memory_manager and next_args.get("action") in {"add", "replace"}: + try: + agent._memory_manager.on_memory_write( + next_args.get("action", ""), + target, + next_args.get("content", ""), + metadata=agent._build_memory_write_metadata( + task_id=effective_task_id, + tool_call_id=tool_call_id, + ), + ) + except Exception: + pass + return _finish_agent_tool(result, next_args) + elif agent._memory_manager and agent._memory_manager.has_tool(function_name): + def _execute(next_args: dict) -> Any: + return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, next_args), next_args) + elif function_name == "clarify": + def _execute(next_args: dict) -> Any: + from tools.clarify_tool import clarify_tool as _clarify_tool + return _finish_agent_tool( + _clarify_tool( + question=next_args.get("question", ""), + choices=next_args.get("choices"), + callback=agent.clarify_callback, + ), + next_args, ) - ) elif function_name == "delegate_task": - return _finish_agent_tool(agent._dispatch_delegate_task(function_args)) + def _execute(next_args: dict) -> Any: + return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args) else: - return _ra().handle_function_call( - function_name, function_args, effective_task_id, - tool_call_id=tool_call_id, - session_id=agent.session_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, - skip_pre_tool_call_hook=True, - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - ) + def _execute(next_args: dict) -> Any: + return _ra().handle_function_call( + function_name, next_args, effective_task_id, + tool_call_id=tool_call_id, + session_id=agent.session_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, + skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), + tool_request_middleware_trace=list(_tool_middleware_trace), + ) + + from hermes_cli.middleware import run_tool_execution_middleware + + return run_tool_execution_middleware( + function_name, + function_args, + lambda next_args: _execute(next_args if isinstance(next_args, dict) else function_args), + original_args=function_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index c52b9b72d7d8..b66a6615ae09 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1225,6 +1225,28 @@ def run_conversation( _sanitize_structure_non_ascii(api_kwargs) if agent.api_mode == "codex_responses": api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False) + try: + from hermes_cli.middleware import apply_llm_request_middleware + + _llm_request_mw = apply_llm_request_middleware( + api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + ) + api_kwargs = _llm_request_mw.payload + _original_api_kwargs = _llm_request_mw.original_payload + _llm_middleware_trace = _llm_request_mw.trace + except Exception: + _original_api_kwargs = dict(api_kwargs) + _llm_middleware_trace = [] try: from hermes_cli.plugins import ( @@ -1277,6 +1299,7 @@ def run_conversation( request_char_count=total_chars, max_tokens=agent.max_tokens, started_at=api_start_time, + middleware_trace=list(_llm_middleware_trace), request=_request_payload, ) except Exception: @@ -1335,7 +1358,24 @@ def run_conversation( ) return agent._interruptible_api_call(next_api_kwargs) - response = _perform_api_call(api_kwargs) + from hermes_cli.middleware import run_llm_execution_middleware + + response = run_llm_execution_middleware( + api_kwargs, + _perform_api_call, + original_request=_original_api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + middleware_trace=list(_llm_middleware_trace), + ) api_duration = time.time() - api_start_time diff --git a/agent/tool_executor.py b/agent/tool_executor.py index fc3667edb50a..f908aedb8062 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -70,6 +70,7 @@ def _emit_terminal_post_tool_call( status: str | None = None, error_type: str | None = None, error_message: str | None = None, + middleware_trace: Optional[list[dict[str, Any]]] = None, ) -> None: try: from model_tools import _emit_post_tool_call_hook @@ -86,6 +87,7 @@ def _emit_terminal_post_tool_call( status=status, error_type=error_type, error_message=error_message, + middleware_trace=list(middleware_trace or []), ) except Exception: pass @@ -111,6 +113,7 @@ def _emit_cancelled_terminal_post_tool_call( start_time: float, reason: str = "user interrupt", error_type: str = "keyboard_interrupt", + middleware_trace: Optional[list[dict[str, Any]]] = None, ) -> str: result = _cancelled_tool_result(reason) _emit_terminal_post_tool_call( @@ -124,6 +127,7 @@ def _emit_cancelled_terminal_post_tool_call( status="cancelled", error_type=error_type, error_message=f"Tool execution cancelled by {reason}", + middleware_trace=list(middleware_trace or []), ) return result @@ -177,6 +181,65 @@ def _tool_search_scoped_names(agent) -> frozenset: return names +def _apply_tool_request_middleware_for_agent( + agent, + *, + function_name: str, + function_args: dict, + effective_task_id: str, + tool_call_id: str, +) -> tuple[dict, list[dict[str, Any]]]: + try: + from hermes_cli.middleware import apply_tool_request_middleware + + result = apply_tool_request_middleware( + function_name, + function_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) + payload = result.payload if isinstance(result.payload, dict) else function_args + return payload, list(result.trace) + except Exception as exc: + logger.debug("tool_request middleware error: %s", exc) + return function_args, [] + + +def _run_agent_tool_execution_middleware( + agent, + *, + function_name: str, + function_args: dict, + effective_task_id: str, + tool_call_id: str, + execute, +) -> tuple[Any, dict]: + observed_args = function_args + + def _execute(next_args: dict) -> Any: + nonlocal observed_args + observed_args = next_args if isinstance(next_args, dict) else function_args + return execute(observed_args) + + from hermes_cli.middleware import run_tool_execution_middleware + + result = run_tool_execution_middleware( + function_name, + function_args, + _execute, + original_args=function_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) + return result, observed_args + + def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: """Execute multiple tool calls concurrently using a thread pool. @@ -198,7 +261,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe return # ── Parse args + pre-execution bookkeeping ─────────────────────── - parsed_calls = [] # list of (tool_call, function_name, function_args) + parsed_calls = [] # list of (tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail) for tool_call in tool_calls: function_name = tool_call.function.name @@ -250,6 +313,14 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception: pass + function_args, middleware_trace = _apply_tool_request_middleware_for_agent( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + ) + # ── Block evaluation (BEFORE checkpoint preflight) ─────────── # We must know whether the tool will execute before touching # checkpoint state (dedup slot, real snapshots). @@ -268,6 +339,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe status="blocked", error_type="tool_scope_block", error_message=_ts_scope_block, + middleware_trace=list(middleware_trace), ) else: try: @@ -280,6 +352,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_call_id=getattr(tool_call, "id", "") or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", + middleware_trace=list(middleware_trace), ) except Exception: block_message = None @@ -296,6 +369,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe status="blocked", error_type="plugin_block", error_message=block_message, + middleware_trace=list(middleware_trace), ) else: guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args) @@ -312,6 +386,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe status="blocked", error_type="guardrail_block", error_message=getattr(guardrail_decision, "message", None) or "Tool blocked by guardrail policy", + middleware_trace=list(middleware_trace), ) # ── Checkpoint preflight (only for tools that will execute) ── @@ -338,13 +413,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception: pass - parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail)) + parsed_calls.append((tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail)) # ── Logging / callbacks ────────────────────────────────────────── - tool_names_str = ", ".join(name for _, name, _, _, _ in parsed_calls) + tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls) if not agent.quiet_mode: print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}") - for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1): + for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1): args_str = json.dumps(args, ensure_ascii=False) if agent.verbose_logging: print(f" 📞 Tool {i}: {name}({list(args.keys())})") @@ -353,7 +428,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}") - for tc, name, args, block_result, blocked_by_guardrail in parsed_calls: + for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls: if block_result is not None: continue if agent.tool_progress_callback: @@ -363,7 +438,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception as cb_err: logging.debug(f"Tool progress callback error: {cb_err}") - for tc, name, args, block_result, blocked_by_guardrail in parsed_calls: + for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls: if block_result is not None: continue if agent.tool_start_callback: @@ -373,18 +448,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe logging.debug(f"Tool start callback error: {cb_err}") # ── Concurrent execution ───────────────────────────────────────── - # Each slot holds (function_name, function_args, function_result, duration, error_flag, blocked_flag) + # Each slot holds (function_name, function_args, function_result, duration, error_flag, blocked_flag, middleware_trace) results = [None] * num_tools - for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls): + for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): if block_result is not None: - results[i] = (name, args, block_result, 0.0, True, True) + results[i] = (name, args, block_result, 0.0, True, True, middleware_trace) # Touch activity before launching workers so the gateway knows # we're executing tools (not stuck). agent._current_tool = tool_names_str agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") - def _run_tool(index, tool_call, function_name, function_args): + def _run_tool(index, tool_call, function_name, function_args, middleware_trace): """Worker function executed in a thread.""" # Register this worker tid so the agent can fan out an interrupt # to it — see AIAgent.interrupt(). Must happen first thing, and @@ -423,6 +498,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_call.id, messages=messages, pre_tool_block_checked=True, + skip_tool_request_middleware=True, + tool_request_middleware_trace=list(middleware_trace), ) except KeyboardInterrupt: try: @@ -436,10 +513,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", start_time=start, + middleware_trace=list(middleware_trace), ) duration = time.time() - start logger.info("tool %s cancelled (%.2fs)", function_name, duration) - results[index] = (function_name, function_args, result, duration, True, False) + results[index] = (function_name, function_args, result, duration, True, False, middleware_trace) return except Exception as tool_error: result = f"Error executing tool '{function_name}': {tool_error}" @@ -450,7 +528,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200]) else: logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result)) - results[index] = (function_name, function_args, result, duration, is_error, False) + results[index] = (function_name, function_args, result, duration, is_error, False, middleware_trace) finally: # Tear down worker-tid tracking. Clear any interrupt bit we may # have set so the next task scheduled onto this recycled tid @@ -475,7 +553,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe try: runnable_calls = [ (i, tc, name, args) - for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls) + for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls) if block_result is None ] futures = [] @@ -487,7 +565,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # _approval_session_key) AND thread-local approval/sudo # callbacks into the worker thread; clears callbacks on exit. f = executor.submit( - propagate_context_to_thread(_run_tool), i, tc, name, args + propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3] ) futures.append(f) @@ -545,7 +623,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe spinner.stop(f"⚡ {completed}/{num_tools} tools completed in {total_dur:.1f}s total") # ── Post-execution: display per-tool results ───────────────────── - for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls): + for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): r = results[i] blocked = False if r is None: @@ -562,6 +640,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe status="cancelled", error_type="keyboard_interrupt", error_message="Tool execution cancelled by user interrupt", + middleware_trace=list(middleware_trace), ) else: function_result = f"Error executing tool '{name}': thread did not return a result" @@ -575,10 +654,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe status="error", error_type="thread_missing_result", error_message=function_result, + middleware_trace=list(middleware_trace), ) tool_duration = 0.0 else: - function_name, function_args, function_result, tool_duration, is_error, blocked = r + function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r if not blocked: function_result = agent._append_guardrail_observation( @@ -738,6 +818,14 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe except Exception: pass + function_args, middleware_trace = _apply_tool_request_middleware_for_agent( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + ) + # Check plugin hooks for a block directive before executing. _block_msg: Optional[str] = None _block_error_type = "plugin_block" @@ -755,6 +843,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_call_id=getattr(tool_call, "id", "") or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", + middleware_trace=list(middleware_trace), ) except Exception: pass @@ -853,6 +942,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe status="blocked", error_type=_block_error_type, error_message=_block_msg, + middleware_trace=list(middleware_trace), ) elif _guardrail_block_decision is not None: # Tool blocked by tool-loop guardrail — synthesize exactly one @@ -869,71 +959,108 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe status="blocked", error_type="guardrail_block", error_message=getattr(_guardrail_block_decision, "message", None) or "Tool blocked by guardrail policy", + middleware_trace=list(middleware_trace), ) elif function_name == "todo": - from tools.todo_tool import todo_tool as _todo_tool - function_result = _todo_tool( - todos=function_args.get("todos"), - merge=function_args.get("merge", False), - store=agent._todo_store, + def _execute(next_args: dict) -> Any: + from tools.todo_tool import todo_tool as _todo_tool + return _todo_tool( + todos=next_args.get("todos"), + merge=next_args.get("merge", False), + store=agent._todo_store, + ) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, ) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}") elif function_name == "session_search": - session_db = agent._get_session_db_for_recall() - if not session_db: - from hermes_state import format_session_db_unavailable - function_result = json.dumps({"success": False, "error": format_session_db_unavailable()}) - else: + def _execute(next_args: dict) -> Any: + session_db = agent._get_session_db_for_recall() + if not session_db: + from hermes_state import format_session_db_unavailable + return json.dumps({"success": False, "error": format_session_db_unavailable()}) from tools.session_search_tool import session_search as _session_search - function_result = _session_search( - query=function_args.get("query", ""), - role_filter=function_args.get("role_filter"), - limit=function_args.get("limit", 3), - session_id=function_args.get("session_id"), - around_message_id=function_args.get("around_message_id"), - window=function_args.get("window", 5), - sort=function_args.get("sort"), + return _session_search( + query=next_args.get("query", ""), + role_filter=next_args.get("role_filter"), + limit=next_args.get("limit", 3), + session_id=next_args.get("session_id"), + around_message_id=next_args.get("around_message_id"), + window=next_args.get("window", 5), + sort=next_args.get("sort"), db=session_db, current_session_id=agent.session_id, ) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}") elif function_name == "memory": - target = function_args.get("target", "memory") - from tools.memory_tool import memory_tool as _memory_tool - function_result = _memory_tool( - action=function_args.get("action"), - target=target, - content=function_args.get("content"), - old_text=function_args.get("old_text"), - store=agent._memory_store, + def _execute(next_args: dict) -> Any: + target = next_args.get("target", "memory") + from tools.memory_tool import memory_tool as _memory_tool + result = _memory_tool( + action=next_args.get("action"), + target=target, + content=next_args.get("content"), + old_text=next_args.get("old_text"), + store=agent._memory_store, + ) + # Bridge: notify external memory provider of built-in memory writes + if agent._memory_manager and next_args.get("action") in {"add", "replace"}: + try: + agent._memory_manager.on_memory_write( + next_args.get("action", ""), + target, + next_args.get("content", ""), + metadata=agent._build_memory_write_metadata( + task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", None), + ), + ) + except Exception: + pass + return result + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, ) - # Bridge: notify external memory provider of built-in memory writes - if agent._memory_manager and function_args.get("action") in {"add", "replace"}: - try: - agent._memory_manager.on_memory_write( - function_args.get("action", ""), - target, - function_args.get("content", ""), - metadata=agent._build_memory_write_metadata( - task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", None), - ), - ) - except Exception: - pass tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}") elif function_name == "clarify": - from tools.clarify_tool import clarify_tool as _clarify_tool - function_result = _clarify_tool( - question=function_args.get("question", ""), - choices=function_args.get("choices"), - callback=agent.clarify_callback, + def _execute(next_args: dict) -> Any: + from tools.clarify_tool import clarify_tool as _clarify_tool + return _clarify_tool( + question=next_args.get("question", ""), + choices=next_args.get("choices"), + callback=agent.clarify_callback, + ) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, ) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): @@ -957,7 +1084,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._delegate_spinner = spinner _delegate_result = None try: - function_result = agent._dispatch_delegate_task(function_args) + def _execute(next_args: dict) -> Any: + return agent._dispatch_delegate_task(next_args) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) _delegate_result = function_result finally: agent._delegate_spinner = None @@ -978,7 +1114,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe spinner.start() _ce_result = None try: - function_result = agent.context_compressor.handle_tool_call(function_name, function_args, messages=messages) + def _execute(next_args: dict) -> Any: + return agent.context_compressor.handle_tool_call(function_name, next_args, messages=messages) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) _ce_result = function_result except Exception as tool_error: function_result = json.dumps({"error": f"Context engine tool '{function_name}' failed: {tool_error}"}) @@ -1002,7 +1147,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe spinner.start() _mem_result = None try: - function_result = agent._memory_manager.handle_tool_call(function_name, function_args) + def _execute(next_args: dict) -> Any: + return agent._memory_manager.handle_tool_call(function_name, next_args) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) _mem_result = function_result except Exception as tool_error: function_result = json.dumps({"error": f"Memory tool '{function_name}' failed: {tool_error}"}) @@ -1032,8 +1186,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe api_request_id=getattr(agent, "_current_api_request_id", "") or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), + tool_request_middleware_trace=list(middleware_trace), ) _spinner_result = function_result except KeyboardInterrupt: @@ -1044,6 +1200,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", start_time=tool_start_time, + middleware_trace=list(middleware_trace), ) _spinner_result = function_result try: @@ -1071,8 +1228,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe api_request_id=getattr(agent, "_current_api_request_id", "") or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), + tool_request_middleware_trace=list(middleware_trace), ) except KeyboardInterrupt: _emit_cancelled_terminal_post_tool_call( @@ -1082,6 +1241,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", start_time=tool_start_time, + middleware_trace=list(middleware_trace), ) try: agent.interrupt("keyboard interrupt") @@ -1126,6 +1286,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", duration_ms=int(tool_duration * 1000), + middleware_trace=list(middleware_trace), ) if not _execution_blocked: function_result = agent._append_guardrail_observation( diff --git a/docs/middleware/README.md b/docs/middleware/README.md new file mode 100644 index 000000000000..b385b87eb29b --- /dev/null +++ b/docs/middleware/README.md @@ -0,0 +1,251 @@ +# Hermes Middleware + +Hermes middleware is the behavior-changing companion to observer hooks. +Observer hooks report what happened. Middleware can change what happens by +rewriting a request before execution or by wrapping the execution callback +itself. + +This contract is intentionally backend-neutral. A plugin can use it for local +policy, request shaping, tracing, adaptive routing, cache control, sandbox +selection, or handoff to runtimes such as NeMo Relay without changing Hermes' +planner, model provider adapters, tool registry, memory, or CLI UX. + +With middleware enabled, plugins can: + +- Rewrite LLM provider request kwargs before Hermes calls the provider. +- Rewrite tool arguments before guardrails, approval checks, hooks, and tool + execution see them. +- Wrap the actual LLM execution callback while preserving Hermes retry, + streaming, interrupt, and hook behavior. +- Wrap the actual tool execution callback while preserving Hermes guardrails, + approval, post-tool hooks, and tool-result transformation. + +## Contract + +Plugins register middleware from `register(ctx)`: + +```python +def register(ctx): + ctx.register_middleware("llm_request", on_llm_request) + ctx.register_middleware("llm_execution", on_llm_execution) + ctx.register_middleware("tool_request", on_tool_request) + ctx.register_middleware("tool_execution", on_tool_execution) +``` + +Every middleware callback receives: + +- `telemetry_schema_version`: currently `hermes.observer.v1` +- `middleware_schema_version`: currently `hermes.middleware.v1` +- Runtime context such as `session_id`, `task_id`, `turn_id`, + `api_request_id`, `provider`, `model`, `api_mode`, `tool_name`, and + `tool_call_id` when applicable. + +Supported middleware kinds: + +| Kind | Payload | Return shape | Purpose | +| --- | --- | --- | --- | +| `llm_request` | `request`, `original_request` | `{"request": {...}}` | Replace effective provider kwargs before provider execution. | +| `tool_request` | `tool_name`, `args`, `original_args` | `{"args": {...}}` | Replace effective tool args before hooks, guardrails, approvals, and execution. | +| `llm_execution` | `request`, `original_request`, `next_call` | Any provider response | Wrap or replace the actual provider call. | +| `tool_execution` | `tool_name`, `args`, `original_args`, `next_call` | Any tool result | Wrap or replace the actual tool call. | + +Request middleware can return optional trace fields: + +```python +return { + "request": updated_request, + "source": "my-plugin", + "reason": "selected fallback model", +} +``` + +Hermes stores those trace entries in later observer hook payloads as +`middleware_trace`. + +Execution middleware receives a `next_call` callback. Call it to continue the +chain: + +```python +def on_tool_execution(**kwargs): + result = kwargs["next_call"](kwargs["args"]) + return result +``` + +If multiple plugins register the same execution middleware kind, Hermes runs +them as a nested chain in registration order. Middleware failures are fail-open: +Hermes logs a warning and continues with the next middleware or the base +runtime path. + +## Execution Order + +### LLM Calls + +For each provider request, Hermes applies middleware in this order: + +1. Build provider kwargs from the current conversation. +2. Apply `llm_request` middleware. +3. Emit `pre_api_request` observer hooks with the effective request. +4. Run provider execution through `llm_execution` middleware. +5. Emit `post_api_request` or `api_request_error` observer hooks. + +Request middleware sees the full provider kwargs, including `messages` or +Responses API `input`, model settings, tool definitions, stream options, and +provider-specific options. Execution middleware receives the same effective +request plus `next_call`. + +### Tool Calls + +For each tool call, Hermes applies middleware in this order: + +1. Parse and coerce model-provided tool arguments. +2. Apply `tool_request` middleware. +3. Run the normal Hermes pre-execution path against the effective arguments: + tool availability checks, observer block directives, guardrails, and + approval checks. +4. Run tool execution through `tool_execution` middleware. +5. Emit `post_tool_call` observer hooks. +6. Apply `transform_tool_result` hooks before the result is appended back into + conversation context. + +Tool request middleware runs before approval checks. Use it carefully: a +rewritten path, command, or URL is the value downstream policy will evaluate. + +## Enablement + +Middleware only runs for enabled plugins. For a bundled plugin: + +```bash +hermes plugins enable +``` + +For isolated local testing, use one `HERMES_HOME` for plugin enablement and the +agent run: + +```bash +export HERMES_HOME=/tmp/hermes-middleware-test +mkdir -p "$HERMES_HOME" +hermes plugins enable +hermes chat --query 'Reply exactly ok' +``` + +For source checkouts, prefer the source command so the runtime sees plugins and +middleware from the working tree: + +```bash +uv sync +uv run hermes plugins enable +uv run hermes chat --query 'Reply exactly ok' +``` + +## Generic Plugin Examples + +The examples below are intentionally small. They show the middleware contract +shape without depending on NeMo Relay. + +### LLM Request Middleware + +This plugin tags provider requests and records a middleware trace entry: + +```python +def register(ctx): + ctx.register_middleware("llm_request", tag_llm_request) + + +def tag_llm_request(**kwargs): + request = dict(kwargs["request"]) + extra_body = dict(request.get("extra_body") or {}) + extra_body.setdefault("metadata", {})["hermes_middleware_demo"] = True + request["extra_body"] = extra_body + return { + "request": request, + "source": "middleware-demo", + "reason": "tagged provider request", + } +``` + +The effective request is passed to `pre_api_request`, provider execution, and +`post_api_request`. + +### Tool Request Middleware + +This plugin constrains `terminal` calls to a known working directory: + +```python +def register(ctx): + ctx.register_middleware("tool_request", normalize_terminal_workdir) + + +def normalize_terminal_workdir(**kwargs): + if kwargs.get("tool_name") != "terminal": + return None + args = dict(kwargs["args"]) + args.setdefault("workdir", "/tmp/hermes-middleware-demo") + return { + "args": args, + "source": "middleware-demo", + "reason": "defaulted terminal workdir", + } +``` + +Because this runs before hooks and approvals, downstream telemetry and policy +observe the rewritten `workdir`. + +### LLM Execution Middleware + +This plugin wraps the provider call and preserves the raw provider response: + +```python +import time + + +def register(ctx): + ctx.register_middleware("llm_execution", time_llm_execution) + + +def time_llm_execution(**kwargs): + started = time.monotonic() + response = kwargs["next_call"](kwargs["request"]) + elapsed_ms = int((time.monotonic() - started) * 1000) + print(f"llm_execution elapsed_ms={elapsed_ms}") + return response +``` + +Return the same response shape Hermes expects from the provider adapter. Do not +wrap the response in a plugin-specific envelope unless the rest of the runtime +expects that envelope. + +### Tool Execution Middleware + +This plugin wraps tool execution while preserving the tool result: + +```python +def register(ctx): + ctx.register_middleware("tool_execution", annotate_tool_execution) + + +def annotate_tool_execution(**kwargs): + result = kwargs["next_call"](kwargs["args"]) + # Metrics, logging, or external routing can happen here. + return result +``` + +Execution middleware may call `next_call(modified_args)` to pass a changed +payload to later middleware and the base tool dispatcher. + +Plugin-specific examples should live with the plugin that owns the behavior. +For NeMo Relay adaptive execution middleware, see +[`plugins/observability/nemo_relay/README.md`](../../plugins/observability/nemo_relay/README.md). + +## Safety Notes + +- Middleware should be deterministic for the same input unless it is explicitly + routing to a dynamic external system. +- Request middleware should return complete replacement payloads, not partial + patches. +- Execution middleware should call `next_call(...)` exactly once unless it is + intentionally short-circuiting execution. +- Tool request middleware runs before approvals. If it mutates file paths, + commands, URLs, or arguments, the mutated values are what guardrails and + approvals evaluate. +- Observer hooks remain the right place for read-only telemetry. Use middleware + only when a plugin needs to alter or wrap behavior. diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py new file mode 100644 index 000000000000..938bffcf1723 --- /dev/null +++ b/hermes_cli/middleware.py @@ -0,0 +1,280 @@ +"""Hermes middleware contract helpers. + +Observer hooks report what happened. Middleware can change what happens by +rewriting a request or wrapping the actual execution callback. Keep the small +contract helpers here so agent-loop call sites and plugins share one vocabulary. +""" + +from __future__ import annotations + +import logging +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + +OBSERVER_SCHEMA_VERSION = "hermes.observer.v1" +MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1" + +TOOL_REQUEST_MIDDLEWARE = "tool_request" +TOOL_EXECUTION_MIDDLEWARE = "tool_execution" +LLM_REQUEST_MIDDLEWARE = "llm_request" +LLM_EXECUTION_MIDDLEWARE = "llm_execution" + +# Back-compat aliases for older PoC branches that used API terminology. +API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE +API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE + +VALID_MIDDLEWARE: set[str] = { + TOOL_REQUEST_MIDDLEWARE, + TOOL_EXECUTION_MIDDLEWARE, + LLM_REQUEST_MIDDLEWARE, + LLM_EXECUTION_MIDDLEWARE, +} + + +@dataclass +class RequestMiddlewareResult: + """Result of applying request middleware to a mutable payload.""" + + payload: Any + original_payload: Any + changed: bool = False + trace: List[Dict[str, Any]] = field(default_factory=list) + + +def observer_payload(**kwargs: Any) -> Dict[str, Any]: + kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) + return kwargs + + +def middleware_payload(**kwargs: Any) -> Dict[str, Any]: + kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION) + kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION) + return kwargs + + +def apply_llm_request_middleware( + request: Dict[str, Any], + **context: Any, +) -> RequestMiddlewareResult: + """Apply registered LLM request middleware. + + Middleware may return ``{"request": {...}}`` to replace the effective + provider kwargs before Hermes sends them. + """ + if not _has_middleware(LLM_REQUEST_MIDDLEWARE): + return RequestMiddlewareResult( + payload=request, + original_payload=request, + changed=False, + trace=[], + ) + + original_request = deepcopy(request) + current_request = deepcopy(original_request) + trace: List[Dict[str, Any]] = [] + + for result in _invoke_middleware( + LLM_REQUEST_MIDDLEWARE, + request=current_request, + original_request=original_request, + **context, + ): + if not isinstance(result, dict): + continue + next_request = result.get("request") + if not isinstance(next_request, dict): + continue + current_request = deepcopy(next_request) + trace.append(_trace_entry(result)) + + return RequestMiddlewareResult( + payload=current_request, + original_payload=original_request, + changed=bool(trace), + trace=trace, + ) + + +def apply_tool_request_middleware( + tool_name: str, + args: Dict[str, Any], + **context: Any, +) -> RequestMiddlewareResult: + """Apply registered tool request middleware. + + Middleware may return ``{"args": {...}}`` to replace the effective tool + arguments before hooks, guardrails, approvals, and execution see them. + """ + if not _has_middleware(TOOL_REQUEST_MIDDLEWARE): + return RequestMiddlewareResult( + payload=args, + original_payload=args, + changed=False, + trace=[], + ) + + original_args = deepcopy(args) + current_args = deepcopy(original_args) + trace: List[Dict[str, Any]] = [] + + for result in _invoke_middleware( + TOOL_REQUEST_MIDDLEWARE, + tool_name=tool_name, + args=current_args, + original_args=original_args, + **context, + ): + if not isinstance(result, dict): + continue + next_args = result.get("args") + if not isinstance(next_args, dict): + continue + current_args = deepcopy(next_args) + trace.append(_trace_entry(result)) + + return RequestMiddlewareResult( + payload=current_args, + original_payload=original_args, + changed=bool(trace), + trace=trace, + ) + + +def apply_api_request_middleware( + request: Dict[str, Any], + **context: Any, +) -> RequestMiddlewareResult: + """Compatibility wrapper for older ``api_request`` naming.""" + return apply_llm_request_middleware(request, **context) + + +def run_llm_execution_middleware( + request: Dict[str, Any], + next_call: Callable[[Dict[str, Any]], Any], + **context: Any, +) -> Any: + """Run provider execution through registered LLM execution middleware.""" + callbacks = _get_middleware_callbacks(LLM_EXECUTION_MIDDLEWARE) + if not callbacks: + return next_call(request) + return _run_execution_chain( + LLM_EXECUTION_MIDDLEWARE, + callbacks, + next_call, + request=request, + original_request=context.pop("original_request", request), + **context, + ) + + +def run_tool_execution_middleware( + tool_name: str, + args: Dict[str, Any], + next_call: Callable[[Dict[str, Any]], Any], + **context: Any, +) -> Any: + """Run tool execution through registered tool execution middleware.""" + callbacks = _get_middleware_callbacks(TOOL_EXECUTION_MIDDLEWARE) + if not callbacks: + return next_call(args) + return _run_execution_chain( + TOOL_EXECUTION_MIDDLEWARE, + callbacks, + next_call, + tool_name=tool_name, + args=args, + original_args=context.pop("original_args", args), + **context, + ) + + +def run_api_execution_middleware( + request: Dict[str, Any], + next_call: Callable[[Dict[str, Any]], Any], + **context: Any, +) -> Any: + """Compatibility wrapper for older ``api_execution`` naming.""" + return run_llm_execution_middleware(request, next_call, **context) + + +def _invoke_middleware(kind: str, **kwargs: Any) -> List[Any]: + from hermes_cli.plugins import invoke_middleware + + return invoke_middleware(kind, **middleware_payload(**kwargs)) + + +def _has_middleware(kind: str) -> bool: + from hermes_cli.plugins import has_middleware + + return has_middleware(kind) + + +def _get_middleware_callbacks(kind: str) -> List[Callable]: + from hermes_cli.plugins import get_plugin_manager + + return list(get_plugin_manager()._middleware.get(kind, [])) + + +def _run_execution_chain( + kind: str, + callbacks: List[Callable], + terminal_call: Callable[[Any], Any], + **kwargs: Any, +) -> Any: + payload_key = "request" if "request" in kwargs else "args" + + class _DownstreamExecutionError(Exception): + def __init__(self, original: BaseException) -> None: + super().__init__(str(original)) + self.original = original + + def call_at(index: int, payload: Any) -> Any: + if index >= len(callbacks): + return terminal_call(payload) + + callback = callbacks[index] + next_called = False + next_result: Any = None + + def next_call(next_payload: Any = None) -> Any: + nonlocal next_called, next_result + next_called = True + try: + next_result = call_at(index + 1, payload if next_payload is None else next_payload) + return next_result + except BaseException as exc: + raise _DownstreamExecutionError(exc) from exc + + call_kwargs = middleware_payload(**kwargs) + call_kwargs[payload_key] = payload + call_kwargs["next_call"] = next_call + try: + return callback(**call_kwargs) + except _DownstreamExecutionError as exc: + raise exc.original + except Exception as exc: + logger.warning( + "Middleware '%s' callback %s raised: %s", + kind, + getattr(callback, "__name__", repr(callback)), + exc, + ) + if next_called: + return next_result + return call_at(index + 1, payload) + + return call_at(0, kwargs[payload_key]) + + +def _trace_entry(result: Dict[str, Any]) -> Dict[str, Any]: + entry: Dict[str, Any] = {} + for key in ("source", "reason", "name"): + value = result.get(key) + if isinstance(value, str) and value: + entry[key] = value + if not entry: + entry["source"] = "plugin" + return entry diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index fd449fc27a43..d5cb7e8fe015 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union from hermes_constants import get_hermes_home from utils import env_var_enabled from hermes_cli.config import cfg_get -OBSERVER_SCHEMA_VERSION = "hermes.observer.v1" +from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE def get_bundled_plugins_dir() -> Path: @@ -277,6 +277,7 @@ class LoadedPlugin: module: Optional[types.ModuleType] = None tools_registered: List[str] = field(default_factory=list) hooks_registered: List[str] = field(default_factory=list) + middleware_registered: List[str] = field(default_factory=list) commands_registered: List[str] = field(default_factory=list) enabled: bool = False error: Optional[str] = None @@ -952,6 +953,27 @@ class PluginContext: self._manager._hooks.setdefault(hook_name, []).append(callback) logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name) + # -- middleware registration ------------------------------------------- + + def register_middleware(self, kind: str, callback: Callable) -> None: + """Register a behavior-changing middleware callback. + + Middleware is separate from observer hooks: request middleware may + rewrite the effective payload, and execution middleware may wrap the + real callback. Unknown kinds are stored for forward compatibility but + warned so plugin authors can catch typos. + """ + if kind not in VALID_MIDDLEWARE: + logger.warning( + "Plugin '%s' registered unknown middleware '%s' " + "(valid: %s)", + self.manifest.name, + kind, + ", ".join(sorted(VALID_MIDDLEWARE)), + ) + self._manager._middleware.setdefault(kind, []).append(callback) + logger.debug("Plugin %s registered middleware: %s", self.manifest.name, kind) + # -- skill registration ------------------------------------------------- def register_skill( @@ -1010,6 +1032,7 @@ class PluginManager: def __init__(self) -> None: self._plugins: Dict[str, LoadedPlugin] = {} self._hooks: Dict[str, List[Callable]] = {} + self._middleware: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() self._plugin_platform_names: Set[str] = set() self._cli_commands: Dict[str, dict] = {} @@ -1039,6 +1062,7 @@ class PluginManager: if force: self._plugins.clear() self._hooks.clear() + self._middleware.clear() self._plugin_tool_names.clear() self._cli_commands.clear() self._plugin_commands.clear() @@ -1449,15 +1473,28 @@ class PluginManager: for h in p.hooks_registered } ) + loaded.middleware_registered = list( + { + kind + for kind, cbs in self._middleware.items() + if cbs + } + - { + kind + for name, p in self._plugins.items() + for kind in p.middleware_registered + } + ) loaded.commands_registered = [ c for c in self._plugin_commands if self._plugin_commands[c].get("plugin") == manifest.name ] loaded.enabled = True logger.debug( - " registered: %d tool(s), %d hook(s), %d slash command(s), %d CLI command(s)", + " registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)", len(loaded.tools_registered), len(loaded.hooks_registered), + len(loaded.middleware_registered), len(loaded.commands_registered), sum( 1 for c in self._cli_commands @@ -1575,6 +1612,33 @@ class PluginManager: """Return True when at least one callback is registered for a hook.""" return bool(self._hooks.get(hook_name)) + def has_middleware(self, kind: str) -> bool: + """Return True when at least one callback is registered for middleware.""" + return bool(self._middleware.get(kind)) + + def invoke_middleware(self, kind: str, **kwargs: Any) -> List[Any]: + """Call registered middleware callbacks for *kind*. + + Each callback is isolated so one plugin cannot break the base runtime + path. Middleware that wants to change behavior must return the shape + documented by the caller-specific contract. + """ + callbacks = self._middleware.get(kind, []) + results: List[Any] = [] + for cb in callbacks: + try: + ret = cb(**kwargs) + if ret is not None: + results.append(ret) + except Exception as exc: + logger.warning( + "Middleware '%s' callback %s raised: %s", + kind, + getattr(cb, "__name__", repr(cb)), + exc, + ) + return results + # ----------------------------------------------------------------------- # Introspection # ----------------------------------------------------------------------- @@ -1594,6 +1658,7 @@ class PluginManager: "enabled": loaded.enabled, "tools": len(loaded.tools_registered), "hooks": len(loaded.hooks_registered), + "middleware": len(loaded.middleware_registered), "commands": len(loaded.commands_registered), "error": loaded.error, } @@ -1655,6 +1720,23 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: return get_plugin_manager().invoke_hook(hook_name, **kwargs) +def invoke_middleware(kind: str, **kwargs: Any) -> List[Any]: + """Invoke registered middleware callbacks. + + Returns a list of non-``None`` return values from middleware callbacks. + """ + return get_plugin_manager().invoke_middleware(kind, **kwargs) + + +def has_middleware(kind: str) -> bool: + """Return True when middleware callbacks are registered for ``kind``.""" + manager = get_plugin_manager() + method = getattr(manager, "has_middleware", None) + if callable(method): + return bool(method(kind)) + return bool(getattr(manager, "_middleware", {}).get(kind)) + + def has_hook(hook_name: str) -> bool: """Return True when a hook has registered callbacks.""" return get_plugin_manager().has_hook(hook_name) @@ -1683,6 +1765,7 @@ def get_pre_tool_call_block_message( tool_call_id: str = "", turn_id: str = "", api_request_id: str = "", + middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> Optional[str]: """Check ``pre_tool_call`` hooks for a blocking directive. @@ -1709,6 +1792,7 @@ def get_pre_tool_call_block_message( tool_call_id=tool_call_id, turn_id=turn_id, api_request_id=api_request_id, + middleware_trace=list(middleware_trace or []), ) for result in hook_results: diff --git a/model_tools.py b/model_tools.py index c3a9c98c60c7..9d04ada2d758 100644 --- a/model_tools.py +++ b/model_tools.py @@ -823,6 +823,7 @@ def _emit_post_tool_call_hook( status: Optional[str] = None, error_type: Optional[str] = None, error_message: Optional[str] = None, + middleware_trace: Optional[List[Dict[str, Any]]] = None, ) -> None: """Emit the ``post_tool_call`` observer hook. @@ -853,6 +854,7 @@ def _emit_post_tool_call_hook( status=status, error_type=error_type, error_message=error_message, + middleware_trace=list(middleware_trace or []), ) except Exception as _hook_err: logger.debug("post_tool_call hook error: %s", _hook_err) @@ -869,6 +871,8 @@ def handle_function_call( user_task: Optional[str] = None, enabled_tools: Optional[List[str]] = None, skip_pre_tool_call_hook: bool = False, + skip_tool_request_middleware: bool = False, + tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None, enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None, ) -> str: @@ -900,6 +904,7 @@ def handle_function_call( function_args = coerce_tool_args(function_name, function_args) if not isinstance(function_args, dict): function_args = {} + _tool_middleware_trace = list(tool_request_middleware_trace or []) # ── Tool Search bridge dispatch ────────────────────────────────── # tool_search and tool_describe are pure catalog reads — handle them @@ -970,10 +975,32 @@ def handle_function_call( user_task=user_task, enabled_tools=enabled_tools, skip_pre_tool_call_hook=skip_pre_tool_call_hook, + skip_tool_request_middleware=skip_tool_request_middleware, + tool_request_middleware_trace=list(_tool_middleware_trace), enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, ) + _tool_original_args = dict(function_args) + if not skip_tool_request_middleware: + try: + from hermes_cli.middleware import apply_tool_request_middleware + + _tool_request_mw = apply_tool_request_middleware( + function_name, + function_args, + task_id=task_id or "", + session_id=session_id or "", + tool_call_id=tool_call_id or "", + turn_id=turn_id or "", + api_request_id=api_request_id or "", + ) + function_args = _tool_request_mw.payload + _tool_original_args = _tool_request_mw.original_payload + _tool_middleware_trace = _tool_request_mw.trace + except Exception as _mw_err: + logger.debug("tool_request middleware error: %s", _mw_err) + try: if function_name in _AGENT_LOOP_TOOLS: return json.dumps({"error": f"{function_name} must be handled by the agent loop"}) @@ -1000,6 +1027,7 @@ def handle_function_call( tool_call_id=tool_call_id or "", turn_id=turn_id or "", api_request_id=api_request_id or "", + middleware_trace=list(_tool_middleware_trace), ) except Exception as _hook_err: logger.debug("pre_tool_call hook error: %s", _hook_err) @@ -1018,6 +1046,7 @@ def handle_function_call( status="blocked", error_type="plugin_block", error_message=block_message, + middleware_trace=list(_tool_middleware_trace), ) return result @@ -1082,7 +1111,19 @@ def handle_function_call( task_id=task_id, user_task=user_task, ) - result = _dispatch(function_args) + from hermes_cli.middleware import run_tool_execution_middleware + + result = run_tool_execution_middleware( + function_name, + function_args, + _dispatch, + original_args=_tool_original_args, + task_id=task_id or "", + session_id=session_id or "", + tool_call_id=tool_call_id or "", + turn_id=turn_id or "", + api_request_id=api_request_id or "", + ) finally: if _approval_tokens is not None and reset_current_observability_context is not None: try: @@ -1101,6 +1142,7 @@ def handle_function_call( turn_id=turn_id, api_request_id=api_request_id, duration_ms=duration_ms, + middleware_trace=list(_tool_middleware_trace), ) # Generic tool-result canonicalization seam: plugins receive the diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index f1a2c3b7dc39..b53766962131 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -165,6 +165,28 @@ When `HERMES_NEMO_RELAY_PLUGINS_TOML` is set and initializes successfully, NeMo Relay owns exporter lifecycle through that config. The direct `HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped. +To enable NeMo Relay managed execution intercepts for provider and tool calls, +include an adaptive component in the same `plugins.toml`: + +```toml +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +``` + +When the adaptive component is enabled and the installed NeMo Relay runtime +exposes `llm.execute(...)` / `tools.execute(...)`, Hermes routes LLM and tool +execution through those middleware boundaries. The observer hooks still emit +session, turn, approval, and subagent marks; the plugin skips its manual +`llm.call` and `tools.call` spans for executions that are already managed by +NeMo Relay. + +For the full generic Hermes middleware contract, see +[`docs/middleware/README.md`](../../../docs/middleware/README.md). + ## Canonical Local Examples The examples below use the official `nemo-relay==0.3` distribution and a local @@ -366,3 +388,166 @@ subagent IDs, role/status fields when present, and derived `parent_trajectory_id` / `child_trajectory_id` values. This keeps the ATOF stream lossless for later ATIF conversion that can compact subagents into separate trajectories. + +## Adaptive Middleware Example + +The `observability/nemo_relay` plugin uses Hermes execution middleware to hand +LLM and tool calls to NeMo Relay managed execution when an adaptive component is +enabled. + +Minimal `plugins.toml`: + +```toml +version = 1 + +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +``` + +Enable it for Hermes: + +```bash +export HERMES_NEMO_RELAY_PLUGINS_TOML=/tmp/hermes-middleware-test/plugins.toml +``` + +When the adaptive component is enabled and the installed NeMo Relay runtime +exposes `llm.execute(...)` and `tools.execute(...)`, Hermes routes execution +through these boundaries: + +```text +Hermes provider call + -> llm_execution middleware + -> nemo_relay.llm.execute(...) + -> Hermes provider adapter next_call(...) + +Hermes tool call + -> tool_execution middleware + -> nemo_relay.tools.execute(...) + -> Hermes tool dispatcher next_call(...) +``` + +The plugin still emits observer marks for sessions, turns, approvals, and +subagents. When adaptive managed execution is active, it skips manual +`llm.call` and `tools.call` observer spans to avoid duplicate LLM/tool events +for the same execution. + +### Local Adaptive E2E + +This example enables both NeMo Relay observability export and adaptive execution +middleware for a local Hermes run. + +```bash +pip install "nemo-relay==0.3" + +export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home +mkdir -p "$HERMES_HOME" /tmp/hermes-middleware-test/nemo-relay + +cat > "$HERMES_HOME/config.yaml" <<'YAML' +model: + provider: custom + default: qwen3.6:35b + base_url: http://127.0.0.1:11434/v1 + api_key: ollama +plugins: + enabled: + - observability/nemo_relay +YAML + +cat > /tmp/hermes-middleware-test/nemo-relay/plugins.toml <<'TOML' +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "/tmp/hermes-middleware-test/atof" +filename = "middleware-events.jsonl" +mode = "overwrite" + +[components.config.atif] +enabled = true +output_directory = "/tmp/hermes-middleware-test/atif" +filename_template = "middleware-trajectory-{session_id}.json" +agent_name = "Hermes Middleware E2E" +agent_version = "local" + +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +TOML + +export HERMES_NEMO_RELAY_PLUGINS_TOML=/tmp/hermes-middleware-test/nemo-relay/plugins.toml + +hermes chat \ + --query 'Use the terminal tool exactly once to run printf middleware_execution_ok. Then reply with exactly the command output.' \ + --provider custom \ + --model qwen3.6:35b \ + --toolsets terminal \ + --max-turns 4 \ + --quiet \ + --accept-hooks +``` + +Expected CLI output: + +```text +session_id: middleware-demo-session +middleware_execution_ok +``` + +Expected ATOF shape: + +```jsonl +{"kind":"scope","category":"llm","name":"custom","scope_category":"start","metadata":{"session_id":"middleware-demo-session"},"data":{"mode":"route"}} +{"kind":"scope","category":"tool","name":"terminal","scope_category":"start","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal"},"data":{"mode":"route"}} +{"kind":"scope","category":"tool","name":"terminal","scope_category":"end","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal","status":"ok"},"data":"{\"output\":\"middleware_execution_ok\",\"exit_code\":0,\"error\":null}"} +``` + +Expected ATIF shape: + +```json +{ + "schema_version": "ATIF-v1.7", + "session_id": "middleware-demo-session", + "agent": { + "name": "Hermes Middleware E2E", + "version": "local", + "model_name": "qwen3.6:35b" + }, + "steps": [ + { + "source": "agent", + "tool_calls": [ + { + "function_name": "terminal", + "arguments": {"command": "printf middleware_execution_ok"} + } + ], + "observation": { + "results": [ + { + "source_call_id": "call_terminal", + "content": "{\"output\":\"middleware_execution_ok\",\"exit_code\":0,\"error\":null}" + } + ] + } + }, + { + "source": "agent", + "message": "middleware_execution_ok" + } + ] +} +``` diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index 25078a21c6d7..cd1587fdab0c 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -42,6 +42,9 @@ class _SubagentParent: @dataclass class _Settings: plugins_toml_path: str = "" + plugins_config: dict[str, Any] | None = None + adaptive_enabled: bool = False + adaptive_mode: str = "observe" atof_enabled: bool = False atof_output_directory: str = "" atof_filename: str = "hermes-atof.jsonl" @@ -67,17 +70,15 @@ class _Runtime: self._configure_atof() def _configure_plugins_toml(self) -> bool: - if not self.settings.plugins_toml_path: + if not self.settings.plugins_config: return False plugin_mod = getattr(self.nemo_relay, "plugin", None) initialize = getattr(plugin_mod, "initialize", None) if not callable(initialize): return False - config_path = Path(self.settings.plugins_toml_path) try: - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - self._ensure_plugin_config_output_dirs(config) - result = initialize(config) + self._ensure_plugin_config_output_dirs(self.settings.plugins_config) + result = initialize(self.settings.plugins_config) if inspect.isawaitable(result): asyncio.run(result) return True @@ -221,6 +222,100 @@ class _Runtime: self.subagent_parents.pop(child_session_id, None) self.mark("hermes.subagent.stop", kwargs) + def managed_llm_enabled(self) -> bool: + return ( + self.settings.adaptive_enabled + and callable(getattr(getattr(self.nemo_relay, "llm", None), "execute", None)) + and callable(getattr(self.nemo_relay, "LLMRequest", None)) + ) + + def managed_tool_enabled(self) -> bool: + return ( + self.settings.adaptive_enabled + and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None)) + ) + + def execute_llm(self, kwargs: dict[str, Any]) -> Any: + state = self.ensure_session(kwargs) + request_body = _jsonable(kwargs.get("request") or {}) + request = self.nemo_relay.LLMRequest({}, request_body) + next_call = kwargs.get("next_call") + if not callable(next_call): + return request_body + + raw_response: dict[str, Any] = {"set": False, "value": None} + + def _impl(next_request: Any) -> Any: + next_body = getattr(next_request, "content", next_request) + raw = next_call(next_body if isinstance(next_body, dict) else request_body) + raw_response["set"] = True + raw_response["value"] = raw + return _llm_response_payload(raw) + + async def _managed_execute() -> Any: + result = self.nemo_relay.llm.execute( + str(kwargs.get("provider") or "llm"), + request, + _impl, + handle=state.handle, + data=_jsonable( + { + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "api_call_count": kwargs.get("api_call_count"), + "mode": self.settings.adaptive_mode, + } + ), + metadata=_metadata(kwargs), + model_name=str(kwargs.get("model") or ""), + ) + if inspect.isawaitable(result): + return await result + return result + + managed_result = _resolve_awaitable(_managed_execute()) + return raw_response["value"] if raw_response["set"] else managed_result + + def execute_tool(self, kwargs: dict[str, Any]) -> Any: + state = self.ensure_session(kwargs) + tool_name = str(kwargs.get("tool_name") or "tool") + args = _jsonable(kwargs.get("args") or {}) + next_call = kwargs.get("next_call") + if not callable(next_call): + return args + + raw_response: dict[str, Any] = {"set": False, "value": None} + + def _impl(next_args: Any) -> Any: + effective_args = next_args if isinstance(next_args, dict) else args + raw = next_call(effective_args) + raw_response["set"] = True + raw_response["value"] = raw + return _jsonable(raw) + + async def _managed_execute() -> Any: + result = self.nemo_relay.tools.execute( + tool_name, + args, + _impl, + handle=state.handle, + data=_jsonable( + { + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "tool_call_id": kwargs.get("tool_call_id"), + "mode": self.settings.adaptive_mode, + } + ), + metadata=_metadata(kwargs), + ) + if inspect.isawaitable(result): + return await result + return result + + managed_result = _resolve_awaitable(_managed_execute()) + return raw_response["value"] if raw_response["set"] else managed_result + def register(ctx) -> None: ctx.register_hook("on_session_start", on_session_start) @@ -238,6 +333,8 @@ def register(ctx) -> None: ctx.register_hook("post_approval_response", on_post_approval_response) ctx.register_hook("subagent_start", on_subagent_start) ctx.register_hook("subagent_stop", on_subagent_stop) + ctx.register_middleware("llm_execution", on_llm_execution_middleware) + ctx.register_middleware("tool_execution", on_tool_execution_middleware) def on_session_start(**kwargs: Any) -> None: @@ -280,6 +377,8 @@ def on_pre_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if runtime.managed_llm_enabled(): + return def _record() -> None: state = runtime.ensure_session(kwargs) @@ -303,6 +402,8 @@ def on_post_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if runtime.managed_llm_enabled(): + return def _record() -> None: state = runtime.ensure_session(kwargs) @@ -324,6 +425,8 @@ def on_api_request_error(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if runtime.managed_llm_enabled(): + return def _record() -> None: state = runtime.ensure_session(kwargs) @@ -345,6 +448,8 @@ def on_pre_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if runtime.managed_tool_enabled(): + return def _record() -> None: state = runtime.ensure_session(kwargs) @@ -365,6 +470,8 @@ def on_post_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if runtime.managed_tool_enabled(): + return def _record() -> None: state = runtime.ensure_session(kwargs) @@ -406,6 +513,28 @@ def on_subagent_stop(**kwargs: Any) -> None: _safe(lambda: runtime.mark_subagent_stop(kwargs)) +def on_llm_execution_middleware(**kwargs: Any) -> Any: + runtime = _get_runtime() + next_call = kwargs.get("next_call") + request = kwargs.get("request") or {} + if runtime is not None and runtime.managed_llm_enabled(): + return runtime.execute_llm(kwargs) + if callable(next_call): + return next_call(request) + return request + + +def on_tool_execution_middleware(**kwargs: Any) -> Any: + runtime = _get_runtime() + next_call = kwargs.get("next_call") + args = kwargs.get("args") or {} + if runtime is not None and runtime.managed_tool_enabled(): + return runtime.execute_tool(kwargs) + if callable(next_call): + return next_call(args) + return args + + def _get_runtime() -> Optional[_Runtime]: global _RUNTIME with _LOCK: @@ -429,8 +558,14 @@ def _get_runtime() -> Optional[_Runtime]: def _load_settings() -> _Settings: + plugins_toml_path = _env("HERMES_NEMO_RELAY_PLUGINS_TOML") + plugins_config = _load_plugins_config(plugins_toml_path) + adaptive_config = _enabled_component_config(plugins_config, "adaptive") return _Settings( - plugins_toml_path=_env("HERMES_NEMO_RELAY_PLUGINS_TOML"), + plugins_toml_path=plugins_toml_path, + plugins_config=plugins_config, + adaptive_enabled=adaptive_config is not None, + adaptive_mode=_adaptive_mode(adaptive_config), atof_enabled=_env_bool("HERMES_NEMO_RELAY_ATOF_ENABLED"), atof_output_directory=_env("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY"), atof_filename=_env("HERMES_NEMO_RELAY_ATOF_FILENAME") or "hermes-atof.jsonl", @@ -445,6 +580,44 @@ def _load_settings() -> _Settings: ) +def _load_plugins_config(path: str) -> dict[str, Any] | None: + if not path: + return None + try: + return tomllib.loads(Path(path).read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("NeMo Relay plugins.toml load failed: %s", exc, exc_info=True) + return None + + +def _enabled_component_config( + plugins_config: dict[str, Any] | None, + kind: str, +) -> dict[str, Any] | None: + if not isinstance(plugins_config, dict): + return None + components = plugins_config.get("components") + if not isinstance(components, list): + return None + for component in components: + if not isinstance(component, dict): + continue + if component.get("kind") != kind or not component.get("enabled", True): + continue + config = component.get("config") + return config if isinstance(config, dict) else {} + return None + + +def _adaptive_mode(config: dict[str, Any] | None) -> str: + if not isinstance(config, dict): + return "observe" + mode = config.get("mode") + if isinstance(mode, str) and mode.strip(): + return mode.strip() + return "observe" + + def _env(name: str) -> str: return os.environ.get(name, "").strip() @@ -549,12 +722,78 @@ def _jsonable(value: Any) -> Any: return _jsonable(value.model_dump(mode="json")) except Exception: pass + try: + if hasattr(value, "__dict__"): + return _jsonable(vars(value)) + except Exception: + pass try: return json.loads(json.dumps(value, default=str)) except Exception: return str(value) +def _value(obj: Any, key: str, default: Any = None) -> Any: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _llm_response_payload(response: Any) -> Any: + """Return the LLM response shape NeMo Relay's ATIF conversion expects.""" + payload = _jsonable(response) + if isinstance(payload, dict) and "assistant_message" in payload: + return payload + + choices = _value(response, "choices") + if choices is None and isinstance(payload, dict): + choices = payload.get("choices") + first_choice = choices[0] if isinstance(choices, list) and choices else None + message = _value(first_choice, "message") + finish_reason = _value(first_choice, "finish_reason") + + assistant_message: dict[str, Any] = {"role": "assistant", "content": ""} + if message is not None: + assistant_message["role"] = _value(message, "role", "assistant") or "assistant" + content = _value(message, "content") + if content is not None: + assistant_message["content"] = _jsonable(content) + tool_calls = _tool_calls_payload(_value(message, "tool_calls")) + if tool_calls: + assistant_message["tool_calls"] = tool_calls + reasoning = _value(message, "reasoning_content") + if reasoning is not None: + assistant_message["reasoning_content"] = _jsonable(reasoning) + elif isinstance(payload, dict): + assistant_message["content"] = payload.get("content") or payload.get("output_text") or "" + + return { + "model": _value(response, "model", payload.get("model") if isinstance(payload, dict) else None), + "assistant_message": assistant_message, + "finish_reason": finish_reason, + "usage": _jsonable(_value(response, "usage", payload.get("usage") if isinstance(payload, dict) else None)), + } + + +def _tool_calls_payload(tool_calls: Any) -> list[dict[str, Any]]: + if not isinstance(tool_calls, list): + return [] + normalized: list[dict[str, Any]] = [] + for call in tool_calls: + function = _value(call, "function") + normalized.append( + { + "id": _value(call, "id"), + "type": _value(call, "type", "function") or "function", + "function": { + "name": _value(function, "name"), + "arguments": _value(function, "arguments"), + }, + } + ) + return normalized + + def _safe(fn) -> None: try: fn() @@ -562,6 +801,35 @@ def _safe(fn) -> None: logger.debug("NeMo Relay hook handling failed: %s", exc, exc_info=True) +def _resolve_awaitable(value: Any) -> Any: + if not inspect.isawaitable(value): + return value + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(value) + + result: dict[str, Any] = {} + error: dict[str, BaseException] = {} + + def _runner() -> None: + try: + result["value"] = asyncio.run(value) + except BaseException as exc: # pragma: no cover - re-raised below + error["exc"] = exc + + thread = threading.Thread( + target=_runner, + name="hermes-nemo-relay-awaitable", + daemon=True, + ) + thread.start() + thread.join() + if "exc" in error: + raise error["exc"] + return result.get("value") + + def reset_for_tests() -> None: global _RUNTIME with _LOCK: diff --git a/run_agent.py b/run_agent.py index d0d0293439de..c0b3619896c5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4775,10 +4775,22 @@ class AIAgent: def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str, tool_call_id: Optional[str] = None, messages: list = None, - pre_tool_block_checked: bool = False) -> str: + pre_tool_block_checked: bool = False, + skip_tool_request_middleware: bool = False, + tool_request_middleware_trace: Optional[list[dict[str, Any]]] = None) -> str: """Forwarder — see ``agent.agent_runtime_helpers.invoke_tool``.""" from agent.agent_runtime_helpers import invoke_tool - return invoke_tool(self, function_name, function_args, effective_task_id, tool_call_id, messages, pre_tool_block_checked) + return invoke_tool( + self, + function_name, + function_args, + effective_task_id, + tool_call_id, + messages, + pre_tool_block_checked, + skip_tool_request_middleware, + tool_request_middleware_trace, + ) @staticmethod def _wrap_verbose(label: str, text: str, indent: str = " ") -> str: diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index baf7f92fcfb4..6bff7b6d87d3 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -18,8 +18,15 @@ from hermes_cli.plugins import ( get_plugin_command_handler, get_plugin_commands, get_pre_tool_call_block_message, + has_middleware, resolve_plugin_command_result, ) +from hermes_cli.middleware import ( + VALID_MIDDLEWARE, + apply_llm_request_middleware, + apply_tool_request_middleware, + run_tool_execution_middleware, +) # ── Helpers ──────────────────────────────────────────────────────────────── @@ -96,6 +103,110 @@ class TestPluginDiscovery: assert "hello_plugin" in mgr._plugins assert mgr._plugins["hello_plugin"].enabled + def test_plugin_can_register_and_invoke_middleware(self, tmp_path, monkeypatch): + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, + "mw_plugin", + register_body=( + "ctx.register_middleware('llm_request', " + "lambda **kw: {'request': {**kw['request'], 'mw': True}})\n" + " ctx.register_middleware('tool_request', " + "lambda **kw: {'args': {**kw['args'], 'mw': True}})" + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + assert "llm_request" in VALID_MIDDLEWARE + assert "tool_request" in VALID_MIDDLEWARE + assert set(mgr._plugins["mw_plugin"].middleware_registered) == {"llm_request", "tool_request"} + assert mgr.invoke_middleware("llm_request", request={"messages": []}) == [ + {"request": {"messages": [], "mw": True}} + ] + assert mgr.invoke_middleware("tool_request", args={"path": "README.md"}) == [ + {"args": {"path": "README.md", "mw": True}} + ] + assert mgr.has_middleware("llm_request") is True + + def test_execution_middleware_does_not_retry_downstream_failure(self, monkeypatch): + calls = [] + + def middleware(**kwargs): + return kwargs["next_call"](kwargs["args"]) + + manager = types.SimpleNamespace(_middleware={"tool_execution": [middleware]}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + def terminal(args): + calls.append(args) + raise RuntimeError("tool failed") + + with pytest.raises(RuntimeError, match="tool failed"): + run_tool_execution_middleware("terminal", {"command": "false"}, terminal) + + assert calls == [{"command": "false"}] + + def test_middleware_helpers_skip_no_listener_work(self, monkeypatch): + manager = types.SimpleNamespace(_middleware={}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + request = {"messages": []} + args = {"path": "README.md"} + + llm_result = apply_llm_request_middleware(request) + tool_result = apply_tool_request_middleware("read_file", args) + + assert llm_result.payload is request + assert llm_result.original_payload is request + assert llm_result.changed is False + assert llm_result.trace == [] + assert tool_result.payload is args + assert tool_result.original_payload is args + assert tool_result.changed is False + assert tool_result.trace == [] + assert run_tool_execution_middleware("terminal", args, lambda payload: payload) is args + assert has_middleware("tool_request") is False + + def test_request_middleware_changed_tracks_trace_not_deep_equality(self, monkeypatch): + def same_payload_middleware(**kwargs): + return {"args": kwargs["args"], "source": "same-payload"} + + manager = types.SimpleNamespace( + _middleware={"tool_request": [same_payload_middleware]}, + invoke_middleware=lambda kind, **kwargs: [same_payload_middleware(**kwargs)], + ) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + args = {"path": "README.md"} + result = apply_tool_request_middleware("read_file", args) + + assert result.payload == args + assert result.original_payload == args + assert result.changed is True + assert result.trace == [{"source": "same-payload"}] + + def test_execution_middleware_post_next_call_error_does_not_retry(self, monkeypatch): + calls = [] + + def middleware(**kwargs): + result = kwargs["next_call"](kwargs["args"]) + raise RuntimeError(f"post-processing failed after {result}") + + manager = types.SimpleNamespace(_middleware={"tool_execution": [middleware]}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + def terminal(args): + calls.append(args) + return "terminal-result" + + result = run_tool_execution_middleware("terminal", {"command": "printf ok"}, terminal) + + assert result == "terminal-result" + assert calls == [{"command": "printf ok"}] + def test_discover_project_plugins(self, tmp_path, monkeypatch): """Plugins in ./.hermes/plugins/ are discovered.""" project_dir = tmp_path / "project" diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 7c18493fd396..c4970bf24158 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -27,8 +27,16 @@ class _FakeNemoRelay: pop=self._scope_pop, event=self._scope_event, ) - self.llm = SimpleNamespace(call=self._llm_call, call_end=self._llm_call_end) - self.tools = SimpleNamespace(call=self._tool_call, call_end=self._tool_call_end) + self.llm = SimpleNamespace( + call=self._llm_call, + call_end=self._llm_call_end, + execute=self._llm_execute, + ) + self.tools = SimpleNamespace( + call=self._tool_call, + call_end=self._tool_call_end, + execute=self._tool_execute, + ) self.plugin = SimpleNamespace(initialize=self._plugin_initialize) self.LLMRequest = _FakeLLMRequest self.AtofExporterConfig = _FakeAtofExporterConfig @@ -55,6 +63,12 @@ class _FakeNemoRelay: def _llm_call_end(self, handle, response, **kwargs): self.events.append(("llm.call_end", handle, response, kwargs)) + def _llm_execute(self, name, request, func, **kwargs): + self.events.append(("llm.execute.start", name, request.content, kwargs)) + result = func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + self.events.append(("llm.execute.end", name, result, kwargs)) + return result + def _tool_call(self, name, args, **kwargs): handle = ("tool", name) self.events.append(("tool.call", name, args, kwargs)) @@ -63,6 +77,12 @@ class _FakeNemoRelay: def _tool_call_end(self, handle, result, **kwargs): self.events.append(("tool.call_end", handle, result, kwargs)) + def _tool_execute(self, name, args, func, **kwargs): + self.events.append(("tool.execute.start", name, args, kwargs)) + result = func({"intercepted": True, **args}) + self.events.append(("tool.execute.end", name, result, kwargs)) + return result + def _make_atof_exporter(self, config): return _FakeAtofExporter(self.events, config) @@ -425,6 +445,221 @@ output_directory = "{atif_dir}" assert atif_dir.is_dir() +def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + seen_request = {} + raw_choice = SimpleNamespace( + message=SimpleNamespace( + role="assistant", + content=None, + tool_calls=[ + SimpleNamespace( + id="tool-1", + type="function", + function=SimpleNamespace(name="terminal", arguments='{"command":"pwd"}'), + ) + ], + reasoning_content="need a tool", + ), + finish_reason="tool_calls", + ) + + def next_call(request): + seen_request.update(request) + return SimpleNamespace( + id="resp-1", + model="demo-model", + choices=[raw_choice], + usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8), + ) + + response = plugin.on_llm_execution_middleware( + session_id="s1", + task_id="t1", + turn_id="turn-1", + api_request_id="api-1", + provider="anthropic", + model="demo-model", + api_call_count=1, + request={"messages": [{"role": "user", "content": "hi"}]}, + next_call=next_call, + ) + + assert response.model == "demo-model" + assert response.choices == [raw_choice] + assert seen_request["intercepted"] is True + execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") + assert execute_start[3]["data"]["mode"] == "route" + execute_end = next(event for event in fake.events if event[0] == "llm.execute.end") + assert execute_end[2] == { + "model": "demo-model", + "assistant_message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "tool-1", + "type": "function", + "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}, + } + ], + "reasoning_content": "need a tool", + }, + "finish_reason": "tool_calls", + "usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}, + } + + +def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive(monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + + response = plugin.on_llm_execution_middleware( + session_id="s1", + provider="anthropic", + model="demo-model", + request={"messages": []}, + next_call=lambda request: {"raw": request}, + ) + + assert response == {"raw": {"messages": []}} + assert not any(event[0] == "llm.execute.start" for event in fake.events) + + +def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + seen_args = {} + + def next_call(args): + seen_args.update(args) + return {"raw": True, "args": args} + + response = plugin.on_tool_execution_middleware( + session_id="s1", + task_id="t1", + turn_id="turn-1", + api_request_id="api-1", + tool_name="terminal", + tool_call_id="tool-1", + args={"command": "pwd"}, + next_call=next_call, + ) + + assert response == {"raw": True, "args": {"command": "pwd", "intercepted": True}} + assert seen_args["intercepted"] is True + execute_start = next(event for event in fake.events if event[0] == "tool.execute.start") + assert execute_start[3]["data"]["mode"] == "route" + assert execute_start[3]["data"]["tool_call_id"] == "tool-1" + + +def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + + response = plugin.on_tool_execution_middleware( + session_id="s1", + tool_name="terminal", + args={"command": "pwd"}, + next_call=lambda args: {"raw": args}, + ) + + assert response == {"raw": {"command": "pwd"}} + assert not any(event[0] == "tool.execute.start" for event in fake.events) + + +def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[components]] +kind = "adaptive" +enabled = true + +[components.config] +mode = "route" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + base = { + "session_id": "s1", + "task_id": "t1", + "turn_id": "turn-1", + "api_request_id": "api-1", + } + plugin.on_pre_api_request( + **base, + provider="anthropic", + model="demo-model", + request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, + ) + plugin.on_post_api_request(**base, response={"ok": True}) + plugin.on_pre_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"}) + plugin.on_post_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True}) + + plugin.on_llm_execution_middleware( + **base, + provider="anthropic", + model="demo-model", + request={"messages": [{"role": "user", "content": "hi"}]}, + next_call=lambda request: {"raw": request}, + ) + plugin.on_tool_execution_middleware( + **base, + tool_name="terminal", + tool_call_id="tool-1", + args={"command": "pwd"}, + next_call=lambda args: {"raw": args}, + ) + + event_names = [event[0] for event in fake.events] + assert "llm.call" not in event_names + assert "llm.call_end" not in event_names + assert "tool.call" not in event_names + assert "tool.call_end" not in event_names + assert "llm.execute.start" in event_names + assert "tool.execute.start" in event_names + + def test_nemo_relay_plugin_noops_without_dependency(monkeypatch): monkeypatch.delitem(sys.modules, "nemo_relay", raising=False) sys.modules.pop("plugins.observability.nemo_relay", None) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e9e7011dd1ed..aef73c665f32 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2466,8 +2466,10 @@ class TestConcurrentToolExecution: api_request_id="", enabled_tools=list(agent.valid_tool_names), skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, enabled_toolsets=agent.enabled_toolsets, disabled_toolsets=agent.disabled_toolsets, + tool_request_middleware_trace=[], ) assert result == "result" @@ -2647,6 +2649,89 @@ class TestConcurrentToolExecution: assert post_call[1]["result"] == '{"ok":true}' assert post_call[1]["status"] == "ok" + def test_sequential_agent_level_tool_execution_middleware_wraps_inline_dispatch(self, agent, monkeypatch): + """Sequential built-in tool paths should expose the adaptive execution boundary.""" + tool_call = _mock_tool_call(name="todo", arguments='{"todos":[]}', call_id="todo-1") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call]) + messages = [] + hook_calls = [] + seen = {} + + def request_middleware(**kwargs): + return { + "args": {**kwargs["args"], "request_rewritten": True}, + "source": "request-test", + } + + def execution_middleware(**kwargs): + seen["middleware_args"] = kwargs["args"] + return kwargs["next_call"]({**kwargs["args"], "merge": True}) + + manager = SimpleNamespace(_middleware={ + "tool_request": [request_middleware], + "tool_execution": [execution_middleware], + }) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + monkeypatch.setattr( + "hermes_cli.plugins.invoke_middleware", + lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == "tool_request" else [], + ) + monkeypatch.setattr( + "hermes_cli.plugins.get_pre_tool_call_block_message", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [], + ) + monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True) + + with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo: + agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") + + assert seen["middleware_args"] == {"todos": [], "request_rewritten": True} + mock_todo.assert_called_once_with(todos=[], merge=True, store=agent._todo_store) + post_call = next(call for call in hook_calls if call[0] == "post_tool_call") + assert post_call[1]["tool_name"] == "todo" + assert post_call[1]["args"] == {"todos": [], "request_rewritten": True, "merge": True} + assert post_call[1]["middleware_trace"] == [{"source": "request-test"}] + + def test_concurrent_agent_level_tool_preserves_request_middleware_trace(self, agent, monkeypatch): + tool_call = _mock_tool_call(name="todo", arguments='{"todos":[]}', call_id="todo-1") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call]) + messages = [] + hook_calls = [] + + def request_middleware(**kwargs): + return { + "args": {**kwargs["args"], "request_rewritten": True}, + "source": "request-test", + } + + manager = SimpleNamespace(_middleware={"tool_request": [request_middleware], "tool_execution": []}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + monkeypatch.setattr( + "hermes_cli.plugins.invoke_middleware", + lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == "tool_request" else [], + ) + monkeypatch.setattr( + "hermes_cli.plugins.get_pre_tool_call_block_message", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [], + ) + monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True) + + with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}'): + agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") + + post_call = next(call for call in hook_calls if call[0] == "post_tool_call") + assert post_call[1]["tool_name"] == "todo" + assert post_call[1]["args"] == {"todos": [], "request_rewritten": True} + assert post_call[1]["middleware_trace"] == [{"source": "request-test"}] + def test_agent_runtime_post_hook_ownership_predicate_covers_agent_tools(self, agent): """Sequential and concurrent agent-level paths share post-hook ownership.""" from agent.agent_runtime_helpers import agent_runtime_owns_post_tool_hook diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index f1a5b510cb22..91e7103aac70 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -64,6 +64,7 @@ class TestHandleFunctionCall: tool_call_id="call-1", turn_id="", api_request_id="", + middleware_trace=[], ), call( "post_tool_call", @@ -79,6 +80,7 @@ class TestHandleFunctionCall: status="ok", error_type=None, error_message=None, + middleware_trace=[], ), call( "transform_tool_result", @@ -145,6 +147,60 @@ class TestHandleFunctionCall: assert "post_tool_call" not in fired assert "transform_tool_result" not in fired + def test_tool_request_and_execution_middleware_wrap_registry_dispatch(self, monkeypatch): + seen = {} + + def fake_invoke_middleware(kind, **kwargs): + if kind == "tool_request": + return [{ + "args": {**kwargs["args"], "rewritten": True}, + "source": "test-middleware", + "reason": "rewrite", + }] + return [] + + def execution_middleware(**kwargs): + seen["execution_args"] = kwargs["args"] + return kwargs["next_call"]({**kwargs["args"], "wrapped": True}) + + def fake_dispatch(tool_name, args, **kwargs): + seen["dispatch"] = (tool_name, args, kwargs) + return json.dumps({"ok": True, "args": args}) + + manager = type( + "Manager", + (), + {"_middleware": {"tool_request": [fake_invoke_middleware], "tool_execution": [execution_middleware]}}, + )() + monkeypatch.setattr("hermes_cli.plugins.invoke_middleware", fake_invoke_middleware) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + hook_calls = [] + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [], + ) + monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True) + monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch) + + result = json.loads( + handle_function_call( + "web_search", + {"q": "test"}, + task_id="task-1", + tool_call_id="tool-1", + session_id="session-1", + ) + ) + + assert seen["execution_args"] == {"q": "test", "rewritten": True} + assert seen["dispatch"][1] == {"q": "test", "rewritten": True, "wrapped": True} + assert result["args"] == {"q": "test", "rewritten": True, "wrapped": True} + expected_trace = [{"source": "test-middleware", "reason": "rewrite"}] + pre_call = next(call for call in hook_calls if call[0] == "pre_tool_call") + post_call = next(call for call in hook_calls if call[0] == "post_tool_call") + assert pre_call[1]["middleware_trace"] == expected_trace + assert post_call[1]["middleware_trace"] == expected_trace + # ========================================================================= # Agent loop tools From 391b59475258e6ea5d000076e71d1968b09dcb4d Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 5 Jun 2026 10:00:21 +0800 Subject: [PATCH 004/541] fix(cli): use Rich [dim] tag instead of ANSI escape in _restore_session_cwd Replace [{_DIM}] with [dim] in all _restore_session_cwd and _preload_resumed_session messages that go through _console_print (Rich Console.print). _DIM is an ANSI escape (\x1b[2;3m) that Rich cannot parse as a markup tag, causing MarkupError on session resume when the stored cwd is missing or inaccessible. Also uses [/dim] closing tag for explicit tag matching. Fixes #39469 --- cli.py | 10 ++--- tests/cli/test_cli_resume_command.py | 66 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index d429e14fc9eb..b203147492eb 100644 --- a/cli.py +++ b/cli.py @@ -5097,9 +5097,9 @@ class HermesCLI: resolved_id = self.session_id if resolved_id and resolved_id != self.session_id: ChatConsole().print( - f"[{_DIM}]Session {_escape(self.session_id)} was compressed into " + f"[dim]Session {_escape(self.session_id)} was compressed into " f"{_escape(resolved_id)}; resuming the descendant with your " - f"transcript.[/]" + f"transcript.[/dim]" ) self.session_id = resolved_id resolved_meta = self._session_db.get_session(self.session_id) @@ -5378,7 +5378,7 @@ class HermesCLI: if quiet: print(msg, file=sys.stderr) else: - self._console_print(f"[{_DIM}]{_escape(msg)}[/]") + self._console_print(f"[dim]{_escape(msg)}[/dim]") return try: @@ -5388,7 +5388,7 @@ class HermesCLI: if quiet: print(msg, file=sys.stderr) else: - self._console_print(f"[{_DIM}]{_escape(msg)}[/]") + self._console_print(f"[dim]{_escape(msg)}[/dim]") return # Retarget the terminal/code-exec tools to match the process cwd. @@ -5398,7 +5398,7 @@ class HermesCLI: if quiet: print(msg, file=sys.stderr) else: - self._console_print(f"[{_DIM}]{_escape(msg)}[/]") + self._console_print(f"[dim]{_escape(msg)}[/dim]") def _preload_resumed_session(self) -> bool: """Load a resumed session's history from the DB early (before first chat). diff --git a/tests/cli/test_cli_resume_command.py b/tests/cli/test_cli_resume_command.py index eb691ab006a0..cdb23f546558 100644 --- a/tests/cli/test_cli_resume_command.py +++ b/tests/cli/test_cli_resume_command.py @@ -221,3 +221,69 @@ class TestPendingResumeNumberedSelection: # A non-resume command disarms the one-shot prompt (#34584). assert cli_obj._pending_resume_sessions is None + + +class TestRestoreSessionCwdMarkup: + """Regression: _restore_session_cwd must not crash with Rich MarkupError. + + Lines that used ``[{_DIM}]`` inside Rich markup triggered + ``rich.errors.MarkupError: closing tag [/] at position N has nothing to + close`` because ``_DIM`` is an ANSI escape (``\\x1b[2;3m``), not a valid + Rich tag. The fix replaces ``[{_DIM}]`` with Rich's native ``[dim]`` tag. + See: https://github.com/NousResearch/hermes-agent/issues/39469 + """ + + def test_missing_dir_does_not_raise_markup_error(self): + """Session cwd gone → dim warning, no MarkupError.""" + cli_obj = _make_cli() + console = MagicMock() + cli_obj._output_console = MagicMock(return_value=console) + + # Use a path that definitely does not exist. + cli_obj._restore_session_cwd({"cwd": "/nonexistent/path/to/nowhere"}) + + # Should have printed a warning via console.print, not crashed. + assert console.print.called + printed = str(console.print.call_args) + assert "Working directory is gone" in printed or "gone" in printed.lower() + + def test_chdir_failure_does_not_raise_markup_error(self, tmp_path): + """os.chdir fails → dim warning, no MarkupError.""" + import os + cli_obj = _make_cli() + console = MagicMock() + cli_obj._output_console = MagicMock(return_value=console) + + # Create a directory, then make it unreadable (simulate chdir failure). + target = tmp_path / "locked" + target.mkdir() + + # Patch os.chdir to raise OSError for our target path. + original_chdir = os.chdir + def fake_chdir(path): + if str(path) == str(target): + raise OSError("Permission denied") + return original_chdir(path) + + with patch("os.chdir", side_effect=fake_chdir): + cli_obj._restore_session_cwd({"cwd": str(target)}) + + assert console.print.called + printed = str(console.print.call_args) + assert "Could not enter" in printed or "permission" in printed.lower() + + def test_success_path_does_not_raise_markup_error(self, tmp_path): + """Successful cwd switch → dim info, no MarkupError.""" + import os + cli_obj = _make_cli() + console = MagicMock() + cli_obj._output_console = MagicMock(return_value=console) + + original_cwd = os.getcwd() + try: + cli_obj._restore_session_cwd({"cwd": str(tmp_path)}) + assert console.print.called + printed = str(console.print.call_args) + assert "Working directory" in printed or "working" in printed.lower() + finally: + os.chdir(original_cwd) From 338f0b22346202b6ab4312ec63c64cec92004b6a Mon Sep 17 00:00:00 2001 From: youngstar-eth <251514042+youngstar-eth@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:44:22 +0300 Subject: [PATCH 005/541] fix(desktop): recover from corrupt Electron cache in bootstrap install (Windows) Windows counterpart of #39127: scripts/install.ps1 `Install-Desktop` runs `npm run pack` once and throws on the opaque ENOENT a corrupt cached Electron download produces, with no recovery. Add `Clear-ElectronBuildCache` plus a purge-and-retry-once on pack failure, mirroring the install.sh fix: remove the cached electron-*.zip (%LOCALAPPDATA%\electron\Cache + ELECTRON_CACHE / electron_config_cache overrides) and stale *-unpacked output, then retry so @electron/get re-downloads with its own SHASUM verification. Refs #37544. Co-Authored-By: Claude Opus 4.8 --- scripts/install.ps1 | 65 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d295749eb39b..c6179e1b6974 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1934,6 +1934,55 @@ function Install-NodeDeps { } } +# Clear the cached Electron download + any half-written unpacked output so the +# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in +# the per-user Electron download cache - most often a partial download resumed +# into the same file, leaving concatenated junk - makes electron-builder's +# `app-builder unpack-electron` extract a tree MISSING the electron binary, so +# the final `electron` -> `Hermes` rename dies with ENOENT and every re-run +# repeats the broken extraction forever. +# +# We deliberately do not validate the zip ourselves: the common +# prepended/concatenated-junk corruption slips past naive checks, so a +# self-rolled gate would skip the real-world case. We unconditionally drop the +# cached electron-*.zip (loose copy and any @electron/get hash-subdir copy) plus +# the stale unpacked dir, then let the caller retry once - @electron/get +# re-downloads with its own SHASUM verification, the real source of truth. +# +# Returns the removed paths. Best-effort: never throws. +function Clear-ElectronBuildCache { + param([string]$DesktopDir) + $removed = @() + + # Per-user Electron download cache dirs, honoring the overrides @electron/get + # respects, then the Windows default (%LOCALAPPDATA%\electron\Cache). + $cacheDirs = @() + if ($env:electron_config_cache) { $cacheDirs += $env:electron_config_cache } + if ($env:ELECTRON_CACHE) { $cacheDirs += $env:ELECTRON_CACHE } + if ($env:LOCALAPPDATA) { $cacheDirs += (Join-Path $env:LOCALAPPDATA 'electron\Cache') } + $cacheDirs += (Join-Path $HOME 'AppData\Local\electron\Cache') + + foreach ($dir in $cacheDirs) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + # Recurse: the bad copy may be the top-level zip OR a copy inside an + # @electron/get hash subdir. + $removed += @(Get-ChildItem -LiteralPath $dir -Recurse -Filter 'electron-*.zip' -File -ErrorAction SilentlyContinue | ForEach-Object { + try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop; $_.FullName } catch { } + }) + } + + # A half-written unpacked dir from an interrupted prior pack poisons the + # rename even after the zip is fixed (win-unpacked / win-arm64-unpacked). + $releaseDir = Join-Path $DesktopDir 'release' + if (Test-Path -LiteralPath $releaseDir) { + $removed += @(Get-ChildItem -LiteralPath $releaseDir -Directory -Filter '*-unpacked' -ErrorAction SilentlyContinue | ForEach-Object { + try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop; $_.FullName } catch { } + }) + } + + return $removed +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -2067,6 +2116,22 @@ function Install-Desktop { $env:WIN_CSC_KEY_PASSWORD = "" & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE + if ($code -ne 0) { + # A corrupt cached Electron zip makes `pack` fail with an opaque + # ENOENT on the final `electron` -> `Hermes` rename: app-builder's + # unpack-electron extracted a partial tree (missing the binary) from + # the bad zip, and re-running reuses the poisoned cache forever. + # Purge the cached download + any stale unpacked output and retry + # once; @electron/get re-downloads with its own SHASUM check. Without + # this a corrupt download hard-fails the whole installer. + $purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir) + if ($purged.Count -gt 0) { + Write-Warn "Desktop build failed - cleared cached Electron download, retrying once:" + foreach ($p in $purged) { Write-Info " - $p" } + & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog + $code = $LASTEXITCODE + } + } $ErrorActionPreference = $prevEAP if ($code -ne 0) { $errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue From facd011b63180295d3a08c432af1cd0552999a36 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:35:50 -0700 Subject: [PATCH 006/541] chore(release): map youngstar-eth in AUTHOR_MAP for salvage PR #39134 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 68c141d5615a..48c239b979f2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -74,6 +74,7 @@ AUTHOR_MAP = { "kaspersniels@gmail.com": "nielskaspers", "kurobaryo@gmail.com": "kurobaryo", "scubamount@users.noreply.github.com": "scubamount", + "251514042+youngstar-eth@users.noreply.github.com": "youngstar-eth", "155192176+alelpoan@users.noreply.github.com": "alelpoan", "aman@abacus.ai": "Aman113114-IITD", "octavio.turra@gmail.com": "octavioturra", From 25742372eb5664bc9626143a486823b27fb4bc5e Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Fri, 5 Jun 2026 00:07:43 +0000 Subject: [PATCH 007/541] fix(approval): check is_approved in execute_code guard (#39275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_execute_code_guard() never called is_approved() before entering the approval flow, and never persisted session/permanent approvals from the gateway response. This meant 'Approve session' and 'Always' buttons had no effect — every execute_code call re-prompted the user. - Add is_approved() check after get_current_session_key(), matching check_all_command_guards() - Persist session ('approve_session') and permanent ('approve_permanent') approvals based on the gateway choice, same as terminal command guard - Add 3 regression tests for session persistence, permanent persistence, and short-circuit on pre-existing approval --- .../test_execute_code_approval_cluster.py | 47 +++++++++++++++++++ tools/approval.py | 18 +++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index db3b1d9e9a33..c5d7f3fb78c9 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -176,6 +176,53 @@ def test_guard_gateway_user_approves_is_one_shot(gw_session): assert A.is_approved(gw_session, "execute_code") is False +def test_guard_gateway_user_approves_session_persists(gw_session): + """'Approve session' stores session-level approval (#39275).""" + _register_resolver(gw_session, "session") + res = A.check_execute_code_guard("import os; print(1)", "local") + assert res["approved"] is True + assert res.get("user_approved") is True + # Session approval should now be stored. + assert A.is_approved(gw_session, "execute_code") is True + # Subsequent calls should auto-approve without prompting. + res2 = A.check_execute_code_guard("import os; print(2)", "local") + assert res2["approved"] is True + # Cleanup + with A._lock: + s = A._session_approved.get(gw_session, set()) + s.discard("execute_code") + + +def test_guard_gateway_user_approves_always_persists(gw_session): + """'Always' stores permanent approval (#39275).""" + _register_resolver(gw_session, "always") + res = A.check_execute_code_guard("import os; print(1)", "local") + assert res["approved"] is True + assert res.get("user_approved") is True + # Permanent approval should now be stored. + assert A.is_approved(gw_session, "execute_code") is True + # Cleanup + with A._lock: + A._permanent_approved.discard("execute_code") + s = A._session_approved.get(gw_session, set()) + s.discard("execute_code") + + +def test_guard_session_approval_short_circuits_prompt(gw_session): + """Once session-approved, execute_code skips the approval prompt (#39275).""" + # Manually set session approval. + A.approve_session(gw_session, "execute_code") + try: + # Even with a denier registered, the is_approved check short-circuits. + _register_resolver(gw_session, "deny") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is True + finally: + with A._lock: + s = A._session_approved.get(gw_session, set()) + s.discard("execute_code") + + def test_guard_gateway_user_denies_blocks(gw_session): _register_resolver(gw_session, "deny") res = A.check_execute_code_guard("import os", "local") diff --git a/tools/approval.py b/tools/approval.py index 8e52f292f2c3..c5051f719995 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -1584,6 +1584,12 @@ def check_execute_code_guard(code: str, env_type: str) -> dict: # paths don't pay to copy a potentially-large script into this string. command = f"execute_code <<'PY'\n{code}\nPY" + # Check session/permanent approval — same gate as check_all_command_guards. + # Without this, "Approve session" / "Always" choices are stored but never + # consulted, so every execute_code call re-prompts the user (#39275). + if is_approved(session_key, pattern_key): + return {"approved": True, "message": None} + # Smart mode: ask the aux LLM about the whole script. An APPROVE here only # suppresses the redundant whole-script prompt; the per-call terminal() # guards (restored by context propagation) still run independently. @@ -1674,9 +1680,15 @@ def check_execute_code_guard(code: str, env_type: str) -> dict: "user_consent": False, } - # Approved — one-shot only. Deliberately NO approve_session/approve_permanent: - # each execute_code script is distinct arbitrary code, so approval never - # persists to future scripts. + # Approved — persist based on scope (same logic as check_all_command_guards). + if choice == "session": + approve_session(session_key, pattern_key) + elif choice == "always": + approve_session(session_key, pattern_key) + approve_permanent(pattern_key) + save_permanent_allowlist(_permanent_approved) + # choice == "once": no persistence — approval lasts this single call only. + return {"approved": True, "message": None, "user_approved": True, "description": description} From e9529578d5a4efeab96f5566c4219d3c51d9ab28 Mon Sep 17 00:00:00 2001 From: YapBi <129007007+HeLLGURD@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:22:17 +0300 Subject: [PATCH 008/541] fix(mcp): widen shutdown_mcp_servers exception guard to BaseException --- tools/mcp_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index ccfe69d55b0f..3a817d579959 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3800,7 +3800,7 @@ def shutdown_mcp_servers(): if future is not None: try: future.result(timeout=15) - except Exception as exc: + except BaseException as exc: logger.debug("Error during MCP shutdown: %s", exc) _stop_mcp_loop() From 1bcfe9c58ac7d9b7aecb876dc583416fe31dc4c6 Mon Sep 17 00:00:00 2001 From: YapBi <129007007+HeLLGURD@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:22:48 +0300 Subject: [PATCH 009/541] fix(cli): widen _run_cleanup MCP shutdown guard to BaseException --- cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli.py b/cli.py index d429e14fc9eb..0e97c480d577 100644 --- a/cli.py +++ b/cli.py @@ -974,7 +974,7 @@ def _run_cleanup(): try: from tools.mcp_tool import shutdown_mcp_servers shutdown_mcp_servers() - except Exception: + except BaseException: pass # Close cached auxiliary LLM clients (sync + async) so that # AsyncHttpxClientWrapper.__del__ doesn't fire on a closed event loop From 2069e78b88bfce0a1c01dc64cda8060dedef96aa Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:58:17 -0700 Subject: [PATCH 010/541] chore: add HeLLGURD to release AUTHOR_MAP for PR #39453 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 48c239b979f2..4112796ae008 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "dirtyren@users.noreply.github.com": "dirtyren", "zhaolei.vc@bytedance.com": "zhaoleibd", "jeffrobodie@gmail.com": "jeffrobodie-glitch", From 5bcb63e400987e937e696b385e01285339b565c5 Mon Sep 17 00:00:00 2001 From: asill-livestream Date: Thu, 4 Jun 2026 06:20:31 +0900 Subject: [PATCH 011/541] fix(tui): add thread-safety locks for _sessions and prompt dicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: Add _sessions_lock to protect all compound mutations and iterations on the global _sessions dict across 5+ concurrent execution contexts (main dispatcher, pool workers, daemon threads, notification poller, atexit handler). C2: Add _prompt_lock to protect _pending/_pending_prompt_payloads/_answers dicts from races between _block() (agent callback thread) and _respond() (pool worker). Lock scope is kept tight — _block() only holds the lock during registration/cleanup, releasing it before _emit() and ev.wait() to avoid blocking other prompts for 300s. All 187 existing TUI tests pass with no regressions. --- tui_gateway/server.py | 186 ++++++++++++++++++++++++------------------ 1 file changed, 106 insertions(+), 80 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 61822b6da436..266d2e3ffb8d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -125,6 +125,8 @@ _db = None _db_error: str | None = None _stdout_lock = threading.Lock() _cfg_lock = threading.Lock() +_sessions_lock = threading.Lock() +_prompt_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None _cfg_path = None @@ -325,7 +327,9 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No def _shutdown_sessions() -> None: - for session in list(_sessions.values()): + with _sessions_lock: + snapshot = list(_sessions.values()) + for session in snapshot: _finalize_session(session, end_reason="tui_shutdown") try: worker = session.get("slash_worker") @@ -546,7 +550,8 @@ def _start_agent_build(sid: str, session: dict) -> None: key = session["session_key"] def _build() -> None: - current = _sessions.get(sid) + with _sessions_lock: + current = _sessions.get(sid) if current is None: ready.set() return @@ -585,7 +590,9 @@ def _start_agent_build(sid: str, session: dict) -> None: pass _wire_callbacks(sid) - _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) + with _sessions_lock: + if sid in _sessions: + _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) info = _session_info(agent, current) @@ -598,7 +605,9 @@ def _start_agent_build(sid: str, session: dict) -> None: current["agent_error"] = str(e) _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: - if _sessions.get(sid) is not current: + with _sessions_lock: + replaced = _sessions.get(sid) is not current + if replaced: if worker is not None: try: worker.close() @@ -819,9 +828,10 @@ def _cwd_for_session_key(session_key: str) -> str: """ if not session_key: return "" - for sess in list(_sessions.values()): - if sess.get("session_key") == session_key: - return str(sess.get("cwd") or "") + with _sessions_lock: + for sess in list(_sessions.values()): + if sess.get("session_key") == session_key: + return str(sess.get("cwd") or "") return "" @@ -863,16 +873,19 @@ def _enable_gateway_prompts() -> None: def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: rid = uuid.uuid4().hex[:8] ev = threading.Event() - _pending[rid] = (sid, ev) - payload["request_id"] = rid - _pending_prompt_payloads[rid] = (event, dict(payload)) + with _prompt_lock: + _pending[rid] = (sid, ev) + payload["request_id"] = rid + _pending_prompt_payloads[rid] = (event, dict(payload)) try: _emit(event, sid, payload) ev.wait(timeout=timeout) finally: - _pending.pop(rid, None) - _pending_prompt_payloads.pop(rid, None) - return _answers.pop(rid, "") + with _prompt_lock: + _pending.pop(rid, None) + _pending_prompt_payloads.pop(rid, None) + with _prompt_lock: + return _answers.pop(rid, "") def _clear_pending(sid: str | None = None) -> None: @@ -884,10 +897,11 @@ def _clear_pending(sid: str | None = None) -> None: sessions sharing the same tui_gateway process. When *sid* is None, every pending prompt is released (used during shutdown). """ - for rid, (owner_sid, ev) in list(_pending.items()): - if sid is None or owner_sid == sid: - _answers[rid] = "" - ev.set() + with _prompt_lock: + for rid, (owner_sid, ev) in list(_pending.items()): + if sid is None or owner_sid == sid: + _answers[rid] = "" + ev.set() # ── Agent factory ──────────────────────────────────────────────────── @@ -2402,34 +2416,37 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): now = time.time() - _sessions[sid] = { - "agent": agent, - "session_key": key, - "history": history, - "history_lock": threading.Lock(), - "history_version": 0, - "inflight_turn": None, - "created_at": now, - "last_active": now, - "running": False, - "attached_images": [], - "image_counter": 0, - "cwd": _completion_cwd(), - "cols": cols, - "slash_worker": None, - "show_reasoning": _load_show_reasoning(), - "tool_progress_mode": _load_tool_progress_mode(), - "edit_snapshots": {}, - "tool_started_at": {}, - # Pin async event emissions to whichever transport created the - # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar). - "transport": current_transport() or _stdio_transport, - } + with _sessions_lock: + _sessions[sid] = { + "agent": agent, + "session_key": key, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + "inflight_turn": None, + "created_at": now, + "last_active": now, + "running": False, + "attached_images": [], + "image_counter": 0, + "cwd": _completion_cwd(), + "cols": cols, + "slash_worker": None, + "show_reasoning": _load_show_reasoning(), + "tool_progress_mode": _load_tool_progress_mode(), + "edit_snapshots": {}, + "tool_started_at": {}, + # Pin async event emissions to whichever transport created the + # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar). + "transport": current_transport() or _stdio_transport, + } db = _get_db() if db is not None: row = db.get_session(key) if row and row.get("cwd"): - _sessions[sid]["cwd"] = row["cwd"] + with _sessions_lock: + if sid in _sessions: + _sessions[sid]["cwd"] = row["cwd"] else: try: db.update_session_cwd(key, _sessions[sid]["cwd"]) @@ -2464,9 +2481,11 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): # session startup resilient). pass _wire_callbacks(sid) - _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) + with _sessions_lock: + if sid in _sessions: + _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) - _emit("session.info", sid, _session_info(agent, _sessions[sid])) + _emit("session.info", sid, _session_info(agent, _sessions.get(sid, {}))) def _new_session_key() -> str: @@ -2818,32 +2837,33 @@ def _(rid, params: dict) -> dict: ready = threading.Event() now = time.time() - _sessions[sid] = { - "agent": None, - "agent_error": None, - "agent_ready": ready, - "attached_images": [], - "cols": cols, - "created_at": now, - "edit_snapshots": {}, - "explicit_cwd": explicit_cwd, - "history": history, - "history_lock": threading.Lock(), - "history_version": 0, - "image_counter": 0, - "cwd": resolved_cwd, - "inflight_turn": None, - "last_active": now, - "pending_title": title or None, - "running": False, - "session_key": key, - "show_reasoning": _load_show_reasoning(), - "slash_worker": None, - "tool_progress_mode": _load_tool_progress_mode(), - "tool_started_at": {}, - "transport": current_transport() or _stdio_transport, - } - _register_session_cwd(_sessions[sid]) + with _sessions_lock: + _sessions[sid] = { + "agent": None, + "agent_error": None, + "agent_ready": ready, + "attached_images": [], + "cols": cols, + "created_at": now, + "edit_snapshots": {}, + "explicit_cwd": explicit_cwd, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + "image_counter": 0, + "cwd": resolved_cwd, + "inflight_turn": None, + "last_active": now, + "pending_title": title or None, + "running": False, + "session_key": key, + "show_reasoning": _load_show_reasoning(), + "slash_worker": None, + "tool_progress_mode": _load_tool_progress_mode(), + "tool_started_at": {}, + "transport": current_transport() or _stdio_transport, + } + _register_session_cwd(_sessions[sid]) # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop # launch (and every "New agent" / draft) opens a session here just to paint # the composer, so eagerly creating a row left an "Untitled" empty session @@ -3233,7 +3253,8 @@ def _(rid, params: dict) -> dict: """ current = str(params.get("current_session_id") or "") try: - snapshot = list(_sessions.items()) + with _sessions_lock: + snapshot = list(_sessions.items()) except Exception as e: return _err(rid, 5036, f"could not enumerate active sessions: {e}") @@ -3287,7 +3308,8 @@ def _(rid, params: dict) -> dict: # dictionary changed size during iteration``. If even the snapshot # raises, fail closed (refuse the delete) rather than fail open. try: - snapshot = list(_sessions.values()) + with _sessions_lock: + snapshot = list(_sessions.values()) except Exception as e: return _err(rid, 5036, f"could not enumerate active sessions: {e}") active = {s.get("session_key") for s in snapshot if s.get("session_key")} @@ -3644,11 +3666,13 @@ def _(rid, params: dict) -> dict: @method("session.close") def _(rid, params: dict) -> dict: sid = params.get("session_id", "") - current = _sessions.get(sid) + with _sessions_lock: + current = _sessions.get(sid) if not current: return _ok(rid, {"closed": False}) with _session_resume_lock: - session = _sessions.pop(sid, None) + with _sessions_lock: + session = _sessions.pop(sid, None) if not session: return _ok(rid, {"closed": False}) _finalize_session(session) @@ -4097,7 +4121,8 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: if evt_key == str(session.get("session_key") or ""): return False try: - snapshot = list(_sessions.values()) + with _sessions_lock: + snapshot = list(_sessions.values()) except Exception: # If we can't safely enumerate live sessions, fail open so we don't # crash the poller thread or drop the event. @@ -5000,12 +5025,13 @@ def _(rid, params: dict) -> dict: def _respond(rid, params, key): r = params.get("request_id", "") - entry = _pending.get(r) - if not entry: - return _err(rid, 4009, f"no pending {key} request") - _, ev = entry - _answers[r] = params.get(key, "") - ev.set() + with _prompt_lock: + entry = _pending.get(r) + if not entry: + return _err(rid, 4009, f"no pending {key} request") + _, ev = entry + _answers[r] = params.get(key, "") + ev.set() return _ok(rid, {"status": "ok"}) From 9cc47b20cb3fcd06e1cee98410a1fa523067f951 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:40:54 -0700 Subject: [PATCH 012/541] feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run provider picker was a hard gate — the only way out was connecting a provider. Add an 'I'll choose a provider later' link that dismisses the overlay and persists the skip to localStorage so it never re-nags on subsequent launches. Users connect a provider any time from Settings -> Providers (manual onboarding already bypasses the skip gate). - onboarding.ts: firstRunSkipped state seeded from localStorage (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action; completeDesktopOnboarding clears the flag once a provider connects. - overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink rendered in both the OAuth picker footer and the API-key fallback, first-run only. - tests: skip persists + hidden in manual mode; full-state fixtures updated. --- .../desktop-onboarding-overlay.test.tsx | 29 ++++++++++ .../components/desktop-onboarding-overlay.tsx | 48 ++++++++++++++--- apps/desktop/src/store/onboarding.test.ts | 1 + apps/desktop/src/store/onboarding.ts | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx index 25d4563261ef..38084ae4c911 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -25,6 +25,7 @@ function setProviders(providers: OAuthProvider[]) { providers, reason: null, requested: false, + firstRunSkipped: false, manual: false } satisfies DesktopOnboardingState) } @@ -33,6 +34,13 @@ const ctx: OnboardingContext = { requestGateway: async () => undefined as never afterEach(() => { cleanup() + + try { + window.localStorage.clear() + } catch { + // jsdom localStorage should always be present; ignore if not. + } + $desktopOnboarding.set({ configured: null, flow: { status: 'idle' }, @@ -40,6 +48,7 @@ afterEach(() => { providers: null, reason: null, requested: false, + firstRunSkipped: false, manual: false }) }) @@ -68,4 +77,24 @@ describe('onboarding Picker', () => { expect(screen.queryByText('Other sign-in options')).toBeNull() expect(screen.queryByText('Recommended')).toBeNull() }) + + it('offers "choose later" on first run and persists the skip', () => { + setProviders([provider('nous', 'Nous Portal')]) + render() + + const skip = screen.getByRole('button', { name: "I'll choose a provider later" }) + + fireEvent.click(skip) + + expect($desktopOnboarding.get().firstRunSkipped).toBe(true) + expect(window.localStorage.getItem('hermes-onboarding-skipped-v1')).toBe('1') + }) + + it('hides "choose later" in manual (add-provider) mode', () => { + setProviders([provider('nous', 'Nous Portal')]) + $desktopOnboarding.set({ ...$desktopOnboarding.get(), manual: true }) + render() + + expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull() + }) }) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 247f5cc6eecd..08f4da29dce1 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -29,6 +29,7 @@ import { confirmOnboardingModel, copyDeviceCode, copyExternalCommand, + dismissFirstRunOnboarding, type OnboardingContext, type OnboardingFlow, peekPendingProviderOAuth, @@ -189,6 +190,13 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway return null } + // The user chose "I'll choose a provider later" on first run. Stay out of the + // way on every subsequent launch — they re-enter via Settings → Providers + // (manual mode), which sets manual=true and bypasses this gate. + if (onboarding.firstRunSkipped && !onboarding.manual) { + return null + } + const { flow } = onboarding const rawReason = onboarding.reason?.trim() || null const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null @@ -304,18 +312,25 @@ const persistShowAll = (value: boolean) => { } export function Picker({ ctx }: { ctx: OnboardingContext }) { - const { mode, providers } = useStore($desktopOnboarding) + const { manual, mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 if (mode === 'apikey' || !hasOauth) { return ( - setOnboardingMode('oauth')} - onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)} - /> +
+ setOnboardingMode('oauth')} + onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)} + /> + {manual ? null : ( +
+ +
+ )} +
) } @@ -352,7 +367,11 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { ) : null} -
+
+ {/* First run only: let the user defer the choice and land in the app. + In manual mode the overlay already has a close affordance, so the + "choose later" escape would be redundant — hide it. */} + {manual ? : } + ) +} + export function FeaturedProviderRow({ onSelect, provider diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 221d2ee3e7dd..b6bb51194eed 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -30,6 +30,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo providers: null, reason: null, requested: false, + firstRunSkipped: false, manual: false, ...overrides } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 341f389c51a1..7b5202986675 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -60,6 +60,13 @@ export interface DesktopOnboardingState { providers: null | OAuthProvider[] reason: null | string requested: boolean + /** True when the user explicitly chose "I'll choose a provider later" on the + * first-run picker. Persisted to localStorage so the blocking overlay never + * re-nags on subsequent launches — the user can connect a provider any time + * from Settings → Providers (or the model picker's "Add provider"). Distinct + * from `configured`: the app still has no usable provider, so chat won't work + * until one is connected; we just stop forcing the choice up front. */ + firstRunSkipped: boolean /** True when the user explicitly opened the provider selector to add / * switch providers from an already-configured app (e.g. via the model * picker's "Add provider" button). Forces the overlay to show the picker @@ -73,6 +80,7 @@ export interface OnboardingContext { } const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1' +const SKIP_CACHE_KEY = 'hermes-onboarding-skipped-v1' const POLL_MS = 2000 const COPY_FLASH_MS = 1500 const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.' @@ -105,6 +113,34 @@ function writeCachedConfigured(value: boolean) { } } +function readCachedSkipped(): boolean { + if (typeof window === 'undefined') { + return false + } + + try { + return window.localStorage.getItem(SKIP_CACHE_KEY) === '1' + } catch { + return false + } +} + +function writeCachedSkipped(value: boolean) { + if (typeof window === 'undefined') { + return + } + + try { + if (value) { + window.localStorage.setItem(SKIP_CACHE_KEY, '1') + } else { + window.localStorage.removeItem(SKIP_CACHE_KEY) + } + } catch { + // localStorage unavailable — degrade silently. + } +} + const INITIAL: DesktopOnboardingState = { configured: readCachedConfigured(), flow: { status: 'idle' }, @@ -112,6 +148,7 @@ const INITIAL: DesktopOnboardingState = { providers: null, reason: null, requested: false, + firstRunSkipped: readCachedSkipped(), manual: false } @@ -398,6 +435,9 @@ export function closeManualOnboarding() { export function completeDesktopOnboarding() { clearPoll() writeCachedConfigured(true) + // A real provider is now connected, so any earlier "choose later" skip is + // moot — clear it so the flag never lingers in a configured install. + writeCachedSkipped(false) $desktopOnboarding.set({ configured: true, flow: { status: 'idle' }, @@ -405,10 +445,23 @@ export function completeDesktopOnboarding() { providers: null, reason: null, requested: false, + firstRunSkipped: false, manual: false }) } +// "I'll choose a provider later" on the first-run picker. Persists the skip so +// the blocking overlay never re-nags on future launches, and dismisses it now +// so the user lands in the app. Chat won't work until a provider is connected +// (from Settings → Providers or the model picker's "Add provider") — this only +// stops forcing the choice up front. Distinct from completeDesktopOnboarding, +// which marks the app actually configured. +export function dismissFirstRunOnboarding() { + clearPoll() + writeCachedSkipped(true) + patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } }) +} + export function setOnboardingMode(mode: OnboardingMode) { patch({ mode }) } From 10c78bf625fff25441d1e01fc5fd7a757790fba8 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 4 Jun 2026 21:02:32 +0700 Subject: [PATCH 013/541] test(desktop): add injectable gateway WebSocket probe + unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds electron/gateway-ws-probe.cjs: a small helper that opens a gateway WebSocket URL and classifies the handshake (open/frame → ok; error or close before open → fail; open-then-early-close → credential rejected; never-opens → timeout). The WebSocket implementation is injected so it can be unit-tested without a real socket. Wires gateway-ws-probe.test.cjs into test:desktop:platforms, covering every handshake outcome plus constructor-throw and missing-impl. --- apps/desktop/electron/gateway-ws-probe.cjs | 188 ++++++++++++++++++ .../electron/gateway-ws-probe.test.cjs | 122 ++++++++++++ apps/desktop/package.json | 2 +- 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/gateway-ws-probe.cjs create mode 100644 apps/desktop/electron/gateway-ws-probe.test.cjs diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.cjs new file mode 100644 index 000000000000..6ed1280be982 --- /dev/null +++ b/apps/desktop/electron/gateway-ws-probe.cjs @@ -0,0 +1,188 @@ +/** + * Live WebSocket validation for the remote-gateway "Test remote" button. + * + * Background: the desktop boot does two independent things to a remote gateway: + * + * 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header) + * to confirm the backend is up. This is what "Test remote" historically + * checked, and what the boot logs print as "Remote Hermes backend is + * ready". + * 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a + * query param) via ``gateway.connect()``. The chat surface only works once + * THIS succeeds. + * + * Those two paths use different processes, transports, and credentials, and the + * server applies extra guards to the WS upgrade that the HTTP status route never + * sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway + * can pass the HTTP status check yet reject the WebSocket — which surfaces to + * the user as a green "Test remote" followed by an opaque "Could not connect to + * Hermes gateway" on the boot overlay. + * + * This module performs the second half of the check: it actually opens the WS + * URL and confirms the upgrade is accepted (and isn't immediately torn down by + * a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the + * unit tests can drive the handshake without a real socket; in production the + * caller passes the Node/Electron global ``WebSocket``. + */ + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000 +// After the upgrade is accepted, a gateway that rejects the credential +// post-handshake closes the socket almost immediately. Wait a short grace +// window: a frame (gateway.ready) or a still-open socket means success; an +// early close means the upgrade was accepted but the session was refused. +const DEFAULT_READY_GRACE_MS = 750 + +/** + * Attempt a live WebSocket connection and classify the outcome. + * + * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. + * @param {object} [options] + * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. + * @param {number} [options.connectTimeoutMs] + * @param {number} [options.readyGraceMs] + * @returns {Promise<{ ok: boolean, reason?: string }>} + */ +function probeGatewayWebSocket(wsUrl, options = {}) { + const WebSocketImpl = options.WebSocketImpl + const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS + const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS + + if (typeof WebSocketImpl !== 'function') { + return Promise.resolve({ + ok: false, + reason: 'WebSocket is not available in this runtime.' + }) + } + + return new Promise(resolve => { + let settled = false + let opened = false + let connectTimer = null + let graceTimer = null + let socket + + const clearTimers = () => { + if (connectTimer !== null) { + clearTimeout(connectTimer) + connectTimer = null + } + if (graceTimer !== null) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + const finish = result => { + if (settled) return + settled = true + clearTimers() + try { + socket?.close?.() + } catch { + // ignore — best effort teardown + } + resolve(result) + } + + try { + socket = new WebSocketImpl(wsUrl) + } catch (error) { + finish({ + ok: false, + reason: error instanceof Error ? error.message : String(error) + }) + return + } + + const onOpen = () => { + if (settled) return + opened = true + // Upgrade accepted. Give the server a brief window to reject the + // credential post-handshake (early close) before declaring success. + graceTimer = setTimeout(() => { + finish({ ok: true }) + }, readyGraceMs) + } + + const onMessage = () => { + // Any frame means the gateway accepted us and is talking — unambiguous + // success, no need to wait out the grace window. + finish({ ok: true }) + } + + const onError = event => { + finish({ + ok: false, + reason: extractErrorReason(event) || 'WebSocket connection failed.' + }) + } + + const onClose = event => { + if (settled) return + if (opened) { + // Opened, then closed inside the grace window: the upgrade was accepted + // but the session was refused (e.g. ws-ticket/token rejected, or a + // server-side Host/Origin guard tripped after accept). + finish({ + ok: false, + reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') + }) + return + } + finish({ + ok: false, + reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') + }) + } + + addListener(socket, 'open', onOpen) + addListener(socket, 'message', onMessage) + addListener(socket, 'error', onError) + addListener(socket, 'close', onClose) + + if (connectTimeoutMs > 0) { + connectTimer = setTimeout(() => { + finish({ + ok: false, + reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.` + }) + }, connectTimeoutMs) + } + }) +} + +function addListener(socket, type, handler) { + if (typeof socket.addEventListener === 'function') { + socket.addEventListener(type, handler) + return + } + // Node's global WebSocket implements addEventListener; this fallback keeps the + // helper usable with the `ws` package's EventEmitter shape too. + if (typeof socket.on === 'function') { + socket.on(type, handler) + } +} + +function extractErrorReason(event) { + if (!event) return '' + if (event instanceof Error) return event.message + const err = event.error || event.message + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + return '' +} + +function closeReason(event, fallback) { + const code = event && typeof event.code === 'number' ? event.code : null + const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' + if (code && reason) return `${fallback} (code ${code}: ${reason})` + if (code) return `${fallback} (code ${code})` + if (reason) return `${fallback} (${reason})` + return fallback +} + +module.exports = { + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_READY_GRACE_MS, + probeGatewayWebSocket +} diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.cjs new file mode 100644 index 000000000000..810494fdc47b --- /dev/null +++ b/apps/desktop/electron/gateway-ws-probe.test.cjs @@ -0,0 +1,122 @@ +/** + * Tests for electron/gateway-ws-probe.cjs. + * + * Run with: node --test electron/gateway-ws-probe.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * The probe drives a real WebSocket handshake for the "Test remote" button. + * Here we inject a fake socket so we can deterministically replay each handshake + * outcome (open, frame, error, early close, never-opens) without a network. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') + +// Minimal WebSocket double: records listeners synchronously (the probe attaches +// them in its executor) and exposes emit() so the test can replay events. +function makeFakeWs() { + const instances = [] + class FakeWs { + constructor(url) { + this.url = url + this.listeners = {} + this.closed = false + instances.push(this) + } + addEventListener(type, fn) { + ;(this.listeners[type] ||= []).push(fn) + } + close() { + this.closed = true + } + emit(type, event) { + for (const fn of this.listeners[type] || []) fn(event) + } + } + return { FakeWs, instances } +} + +const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 } + +test('probe resolves ok when the socket opens and stays open', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + const result = await promise + assert.deepEqual(result, { ok: true }) + assert.equal(instances[0].closed, true) +}) + +test('probe resolves ok immediately when a frame arrives', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 1_000, + readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer + }) + instances[0].emit('open') + instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) + const result = await promise + assert.deepEqual(result, { ok: true }) +}) + +test('probe fails when the socket errors before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('error', { message: 'ECONNREFUSED' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /ECONNREFUSED/) +}) + +test('probe fails when the gateway closes before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('close', { code: 1006 }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /before it opened/) + assert.match(result.reason, /1006/) +}) + +test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + instances[0].emit('close', { code: 4403, reason: 'forbidden' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /credential rejected/) + assert.match(result.reason, /4403/) + assert.match(result.reason, /forbidden/) +}) + +test('probe times out when the socket never opens', async () => { + const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 20, + readyGraceMs: 10 + }) + assert.equal(result.ok, false) + assert.match(result.reason, /Timed out/) +}) + +test('probe fails gracefully when the constructor throws', async () => { + class ThrowingWs { + constructor() { + throw new Error('bad url') + } + } + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST }) + assert.equal(result.ok, false) + assert.match(result.reason, /bad url/) +}) + +test('probe reports unavailable when no WebSocket implementation is provided', async () => { + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined }) + assert.equal(result.ok, false) + assert.match(result.reason, /not available/) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 10703c127561..372022949f32 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From 500cf537b7d0fc31345588b4498e99e86a0f46f8 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 4 Jun 2026 21:02:39 +0700 Subject: [PATCH 014/541] fix(desktop): validate live WebSocket in remote gateway connection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Test remote" button only checked HTTP GET /api/status, but the chat surface depends on the renderer opening a live WebSocket to /api/ws — a separate transport with separate server-side guards (Host/Origin checks, ws-ticket/token auth, peer-IP checks). A gateway could pass the HTTP check yet reject the WebSocket, so the test reported "reachable" while boot still failed with the opaque "Could not connect to Hermes gateway". testDesktopConnectionConfig now mirrors the renderer's connect: after the status check it opens the WS URL (token/local) or a freshly minted ws-ticket (OAuth) and confirms the upgrade is accepted and not immediately torn down by a post-handshake auth rejection. Failures surface an actionable message instead of a false-positive. The WS leg is skipped when the runtime lacks a global WebSocket so it never fails spuriously. --- apps/desktop/electron/main.cjs | 47 +++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ded844ebec8d..5792b1dc208f 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -27,6 +27,7 @@ const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') +const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -3747,18 +3748,42 @@ async function testDesktopConnectionConfig(input = {}) { // for local we fall back to the resolved/started backend. let baseUrl let token = null + let authMode = 'token' if (config.mode === 'remote') { baseUrl = normalizeRemoteBaseUrl(config.remote.url) - if ((config.remote.authMode || 'token') !== 'oauth') { + authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token' + if (authMode !== 'oauth') { token = decryptDesktopSecret(config.remote.token) } } else { const remote = (await resolveRemoteBackend()) || (await startHermes()) baseUrl = remote.baseUrl token = remote.token + authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' } const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + // The HTTP status check above proves the backend is reachable, but the chat + // surface only works once the renderer's live WebSocket to ``/api/ws`` + // connects — a separate transport with separate server-side guards (Host/ + // Origin, ws-ticket/token auth). Validating only the HTTP side produced a + // false-positive "reachable" while the real boot still failed with "Could not + // connect to Hermes gateway". Mirror the renderer's connect here so the test + // reflects the full path the app actually uses. + const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an + // older Electron/Node never fails the test spuriously); Electron's main + // process ships a global WebSocket on every supported version. + if (wsUrl && typeof globalThis.WebSocket === 'function') { + const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { + throw new Error( + `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + + 'The HTTP check can pass while the WebSocket is blocked by a proxy, firewall, or gateway auth/origin guard.' + ) + } + } + return { ok: true, baseUrl, @@ -3766,6 +3791,26 @@ async function testDesktopConnectionConfig(input = {}) { } } +// Build the WS URL the renderer would connect with, so the connection test can +// exercise the same transport. OAuth gateways need a freshly minted single-use +// ticket; token/local gateways carry a long-lived token in the query string. A +// null return means we can't form a credentialed URL (e.g. OAuth without a live +// session) and the WS leg of the test is skipped rather than failing spuriously. +async function resolveTestWsUrl(baseUrl, authMode, token) { + if (authMode === 'oauth') { + try { + const ticket = await mintGatewayWsTicket(baseUrl) + return buildGatewayWsUrlWithTicket(baseUrl, ticket) + } catch { + return null + } + } + if (!token) { + return null + } + return buildGatewayWsUrl(baseUrl, token) +} + function resetBootProgressForReconnect() { updateBootProgress( { From bcb024ad48bc885b4bbd9ff3c170f2a7fc66c6f5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:37:11 -0700 Subject: [PATCH 015/541] fix(desktop): fail remote test when OAuth ws-ticket mint fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Youssef's review caught a residual false-positive: resolveTestWsUrl swallowed an OAuth ticket-mint failure and returned null, so the caller skipped the WS probe and reported the remote test as reachable. But the real boot path (resolveRemoteBackend) treats a mint failure as a hard 'session expired' auth error and refuses to connect — so an expired OAuth session passed the test then failed boot, the exact false-positive this PR exists to kill. Extract resolveTestWsUrl into the electron-free connection-config.cjs (injectable mintTicket) so it's unit-testable, and make OAuth mint failure throw an actionable needsOauthLogin error instead of skipping. Adds the three cases Youssef requested plus a mintTicket-required guard. --- apps/desktop/electron/connection-config.cjs | 54 +++++++++++++++++++ .../electron/connection-config.test.cjs | 50 +++++++++++++++++ apps/desktop/electron/main.cjs | 23 +------- 3 files changed, 106 insertions(+), 21 deletions(-) diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 0704cd6a2b28..c10a657d1970 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -65,6 +65,59 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}` } +/** + * Build the WS URL the renderer would connect with, so the connection test can + * exercise the same transport the app actually uses. + * + * The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise`) + * so this stays electron-free and unit-testable; main.cjs passes the real + * `mintGatewayWsTicket`. + * + * Return semantics: + * - token mode + token → ws(s)://…/api/ws?token=… + * - token mode, no token → null (genuine skip; nothing to authenticate with) + * - oauth, mint ok → ws(s)://…/api/ws?ticket=… + * - oauth, mint fails → THROWS (NOT a skip) + * + * The oauth-mint-failure throw is the important case: the real boot path + * (resolveRemoteBackend in main.cjs) treats a mint failure as a hard + * "session expired" auth error and refuses to connect. Swallowing it here + * would re-introduce the exact false-positive this test exists to catch — + * HTTP /api/status passes, the test reports "reachable", then the renderer + * can't authenticate /api/ws and boot dies with "Could not connect". + * + * @param {string} baseUrl + * @param {'token'|'oauth'} authMode + * @param {string|null} token + * @param {{ mintTicket: (baseUrl: string) => Promise }} deps + * @returns {Promise} + */ +async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) { + if (authMode === 'oauth') { + const mintTicket = deps.mintTicket + if (typeof mintTicket !== 'function') { + throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.') + } + let ticket + try { + ticket = await mintTicket(baseUrl) + } catch (error) { + const err = new Error( + 'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' + + '(it may have expired). Open Settings → Gateway and sign in again.' + ) + err.needsOauthLogin = true + err.cause = error + throw err + } + return buildGatewayWsUrlWithTicket(baseUrl, ticket) + } + if (!token) { + return null + } + return buildGatewayWsUrl(baseUrl, token) +} + function tokenPreview(value) { const raw = String(value || '') @@ -114,5 +167,6 @@ module.exports = { cookiesHaveSession, normalizeRemoteBaseUrl, resolveAuthMode, + resolveTestWsUrl, tokenPreview } diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 3182bd2ff54f..33f81f3b66ed 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -21,6 +21,7 @@ const { cookiesHaveSession, normalizeRemoteBaseUrl, resolveAuthMode, + resolveTestWsUrl, tokenPreview } = require('./connection-config.cjs') @@ -159,3 +160,52 @@ test('tokenPreview returns set for short tokens', () => { test('tokenPreview returns a masked suffix for long tokens', () => { assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop') }) + +// --- resolveTestWsUrl --- +// +// The "Test remote" button must exercise the same WS transport the app uses, +// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that +// is the exact false-positive PR #39098 set out to eliminate. + +test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => { + const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123') + assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123') +}) + +test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => { + assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null) +}) + +test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => { + const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, { + mintTicket: async () => 'tkt-9' + }) + assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9') +}) + +test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => { + await assert.rejects( + () => + resolveTestWsUrl('https://gw.example.com', 'oauth', null, { + mintTicket: async () => { + throw new Error('401 ticket mint failed') + } + }), + err => { + // Actionable, points the user at re-auth, and preserves the cause + flag + // the boot overlay uses to offer a sign-in prompt. + assert.match(err.message, /WebSocket ticket/i) + assert.match(err.message, /sign in again/i) + assert.equal(err.needsOauthLogin, true) + assert.ok(err.cause instanceof Error) + return true + } + ) +}) + +test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => { + await assert.rejects( + () => resolveTestWsUrl('https://gw.example.com', 'oauth', null), + /mintTicket function is required/ + ) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5792b1dc208f..0a877719e3a0 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -35,6 +35,7 @@ const { cookiesHaveSession, normalizeRemoteBaseUrl, resolveAuthMode, + resolveTestWsUrl, tokenPreview } = require('./connection-config.cjs') const { @@ -3770,7 +3771,7 @@ async function testDesktopConnectionConfig(input = {}) { // false-positive "reachable" while the real boot still failed with "Could not // connect to Hermes gateway". Mirror the renderer's connect here so the test // reflects the full path the app actually uses. - const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token) + const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket }) // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an // older Electron/Node never fails the test spuriously); Electron's main // process ships a global WebSocket on every supported version. @@ -3791,26 +3792,6 @@ async function testDesktopConnectionConfig(input = {}) { } } -// Build the WS URL the renderer would connect with, so the connection test can -// exercise the same transport. OAuth gateways need a freshly minted single-use -// ticket; token/local gateways carry a long-lived token in the query string. A -// null return means we can't form a credentialed URL (e.g. OAuth without a live -// session) and the WS leg of the test is skipped rather than failing spuriously. -async function resolveTestWsUrl(baseUrl, authMode, token) { - if (authMode === 'oauth') { - try { - const ticket = await mintGatewayWsTicket(baseUrl) - return buildGatewayWsUrlWithTicket(baseUrl, ticket) - } catch { - return null - } - } - if (!token) { - return null - } - return buildGatewayWsUrl(baseUrl, token) -} - function resetBootProgressForReconnect() { updateBootProgress( { From 96cd37e212e819994c0e8f7135476986b14fa57c Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 5 Jun 2026 09:59:07 +1000 Subject: [PATCH 016/541] fix(dashboard): reap orphaned embedded-chat sessions to stop slash_worker leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #38591 made the dashboard's embedded chat unconditional, every browser refresh of /chat spins up a fresh session.create (new sid + a fresh _SlashWorker via _deferred_build) over /api/ws, but the old tab's WS disconnect only DETACHES the transport (ws.py) — it never closes the old session or its slash_worker. The dashboard's in-process gateway is long-lived, so the detached _SlashWorker subprocess's stdin pipe stays open forever and the worker never reaches EOF: one leaked python process per refresh. Fix at the session-lifecycle layer (not PTY signal timing — verified that a process whose owning gateway dies is always reaped via stdin-EOF; the leak is specifically the long-lived dashboard process keeping detached sessions parked). On WS disconnect, schedule a grace-delayed reap of any session left orphaned (transport detached to stdio, not mid-turn). A quick reconnect / session.resume / prompt.submit rebinds a live transport and cancels the reap, preserving the intentional detach-for-reconnect window. - server.py: extract _teardown_session() (shared with session.close), add _ws_session_is_orphaned() + _schedule_ws_orphan_reap(), gated by HERMES_TUI_WS_ORPHAN_REAP_GRACE_S (default 20s, 0 disables). - ws.py: schedule the reap for each detached session on disconnect. - tests: reap-closes-worker, spares-reattached/mid-turn/finalized, disabled-when-grace-zero. --- tests/test_tui_gateway_server.py | 67 +++++++++++++++++++ tui_gateway/server.py | 110 +++++++++++++++++++++++++------ tui_gateway/ws.py | 19 +++++- 3 files changed, 176 insertions(+), 20 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7899a6de4b2e..47f1a19cc912 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -876,6 +876,73 @@ def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch): server._sessions.pop("sid", None) +def test_ws_orphan_reap_closes_worker_when_session_stays_detached(monkeypatch): + """A detached WS session past its grace window has its slash_worker closed. + + Regression for #38591 fallout: every dashboard refresh spawned a fresh + session + _SlashWorker but never reaped the previous one, leaking one + python subprocess per refresh. + """ + closed = {"worker": False} + + class _FakeWorker: + def close(self): + closed["worker"] = True + + server._sessions["orphan-sid"] = _session( + transport=server._stdio_transport, + slash_worker=_FakeWorker(), + running=False, + ) + # Run the reap body synchronously (no real timer/grace) to assert behaviour. + monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.01) + try: + # Directly invoke the orphaned-check + teardown the timer would run. + assert server._ws_session_is_orphaned(server._sessions["orphan-sid"]) is True + session = server._sessions.pop("orphan-sid") + server._teardown_session(session) + assert closed["worker"] is True + finally: + server._sessions.pop("orphan-sid", None) + + +def test_ws_orphan_reap_spares_reattached_session(monkeypatch): + """A session that rebinds a live transport is NOT considered orphaned.""" + + class _LiveTransport: + def write(self, *a, **k): + return True + + # Reattached: transport is a live (non-stdio) transport. + reattached = _session(transport=_LiveTransport(), running=False) + assert server._ws_session_is_orphaned(reattached) is False + + # Mid-turn sessions are also spared even if detached. + mid_turn = _session(transport=server._stdio_transport, running=True) + assert server._ws_session_is_orphaned(mid_turn) is False + + # Already finalized sessions are spared (idempotency). + done = _session(transport=server._stdio_transport, running=False, _finalized=True) + assert server._ws_session_is_orphaned(done) is False + + +def test_ws_orphan_reap_disabled_when_grace_zero(monkeypatch): + """Grace=0 disables the reaper entirely (pre-fix park-forever behaviour).""" + fired = {"timer": False} + + class _Timer: + def __init__(self, *a, **k): + fired["timer"] = True + + def start(self): + pass + + monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0.0) + monkeypatch.setattr(server.threading, "Timer", _Timer) + server._schedule_ws_orphan_reap("any-sid") + assert fired["timer"] is False + + def test_init_session_fires_reset_hook(monkeypatch): hooks = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 266d2e3ffb8d..4dc30e06a994 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -136,6 +136,24 @@ try: except (ValueError, TypeError): _slash_timeout = 45.0 _SLASH_WORKER_TIMEOUT_S = max(5.0, _slash_timeout) + +# When a WebSocket client (the dashboard's embedded-chat tab / desktop app) +# disconnects, ``tui_gateway.ws`` detaches the transport but intentionally +# leaves the session parked so a quick reconnect can reattach it (see ws.py). +# That park is unbounded, though: a browser refresh spins up a brand-new +# ``session.create`` (new sid + a fresh _SlashWorker via _deferred_build) and +# never reattaches the OLD sid, so the old session's slash-worker subprocess +# lingers forever — one leaked python process per refresh (#38591 fallout). +# After this grace window, an orphaned (transport-detached, not-running) WS +# session is reaped: its _SlashWorker is closed and the session finalized. +# Set to 0 to disable (park forever, pre-fix behaviour). +try: + _ws_orphan_reap_grace = float( + os.environ.get("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S") or "20" + ) +except (ValueError, TypeError): + _ws_orphan_reap_grace = 20.0 +_WS_ORPHAN_REAP_GRACE_S = max(0.0, _ws_orphan_reap_grace) _DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity") _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"}) @@ -326,6 +344,78 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No pass +def _teardown_session(session: dict | None) -> None: + """Fully tear down a session: finalize, unregister, close agent + worker. + + Shared by ``session.close`` and the orphaned-WS-session reaper so the + slash-worker subprocess is always closed exactly once via the same path. + Idempotent: the ``_finalized`` guard in ``_finalize_session`` and the + ``poll()`` guard in ``_SlashWorker.close`` make repeat calls harmless. + """ + if not session: + return + _finalize_session(session) + try: + from tools.approval import unregister_gateway_notify + + unregister_gateway_notify(session["session_key"]) + except Exception: + pass + try: + agent = session.get("agent") + if agent and hasattr(agent, "close"): + agent.close() + except Exception: + pass + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass + + +def _ws_session_is_orphaned(session: dict | None) -> bool: + """True if a WS session has no live transport and no in-flight turn. + + After ``handle_ws`` detaches a disconnected client it points the session + at ``_stdio_transport``. In the dashboard's in-process gateway there is no + real stdio peer reading those frames, so a session left on the stdio + transport (and not mid-turn) is genuinely orphaned and safe to reap. + """ + if not session or session.get("_finalized"): + return False + if session.get("running"): + return False + return session.get("transport") is _stdio_transport + + +def _schedule_ws_orphan_reap(sid: str) -> None: + """After a grace window, reap session ``sid`` iff it's still orphaned. + + Called from the WS-disconnect path. The grace window lets a transient + reconnect (or a ``session.resume`` that reattaches the transport) cancel + the reap by re-binding a live transport. Disabled when the grace is 0. + """ + if _WS_ORPHAN_REAP_GRACE_S <= 0: + return + + def _reap() -> None: + with _session_resume_lock: + session = _sessions.get(sid) + if not _ws_session_is_orphaned(session): + return + _sessions.pop(sid, None) + try: + _teardown_session(session) + except Exception: + pass + + timer = threading.Timer(_WS_ORPHAN_REAP_GRACE_S, _reap) + timer.daemon = True + timer.start() + + def _shutdown_sessions() -> None: with _sessions_lock: snapshot = list(_sessions.values()) @@ -3675,25 +3765,7 @@ def _(rid, params: dict) -> dict: session = _sessions.pop(sid, None) if not session: return _ok(rid, {"closed": False}) - _finalize_session(session) - try: - from tools.approval import unregister_gateway_notify - - unregister_gateway_notify(session["session_key"]) - except Exception: - pass - try: - agent = session.get("agent") - if agent and hasattr(agent, "close"): - agent.close() - except Exception: - pass - try: - worker = session.get("slash_worker") - if worker: - worker.close() - except Exception: - pass + _teardown_session(session) return _ok(rid, {"closed": True}) diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 365ce21b6376..e822f7f874cf 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -262,15 +262,32 @@ async def handle_ws(ws: Any) -> None: break finally: detached_sessions = 0 + reaped_scheduled = 0 if transport is not None: transport.close() # Detach the transport from any sessions it owned so later emits # fall back to stdio instead of crashing into a closed socket. - for _, sess in list(server._sessions.items()): + # + # In the dashboard's in-process gateway that stdio fallback has no + # real reader, so a detached session would otherwise sit forever + # holding its _SlashWorker subprocess open (one leaked python proc + # per browser refresh — #38591 fallout). Schedule a grace-delayed + # reap; a quick reconnect / session.resume re-binds a live + # transport and cancels it (see _ws_session_is_orphaned). + for _sid, sess in list(server._sessions.items()): if sess.get("transport") is transport: sess["transport"] = server._stdio_transport detached_sessions += 1 + try: + server._schedule_ws_orphan_reap(_sid) + reaped_scheduled += 1 + except Exception: + _log.exception( + "ws orphan-reap schedule failed peer=%s sid=%s", + peer, + _sid, + ) try: await ws.close() except Exception as exc: From fb853a1783099d75edab49dba0dde6ead8c26340 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 4 Jun 2026 22:56:17 -0400 Subject: [PATCH 017/541] fix(install): scrap rebuild venv --- hermes_cli/main.py | 79 +------------ hermes_cli/managed_uv.py | 110 +------------------ tests/hermes_cli/test_cmd_update.py | 9 +- tests/hermes_cli/test_managed_uv.py | 122 +-------------------- tests/hermes_cli/test_update_autostash.py | 44 +------- tests/hermes_cli/test_update_venv_ready.py | 83 -------------- tests/hermes_cli/test_uv_tool_update.py | 9 +- 7 files changed, 23 insertions(+), 433 deletions(-) delete mode 100644 tests/hermes_cli/test_update_venv_ready.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index afb7ce9f7a69..08e610d177f8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8029,20 +8029,12 @@ def _update_via_zip(args): # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() - uv_bin, fresh_bootstrap = ensure_uv() - # First-time managed uv install on an existing checkout: the old venv - # may point to a Python without FTS5. Rebuild it so the new managed - # uv provides a fresh interpreter with FTS5 guaranteed. - if fresh_bootstrap and uv_bin: - if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): - raise RuntimeError( - "venv rebuild failed; aborting update before dependency install" - ) + uv_bin = ensure_uv() pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -8697,48 +8689,6 @@ def _venv_scripts_dir() -> Path | None: return scripts if scripts.is_dir() else None -def _wait_for_interpreter_venv_ready(*, timeout: float = 15.0) -> bool: - """Ensure the venv hosting ``sys.executable`` has an intact ``pyvenv.cfg``. - - During ``hermes update`` the managed-uv path can rebuild the project venv - (``rebuild_venv`` → ``shutil.rmtree`` + ``uv venv``) before the - desktop-rebuild and profile-skills-sync steps run. Both of those steps - spawn a child process with ``sys.executable``. If they fire while the venv - is mid-rewrite, the interpreter launcher finds the venv directory but no - ``pyvenv.cfg`` yet and aborts with the bare stderr line - ``No pyvenv.cfg file`` — surfacing as a spurious "Desktop build failed" / - "sync failed" on an update that otherwise succeeded. - - A venv's ``pyvenv.cfg`` sits one level up from the interpreter's ``bin`` / - ``Scripts`` dir. If ``sys.executable`` is NOT a venv interpreter (no - sibling marker dir, e.g. a system Python on PATH), there is nothing to - wait for and we return True immediately. Otherwise we poll briefly for the - marker to (re)appear — the rewrite window is short — and return whether - it's present. Best-effort: never raises, callers proceed regardless. - """ - try: - exe = Path(sys.executable).resolve() - except Exception: - return True - - venv_dir = exe.parent.parent # .../venv/{bin,Scripts}/python -> .../venv - bin_dir = venv_dir / ("Scripts" if _is_windows() else "bin") - if not bin_dir.is_dir(): - # Not a venv-hosted interpreter — pyvenv.cfg is irrelevant. - return True - - cfg = venv_dir / "pyvenv.cfg" - if cfg.is_file(): - return True - - deadline = _time.monotonic() + max(0.0, timeout) - while _time.monotonic() < deadline: - if cfg.is_file(): - return True - _time.sleep(0.25) - return cfg.is_file() - - def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: """Entry-point shims that uv may try to rewrite during ``pip install -e .``. @@ -10133,7 +10083,7 @@ def _cmd_update_pip(args): # Keep managed uv current before using it. update_managed_uv() - uv, _fresh_bootstrap = ensure_uv() + uv = ensure_uv() in_venv = sys.prefix != sys.base_prefix # pipx-managed installs live under .../pipx/venvs//... pipx_managed = "pipx" in sys.prefix.split(os.sep) @@ -10575,20 +10525,12 @@ def _cmd_update_impl(args, gateway_mode: bool): # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() - uv_bin, fresh_bootstrap = ensure_uv() - # First-time managed uv install on an existing checkout: the old venv - # may point to a Python without FTS5. Rebuild it so the new managed - # uv provides a fresh interpreter with FTS5 guaranteed. - if fresh_bootstrap and uv_bin: - if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): - raise RuntimeError( - "venv rebuild failed; aborting update before dependency install" - ) + uv_bin = ensure_uv() pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -10651,18 +10593,13 @@ def _cmd_update_impl(args, gateway_mode: bool): has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir) if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app: print("→ Checking if desktop app needs rebuilding...") - # The Python-dependency step above may have rebuilt the venv that - # hosts sys.executable. Wait for its pyvenv.cfg to settle before - # spawning, or the child interpreter aborts with "No pyvenv.cfg - # file" and the rebuild spuriously "fails" on a successful update. - _wait_for_interpreter_venv_ready() _desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"] # Stream the build output live (long Electron builds otherwise # look hung). On the rare nonzero exit, retry once after waiting # again for the venv — this covers a still-settling rebuild window # the first wait didn't fully catch. build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) - if build_result.returncode != 0 and _wait_for_interpreter_venv_ready(): + if build_result.returncode != 0: build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) if build_result.returncode != 0: print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)") @@ -10719,10 +10656,6 @@ def _cmd_update_impl(args, gateway_mode: bool): if all_profiles: print() print("→ Syncing bundled skills to all profiles...") - # seed_profile_skills spawns sys.executable; if the venv was - # just rebuilt above, wait for pyvenv.cfg before the loop so - # the children don't abort with "No pyvenv.cfg file". - _wait_for_interpreter_venv_ready() for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 722cf5ba33b5..3f027e73c874 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -6,11 +6,6 @@ If the binary is missing, ``ensure_uv()`` bootstraps it via the official standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH probing, no conda guards, no multi-location resolution chains. - -When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no -managed uv before), it returns ``(path, True)`` instead of just ``path``. -Callers in the update path use that signal to nuke and recreate the venv -with the now-current managed uv, guaranteeing a Python with FTS5. """ from __future__ import annotations @@ -22,7 +17,7 @@ import shutil import subprocess import tempfile from pathlib import Path -from typing import Optional, Tuple +from typing import Optional from hermes_constants import get_hermes_home @@ -56,20 +51,15 @@ def resolve_uv() -> Optional[str]: return None -def ensure_uv() -> Tuple[Optional[str], bool]: +def ensure_uv() -> Optional[str]: """Return the managed uv path, installing it first if necessary. - Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is - ``True`` when we just installed managed uv for the first time (there was - no managed uv before this call). Callers can use that signal to rebuild - the venv so Python is guaranteed to have FTS5. - - On failure returns ``(None, False)`` (never raises) so callers can fall + On failure returns ``None`` (never raises) so callers can fall back to pip gracefully. """ existing = resolve_uv() if existing: - return (existing, False) + return existing target = managed_uv_path() target.parent.mkdir(parents=True, exist_ok=True) @@ -81,7 +71,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]: except Exception as exc: logger.warning("Managed uv install failed: %s", exc) print(f" ✗ Failed to install managed uv: {exc}") - return (None, False) + return None # Verify result = resolve_uv() @@ -95,95 +85,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]: print(f" ✓ Managed uv installed ({version})") else: print(" ✗ Managed uv install appeared to succeed but binary not found") - return (result, result is not None) - - -def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: - """Nuke and recreate the venv with managed uv. - - Called when managed uv is first bootstrapped on an existing install — the - old venv may point to a Python without FTS5, so we rebuild it with a - fresh interpreter from the current managed uv. Returns ``True`` on - success. - - The old venv is moved aside *atomically* (``os.replace`` to ``.old``) - before recreating — never deleted in place. On Windows a still-running - ``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open; - ``shutil.rmtree(ignore_errors=True)`` would delete everything it *can* - (site-packages, certifi's cert bundle) and silently leave a half-gutted - venv that the following ``uv venv`` then refuses to overwrite ("directory - already exists") — bricking the install with no recovery (every later HTTPS - call dies with ``FileNotFoundError`` for the missing cert bundle). - ``--clear`` alone does not fix this: when the locked interpreter is *inside* - the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can - delete the held ``python.exe``. ``os.replace`` of the parent directory *is* - allowed (Windows tracks a running ``.exe`` by handle, not path), so the - rebuild completes while the running process keeps using the moved-aside copy - until it restarts. If the venv genuinely cannot be moved, we abort cleanly - and leave it fully intact; and if the rebuild itself fails we move the old - venv back so Hermes is never left with no venv at all. - """ - backup: Optional[Path] = None - if venv_dir.exists(): - print(f" → Rebuilding venv (old Python may lack FTS5)...") - backup = venv_dir.with_name(venv_dir.name + ".old") - shutil.rmtree(backup, ignore_errors=True) # clear any stale backup - try: - # Atomic move — fails (without partial deletion) if a process still - # holds files inside the venv, which is exactly the Windows - # file-lock case that previously bricked the install. - os.replace(venv_dir, backup) - except OSError as exc: - logger.warning("venv rebuild aborted — venv in use: %s", exc) - print( - " ✗ venv rebuild aborted — the venv is in use; stop the " - f"gateway/desktop and retry ({exc})" - ) - return False - - result = subprocess.run( - [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"], - capture_output=True, - text=True, - check=False, - ) - - def _restore_backup() -> None: - if backup is not None and backup.exists(): - shutil.rmtree(venv_dir, ignore_errors=True) - try: - os.replace(backup, venv_dir) - print(" ↩ Restored previous venv after failed rebuild.") - except OSError: - pass - - if result.returncode == 0: - venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" - # uv can exit 0 yet leave no usable interpreter (e.g. a half-written - # venv). Don't report success on a venv that has no python — restore the - # moved-aside copy so the caller can abort without losing a working env. - if not venv_python.exists(): - logger.warning("venv rebuild reported success but %s is missing", venv_python) - print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}") - _restore_backup() - return False - if backup is not None: - shutil.rmtree(backup, ignore_errors=True) - py_ver = subprocess.run( - [str(venv_python), "--version"], - capture_output=True, - text=True, - check=False, - ).stdout.strip() - print(f" ✓ venv rebuilt ({py_ver})") - return True - else: - # Rebuild failed — restore the old venv so we never leave Hermes with no - # venv (the bricked-install failure mode this function exists to avoid). - _restore_backup() - logger.warning("venv rebuild failed: %s", result.stderr) - print(f" ✗ venv rebuild failed: {result.stderr.strip()}") - return False + return result def update_managed_uv() -> Optional[str]: diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index bcf92c5e7216..d8204524c543 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -59,19 +59,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index aff6b8de74ac..a9e9b92a67a9 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -76,11 +76,10 @@ class TestEnsureUv: _make_executable(tmp_path / "bin" / "uv") with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): from hermes_cli.managed_uv import ensure_uv - path, fresh = ensure_uv() + path = ensure_uv() assert path == str(tmp_path / "bin" / "uv") - assert fresh is False - def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path): + def test_installs_if_missing(self, tmp_path): with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ patch("hermes_cli.managed_uv._install_uv") as mock_install: # Simulate the installer creating the binary @@ -89,127 +88,16 @@ class TestEnsureUv: mock_install.side_effect = fake_install from hermes_cli.managed_uv import ensure_uv - path, fresh = ensure_uv() + path = ensure_uv() assert path == str(tmp_path / "bin" / "uv") - assert fresh is True mock_install.assert_called_once() - def test_install_failure_returns_none_false(self, tmp_path): + def test_install_failure_returns_none(self, tmp_path): with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): from hermes_cli.managed_uv import ensure_uv - path, fresh = ensure_uv() + path = ensure_uv() assert path is None - assert fresh is False - - -# --------------------------------------------------------------------------- -# rebuild_venv -# --------------------------------------------------------------------------- - -class TestRebuildVenv: - def test_moves_old_venv_aside_and_creates_new(self, tmp_path): - """The old venv is moved aside to .old (never rmtree'd in place), - uv is invoked with --clear, the moved-aside backup is removed on - success, and the rebuilt interpreter is reported.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "old_file").write_text("stale") - - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock(returncode=0, stderr="", stdout="") - if len(cmd) >= 2 and cmd[1] == "venv": - # Simulate uv creating the venv dir with a python interpreter - bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") - bin_dir.mkdir(parents=True, exist_ok=True) - python_name = "python.exe" if os.name == "nt" else "python" - (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") - elif "--version" in cmd: - m.stdout = "Python 3.11.0" - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is True - # uv venv was invoked exactly once, always with --clear. - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}" - assert "--clear" in venv_calls[0] - # The moved-aside backup is cleaned up after a successful rebuild. - assert not (tmp_path / "venv.old").exists() - - def test_aborts_without_deleting_when_venv_in_use(self, tmp_path): - """If os.replace fails (Windows file lock — venv in use), we must abort - cleanly WITHOUT deleting the venv and WITHOUT invoking uv.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "locked") .write_text("held open") - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - return MagicMock(returncode=0, stderr="", stdout="") - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - # venv left fully intact, uv never invoked. - assert venv_dir.exists() and (venv_dir / "locked").exists() - assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == [] - - def test_restores_backup_when_rebuild_fails(self, tmp_path): - """If uv venv exits non-zero, the moved-aside venv is restored so we - never leave Hermes with no venv at all.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "marker").write_text("original") - uv_bin = str(tmp_path / "bin" / "uv") - - def fake_run(cmd, **kwargs): - return MagicMock(returncode=1, stderr="boom", stdout="") - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - # Original venv restored from the .old backup. - assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original" - assert not (tmp_path / "venv.old").exists() - - def test_rebuild_failure_returns_false(self, tmp_path): - venv_dir = tmp_path / "venv" - uv_bin = str(tmp_path / "bin" / "uv") - - with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=1, stderr="nope") - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - assert result is False - - def test_rebuild_success_without_python_returns_false(self, tmp_path): - """uv can exit 0 yet leave no interpreter; that must not count as success - (guard adapted from #38511).""" - venv_dir = tmp_path / "venv" - uv_bin = str(tmp_path / "bin" / "uv") - - with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - assert result is False - # Returned before the `python --version` probe ran (only the uv venv call). - assert mock_run.call_count == 1 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index b291790775f2..6cd08a439db9 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -29,19 +29,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path): @@ -423,41 +418,6 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): assert ".[all]" in install_cmds[0] -def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path): - """A failed fresh managed-uv venv rebuild must not continue into pip install - (guard adapted from #38511).""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False) - - recorded = [] - - def fake_run(cmd, **kwargs): - recorded.append(cmd) - # Tolerant matching: the update flow's exact git invocations vary by - # checkout, so key off the verb. Branch detection must return a real name - # and rev-list a parseable count, or the flow aborts early before it ever - # reaches the venv rebuild this test exercises. - if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git": - if "rev-parse" in cmd: - return SimpleNamespace(stdout="main\n", stderr="", returncode=0) - if "rev-list" in cmd: - return SimpleNamespace(stdout="1\n", stderr="", returncode=0) - if "pull" in cmd: - return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - - with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \ - patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \ - pytest.raises(RuntimeError, match="venv rebuild failed"): - hermes_main.cmd_update(SimpleNamespace()) - - install_cmds = [c for c in recorded if "pip" in c and "install" in c] - assert install_cmds == [] - - def test_install_with_optional_fallback_honors_custom_group(monkeypatch): """Termux update path should target .[termux-all] when requested.""" calls = [] diff --git a/tests/hermes_cli/test_update_venv_ready.py b/tests/hermes_cli/test_update_venv_ready.py deleted file mode 100644 index 69cf1f4f6f2b..000000000000 --- a/tests/hermes_cli/test_update_venv_ready.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``. - -During ``hermes update`` the managed-uv path can rebuild the project venv -(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync -steps spawn ``sys.executable``. If those children fire while the venv is -mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and -the step spuriously "fails" on an otherwise-successful update. The helper -waits for the marker to settle first. -""" - -from __future__ import annotations - -import os -import threading -import time -from pathlib import Path - -from hermes_cli.main import _wait_for_interpreter_venv_ready - - -def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path: - """Create a venv-shaped dir and return the interpreter path inside it.""" - bin_name = "Scripts" if os.name == "nt" else "bin" - bin_dir = tmp_path / bin_name - bin_dir.mkdir(parents=True) - py = bin_dir / ("python.exe" if os.name == "nt" else "python") - py.write_text("#!/bin/sh\n") - if with_cfg: - (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") - return py - - -class TestWaitForInterpreterVenvReady: - def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=True) - monkeypatch.setattr("sys.executable", str(py)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - assert time.monotonic() - t0 < 0.5 - - def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch): - # A bare interpreter whose parent.parent has no bin/Scripts marker - # dir is not venv-hosted; pyvenv.cfg is irrelevant. - sys_py = tmp_path / "usr" / "bin" / "python" - sys_py.parent.mkdir(parents=True) - sys_py.write_text("#!/bin/sh\n") - # Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv - monkeypatch.setattr("sys.executable", str(sys_py)) - # parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin - # which exists — so this would look venv-ish. Use a deeper layout - # where parent.parent has no bin marker: - deep = tmp_path / "opt" / "py3" / "real" / "python" - deep.parent.mkdir(parents=True) - deep.write_text("#!/bin/sh\n") - monkeypatch.setattr("sys.executable", str(deep)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - assert time.monotonic() - t0 < 0.5 - - def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=False) - monkeypatch.setattr("sys.executable", str(py)) - - def _write_cfg_later(): - time.sleep(0.6) - (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") - - th = threading.Thread(target=_write_cfg_later) - th.start() - try: - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - elapsed = time.monotonic() - t0 - finally: - th.join() - assert 0.5 < elapsed < 2.0 - - def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=False) - monkeypatch.setattr("sys.executable", str(py)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=1) is False - assert 0.9 < time.monotonic() - t0 < 1.6 diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py index f1f2905ef890..b8f294de373e 100644 --- a/tests/hermes_cli/test_uv_tool_update.py +++ b/tests/hermes_cli/test_uv_tool_update.py @@ -40,19 +40,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield From 7c00ffd92c4135365f395224d359a760f8838cce Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 5 Jun 2026 13:30:02 +1000 Subject: [PATCH 018/541] fix(google-workspace): fall back to uv when venv has no pip (#39516) The Hermes Docker image's venv is built with `uv sync`, which does not bootstrap pip into the venv. When the google-workspace setup script needs to install its deps and the running interpreter has no pip, `sys.executable -m pip install` dead-ends with "No module named pip" (reported via Discord support). install_deps() now falls back to `uv pip install --python ` when the pip path fails and uv is on PATH. uv installs into the exact interpreter the script is running under without needing pip present, so the pip-less venv self-heals (e.g. a dep evicted on image update, or a build without the [google]/[all] extra). On environments with neither pip nor uv, the [google] extra hint is printed as before. Verified E2E against nousresearch/hermes-agent:latest: under the venv python with a missing dep, --install-deps now prints "Dependencies installed." and exits 0 instead of failing. Adds TestInstallDeps regression coverage: pip path, uv fallback, uv-not-consulted-when-pip-works control, and both no-installer-available and uv-also-fails failure cases. --- .../google-workspace/scripts/setup.py | 40 +++++- tests/skills/test_google_oauth_setup.py | 123 ++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index d09085fe779e..f5300e9e6a9d 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -26,6 +26,7 @@ from __future__ import annotations # allow PEP 604 `X | None` on Python 3.9+ import argparse import json import os +import shutil import subprocess import sys from pathlib import Path @@ -103,6 +104,8 @@ def install_deps(): pass print("Installing Google API dependencies...") + + # First choice: pip in the current interpreter. Works for most installs. try: subprocess.check_call( [sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES, @@ -111,13 +114,36 @@ def install_deps(): print("Dependencies installed.") return True except subprocess.CalledProcessError as e: - print(f"ERROR: Failed to install dependencies: {e}") - print( - "On environments without pip (e.g. Nix), install the optional extra instead:" - ) - print(" pip install 'hermes-agent[google]'") - print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}") - return False + pip_error = e + + # Fallback: the interpreter has no pip (the Hermes Docker image's venv is + # built with `uv sync`, which does not bootstrap pip). `uv pip install + # --python ` installs into that exact interpreter without + # needing pip present. Targeting sys.executable keeps us on the venv the + # script is actually running under, rather than guessing. + uv = shutil.which("uv") + if uv: + try: + subprocess.check_call( + [uv, "pip", "install", "--python", sys.executable, "--quiet"] + + REQUIRED_PACKAGES, + stdout=subprocess.DEVNULL, + ) + print("Dependencies installed.") + return True + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to install dependencies via uv: {e}") + print(f"Manually: {uv} pip install --python {sys.executable} {' '.join(REQUIRED_PACKAGES)}") + return False + + print(f"ERROR: Failed to install dependencies: {pip_error}") + print( + "On environments without pip (e.g. Nix, or the Hermes Docker image's " + "uv-managed venv), install the optional extra instead:" + ) + print(" pip install 'hermes-agent[google]'") + print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}") + return False def _ensure_deps(): diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index a7908bd76a1f..1b7b0e17d216 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -322,3 +322,126 @@ class TestHermesConstantsFallback: import hermes_constants assert module.get_hermes_home is hermes_constants.get_hermes_home assert module.display_hermes_home is hermes_constants.display_hermes_home + + +def _load_setup_module(monkeypatch): + """Load setup.py without stubbing _ensure_deps (for install_deps tests).""" + spec = importlib.util.spec_from_file_location( + "google_workspace_setup_installdeps_test", SCRIPT_PATH + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _force_deps_missing(monkeypatch): + """Make `import googleapiclient` / `import google_auth_oauthlib` fail so + install_deps() proceeds past its early-return short-circuit.""" + for name in ("googleapiclient", "google_auth_oauthlib"): + monkeypatch.setitem(sys.modules, name, None) + + +class TestInstallDeps: + """Tests for install_deps() interpreter/installer selection. + + Regression coverage for the Hermes Docker image, whose venv is built with + `uv sync` and ships without pip — `sys.executable -m pip install` fails + with `No module named pip`, so install_deps() must fall back to uv. + """ + + def test_returns_early_when_already_installed(self, monkeypatch): + """If both libs import, no installer subprocess runs at all.""" + module = _load_setup_module(monkeypatch) + # Don't force-missing: real test env has the libs importable. Guard + # against any subprocess being spawned. + calls = [] + monkeypatch.setattr( + module.subprocess, "check_call", lambda *a, **k: calls.append(a) + ) + # google_auth_oauthlib may not be installed in the test env; only run + # this assertion when the early-return path is actually reachable. + try: + import googleapiclient # noqa: F401 + import google_auth_oauthlib # noqa: F401 + except ImportError: + pytest.skip("Google libs not installed in test env") + assert module.install_deps() is True + assert calls == [] + + def test_uses_pip_when_available(self, monkeypatch): + """When pip works, install_deps succeeds via pip and never calls uv.""" + module = _load_setup_module(monkeypatch) + _force_deps_missing(monkeypatch) + + recorded = [] + + def fake_check_call(cmd, **kwargs): + recorded.append(cmd) + # pip path is the first attempt — succeed. + return 0 + + which_calls = [] + monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) + monkeypatch.setattr( + module.shutil, "which", lambda name: which_calls.append(name) + ) + + assert module.install_deps() is True + assert recorded[0][:3] == [module.sys.executable, "-m", "pip"] + # Control: uv must NOT be consulted when pip succeeds. + assert which_calls == [] + + def test_falls_back_to_uv_when_pip_missing(self, monkeypatch): + """No pip → uv pip install --python is used.""" + module = _load_setup_module(monkeypatch) + _force_deps_missing(monkeypatch) + + recorded = [] + + def fake_check_call(cmd, **kwargs): + recorded.append(cmd) + if cmd[:3] == [module.sys.executable, "-m", "pip"]: + raise module.subprocess.CalledProcessError(1, cmd) + return 0 # uv invocation succeeds + + monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) + monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv") + + assert module.install_deps() is True + assert len(recorded) == 2 + uv_cmd = recorded[1] + assert uv_cmd[0] == "/usr/local/bin/uv" + assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable] + for pkg in module.REQUIRED_PACKAGES: + assert pkg in uv_cmd + + def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys): + """No pip AND no uv → failure, with the [google] extra hint printed.""" + module = _load_setup_module(monkeypatch) + _force_deps_missing(monkeypatch) + + def fake_check_call(cmd, **kwargs): + raise module.subprocess.CalledProcessError(1, cmd) + + monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) + monkeypatch.setattr(module.shutil, "which", lambda name: None) + + assert module.install_deps() is False + out = capsys.readouterr().out + assert "hermes-agent[google]" in out + + def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys): + """uv present but its install fails → failure surfaced (not swallowed).""" + module = _load_setup_module(monkeypatch) + _force_deps_missing(monkeypatch) + + def fake_check_call(cmd, **kwargs): + raise module.subprocess.CalledProcessError(1, cmd) + + monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) + monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv") + + assert module.install_deps() is False + out = capsys.readouterr().out + assert "via uv" in out From 4eca569bf42095f4ce3c39d228dee46365f38288 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 4 Jun 2026 23:32:08 -0400 Subject: [PATCH 019/541] fix: temp for update --- hermes_cli/managed_uv.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 3f027e73c874..95cde0e7bc86 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -182,3 +182,6 @@ def _install_uv_windows(env: dict[str, str]) -> None: check=True, capture_output=True, ) + +def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: + True # dont remove me. ask ethernet \ No newline at end of file From ab706a3346d0a7c242e6e2f88de4675de11bb98e Mon Sep 17 00:00:00 2001 From: shannonsands Date: Fri, 5 Jun 2026 13:59:55 +1000 Subject: [PATCH 020/541] Clear stale desktop onboarding errors (#38844) --- apps/desktop/src/store/onboarding.test.ts | 97 ++++++++++++++++++++++- apps/desktop/src/store/onboarding.ts | 3 +- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index b6bb51194eed..2958fd03f925 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -8,7 +8,8 @@ import { type OnboardingContext, refreshOnboarding, requestDesktopOnboarding, - saveOnboardingLocalEndpoint + saveOnboardingLocalEndpoint, + submitOnboardingCode } from './onboarding' function provider(id: string, name = id): OAuthProvider { @@ -146,6 +147,100 @@ describe('refreshOnboarding', () => { }) }) +describe('OAuth onboarding', () => { + beforeEach(() => { + window.localStorage.clear() + $desktopOnboarding.set(baseState()) + }) + + afterEach(() => { + window.localStorage.clear() + $desktopOnboarding.set(baseState()) + vi.restoreAllMocks() + }) + + it('clears stale readiness errors after OAuth succeeds and model confirmation is shown', async () => { + const model = 'anthropic/claude-opus-4.8' + const calls: { body?: unknown; path: string }[] = [] + + installApiMock(async ({ body, path }: { body?: unknown; path: string }) => { + calls.push({ body, path }) + + if (path === '/api/providers/oauth/nous/submit') { + return { ok: true, status: 'approved' } + } + + if (path === '/api/model/options') { + return { + providers: [ + { + name: 'Nous Portal', + slug: 'nous', + models: [model] + } + ] + } + } + + if (path.startsWith('/api/model/recommended-default?')) { + return { provider: 'nous', model, free_tier: false } + } + + if (path === '/api/model/set') { + return { ok: true, provider: 'nous', model, gateway_tools: [] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + const requestGateway: OnboardingContext['requestGateway'] = async method => { + if (method === 'reload.env') { + return {} as never + } + + if (method === 'setup.status') { + return { provider_configured: true } as never + } + + if (method === 'setup.runtime_check') { + return { ok: true } as never + } + + throw new Error(`unexpected gateway method: ${method}`) + } + + $desktopOnboarding.set( + baseState({ + flow: { + status: 'awaiting_user', + provider: provider('nous', 'Nous Portal'), + start: { + auth_url: 'https://portal.example/auth', + expires_in: 600, + flow: 'pkce', + session_id: 'portal-session' + }, + code: 'fresh-code' + }, + reason: + 'No access token found for Nous Portal login. setup.status reports configured credentials, but runtime resolution still failed.', + requested: true + }) + ) + + await submitOnboardingCode(onboardingContext(requestGateway)) + + const state = $desktopOnboarding.get() + expect(state.reason).toBeNull() + expect(state.flow.status).toBe('confirming_model') + if (state.flow.status === 'confirming_model') { + expect(state.flow.label).toBe('Nous Portal') + expect(state.flow.currentModel).toBe(model) + } + expect(calls.some(c => c.path === '/api/model/set')).toBe(true) + }) +}) + describe('saveOnboardingLocalEndpoint', () => { beforeEach(() => { window.localStorage.clear() diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 7b5202986675..6c681d4803ac 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -162,7 +162,8 @@ const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e)) const patch = (update: Partial) => $desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update }) -const setFlow = (flow: OnboardingFlow) => patch({ flow }) +const setFlow = (flow: OnboardingFlow) => + patch(flow.status === 'idle' ? { flow } : { flow, reason: null }) const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined) From 7f016f5f336093a684769f5d2413aa77e2510000 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 4 Jun 2026 23:48:02 -0400 Subject: [PATCH 021/541] change(desktop): show up to 50 models in list per provider by default --- apps/desktop/src/store/model-visibility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index b6d3d51dca6c..4f3ce744c087 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -7,7 +7,7 @@ const STORAGE_KEY = 'hermes.desktop.visible-models' /** Models shown per provider in the status-bar dropdown before the user has * customized the list. Backend `models` are already relevance-ordered. */ -export const DEFAULT_VISIBLE_PER_PROVIDER = 5 +export const DEFAULT_VISIBLE_PER_PROVIDER = 50 /** Stable key for a provider/model pair (`::` avoids colliding with model ids * that contain a single colon, e.g. `model:tag`). */ From 46c16b928823baffcf68f70e088cc1118599ad7c Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 5 Jun 2026 09:27:02 +1000 Subject: [PATCH 022/541] fix(deps): bump react-router-dom to 7.17.0 (GHSA-8x6r-g9mw-2r78) Clears the npm-audit React Router advisory CVE-2026-42342 in the web and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0 (patched in 7.15.0; both react-router and react-router-dom now resolve to 7.17.0 in the root lockfile). Note: the advisory's DoS only affects React Router *Framework Mode* (the __manifest server endpoint). Both workspaces use Declarative Mode (web: , desktop: ) as pure client-side SPAs, so we were never actually exploitable -- this is audit-hygiene only. npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds and tsc typecheck all green on 7.17.0. --- apps/desktop/package.json | 2 +- package-lock.json | 70 ++++++++++++--------------------------- web/package.json | 2 +- 3 files changed, 24 insertions(+), 50 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 372022949f32..bcb46c855a02 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -84,7 +84,7 @@ "react": "^19.2.5", "react-arborist": "^3.5.0", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.2", + "react-router-dom": "^7.17.0", "react-shiki": "^0.9.3", "remark-math": "^6.0.0", "shiki": "^4.0.2", diff --git a/package-lock.json b/package-lock.json index b8fbc0c132b2..289f64dda039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,7 +100,7 @@ "react": "^19.2.5", "react-arborist": "^3.5.0", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.2", + "react-router-dom": "^7.17.0", "react-shiki": "^0.9.3", "remark-math": "^6.0.0", "shiki": "^4.0.2", @@ -10188,6 +10188,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -17785,9 +17798,9 @@ } }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -17807,12 +17820,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", - "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.14.2" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" @@ -17822,19 +17835,6 @@ "react-dom": ">=18" } }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-shiki": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz", @@ -19701,7 +19701,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -19718,7 +19717,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -19735,7 +19733,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -19752,7 +19749,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -19769,7 +19765,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -19786,7 +19781,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -19803,7 +19797,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -19820,7 +19813,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -19837,7 +19829,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19854,7 +19845,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19871,7 +19861,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19888,7 +19877,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19905,7 +19893,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19922,7 +19909,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19939,7 +19925,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19956,7 +19941,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19973,7 +19957,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -19990,7 +19973,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20007,7 +19989,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20024,7 +20005,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20041,7 +20021,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -20058,7 +20037,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -20075,7 +20053,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -20092,7 +20069,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -20109,7 +20085,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -20126,7 +20101,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -22172,7 +22146,7 @@ "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.1", + "react-router-dom": "^7.17.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "unicode-animations": "^1.0.3" diff --git a/web/package.json b/web/package.json index 72f6dc4f8e4e..bf79b42658a8 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.1", + "react-router-dom": "^7.17.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "unicode-animations": "^1.0.3" From 6b77fd2a0f430a4e3c681e7c127da474e15d0ef4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:35:40 -0700 Subject: [PATCH 023/541] fix(nix): bump npmDepsHash for react-router 7.17.0 lockfile The react-router-dom 7.14->7.17 lockfile change stales the pinned npm-deps hash in nix/lib.nix, turning the nix flake checks red. Bump to the hash CI's prefetch diagnostic computed for the new lockfile. --- nix/lib.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib.nix b/nix/lib.nix index 9ef9b1acd1db..2373f9707a14 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0="; + npmDepsHash = "sha256-hgnqcpKRPztHhDEpwC7HJrALuJp9wsrV4+GJ6t6HI2c="; npmDeps = pkgs.fetchNpmDeps { inherit src; From 736dc0fd86bf9a8a49fa9373b252139b0ce4d20a Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 5 Jun 2026 12:03:09 +1000 Subject: [PATCH 024/541] fix(nix): use fetchNpmDeps hash for npmDepsHash, not prefetch-npm-deps The previous fix committed the hash from `prefetch-npm-deps` (sha256-hgnqc...), but the actual `fetchNpmDeps` FOD (fetcherVersion 2) that `nix flake check` builds wants sha256-cY+gM... . These two tools disagree for this lockfile, so the build's npm-deps derivation failed with a hash mismatch even though `fix-lockfiles --check` reported "ok". Corrected to the build-verified value. Confirmed `nix build .#tui`, `.#web`, and `.#desktop` all build cleanly with the new hash. --- nix/lib.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib.nix b/nix/lib.nix index 2373f9707a14..ce1445372221 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-hgnqcpKRPztHhDEpwC7HJrALuJp9wsrV4+GJ6t6HI2c="; + npmDepsHash = "sha256-cY+gM1FnTBjmld/uqt7RsqRtW9uQGs8LGokCcxu7bjQ="; npmDeps = pkgs.fetchNpmDeps { inherit src; From 7309f3bef7d7dd5d8d6aa4a98d977de7608ae7d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:03:55 -0700 Subject: [PATCH 025/541] fix(line): map inbound message types to the correct MessageType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LINE adapter classified every non-text inbound message as `MessageType.IMAGE`, which doesn't exist on the enum — so any image, video, audio, file, sticker, or location message raised AttributeError the moment it was constructed. Beyond fixing the crash, every non-text message was being collapsed onto a single type. The gateway routes on MessageType (voice → STT, files → document handling, etc.), so misclassification silently mishandled media. Replace the inline ternary with a `_LINE_MESSAGE_TYPES` lookup that maps each LINE webhook type to its proper enum member (audio → VOICE to match how Telegram/WhatsApp treat voice notes), falling back to TEXT for unknown types. Adds regression tests covering the mapping and the old AttributeError. Co-authored-by: Sahibzada Allahyar <94376830+sahibzada-allahyar@users.noreply.github.com> --- plugins/platforms/line/adapter.py | 17 +++++++++++++++- tests/gateway/test_line_plugin.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index 00663702ea16..130bb2e2c38c 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -133,6 +133,21 @@ MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video +# Map LINE webhook message types to the normalized MessageType the gateway +# routes on. LINE has no separate "voice" type — audio messages are recorded +# voice clips, so they map to VOICE (which the gateway sends through STT), +# mirroring how Telegram/WhatsApp classify voice notes. Anything unknown +# falls back to TEXT. +_LINE_MESSAGE_TYPES = { + "text": MessageType.TEXT, + "image": MessageType.PHOTO, + "video": MessageType.VIDEO, + "audio": MessageType.VOICE, + "file": MessageType.DOCUMENT, + "location": MessageType.LOCATION, + "sticker": MessageType.STICKER, +} + # A 1×1 transparent PNG used as fallback video preview thumbnail when no # explicit preview is supplied — LINE requires ``previewImageUrl`` for # video messages. Sourced from the Python stdlib (no Pillow dependency). @@ -968,7 +983,7 @@ class LineAdapter(BasePlatformAdapter): event_obj = MessageEvent( text=text, - message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE, + message_type=_LINE_MESSAGE_TYPES.get(msg_type, MessageType.TEXT), source=source_obj, raw_message=event, message_id=message_id, diff --git a/tests/gateway/test_line_plugin.py b/tests/gateway/test_line_plugin.py index 4f42c0f08da4..9d897ce7a449 100644 --- a/tests/gateway/test_line_plugin.py +++ b/tests/gateway/test_line_plugin.py @@ -641,3 +641,36 @@ class TestAdapterInit: assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm" assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group" assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel" + + +# --------------------------------------------------------------------------- +# 9. Inbound message-type classification +# --------------------------------------------------------------------------- + +class TestMessageTypeMapping: + """LINE webhook message types must map to the right normalized + MessageType so the gateway routes media correctly (e.g. voice → STT, + files → document handling). Regression guard for the old code that + referenced the non-existent ``MessageType.IMAGE`` and collapsed every + non-text message onto a single type.""" + + def test_image_event_not_attributeerror_regression(self): + # The bug: MessageType.IMAGE doesn't exist on the enum. + MessageType = _line.MessageType + assert not hasattr(MessageType, "IMAGE") + + def test_every_line_type_maps_to_correct_enum(self): + MessageType = _line.MessageType + mapping = _line._LINE_MESSAGE_TYPES + assert mapping["text"] == MessageType.TEXT + assert mapping["image"] == MessageType.PHOTO + assert mapping["video"] == MessageType.VIDEO + # LINE has no separate voice type — audio clips are voice notes. + assert mapping["audio"] == MessageType.VOICE + assert mapping["file"] == MessageType.DOCUMENT + assert mapping["location"] == MessageType.LOCATION + assert mapping["sticker"] == MessageType.STICKER + + def test_unknown_type_falls_back_to_text(self): + MessageType = _line.MessageType + assert _line._LINE_MESSAGE_TYPES.get("flex", MessageType.TEXT) == MessageType.TEXT From 899ee8c23dfd029fdfd7b669ac3ac82fd8388f55 Mon Sep 17 00:00:00 2001 From: Brian Doherty <76168809+brian-doherty@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:08:54 -0700 Subject: [PATCH 026/541] fix(gateway): tolerate non-UTF-8 status/pid files in gateway status reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_read_json_file` caught OSError but not UnicodeDecodeError, so a status file holding binary/non-UTF-8 bytes (truncated or clobbered write) would crash the gateway status path instead of being treated as unreadable. UnicodeDecodeError is a ValueError subclass, not an OSError, so it escaped the existing guard. Widen the catch to (OSError, UnicodeDecodeError) at both read sites in gateway/status.py — `_read_json_file` and the sibling `_read_pid_record`, which had the identical gap. Adds tests covering binary input (returns None) and valid input (still parses) for both. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- gateway/status.py | 10 +++++++--- tests/gateway/test_status.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 935758b904bd..8d2640af0f8f 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]: return None try: raw = path.read_text(encoding="utf-8").strip() - except OSError: + except (OSError, UnicodeDecodeError): + # OSError: file vanished or permission flipped between exists() and + # read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage + # (a truncated or clobbered status file). Either way it's unusable. return None if not raw: return None @@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]: try: raw = pid_path.read_text().strip() - except OSError: - # File was deleted between exists() and read_text(), or permission flipped. + except (OSError, UnicodeDecodeError): + # File was deleted between exists() and read_text(), permission + # flipped, or it holds non-UTF-8 / binary garbage. return None if not raw: return None diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index ae378e0b7538..bbf9d95709e3 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -1036,3 +1036,28 @@ class TestReadProcessCmdlinePsFallback: ) result = status._read_process_cmdline(12345) assert "hermes_cli/main.py" in result + + +class TestCorruptStatusFiles: + """A status / pid file holding non-UTF-8 (binary) bytes must read as + None, not crash the gateway status path with UnicodeDecodeError.""" + + def test_read_json_file_returns_none_on_binary_garbage(self, tmp_path): + p = tmp_path / "runtime.json" + p.write_bytes(b"\xff\xfe\x00\x80not utf-8\x81") + assert status._read_json_file(p) is None + + def test_read_json_file_still_parses_valid_json(self, tmp_path): + p = tmp_path / "runtime.json" + p.write_text(json.dumps({"pid": 7}), encoding="utf-8") + assert status._read_json_file(p) == {"pid": 7} + + def test_read_pid_record_returns_none_on_binary_garbage(self, tmp_path): + p = tmp_path / "gateway.pid" + p.write_bytes(b"\xff\xfe\x00\x80\x81") + assert status._read_pid_record(p) is None + + def test_read_pid_record_still_parses_bare_pid(self, tmp_path): + p = tmp_path / "gateway.pid" + p.write_text("4242", encoding="utf-8") + assert status._read_pid_record(p) == {"pid": 4242} From 439f53cab8e0e1048b1da0b1fc31aa7256888bc1 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 5 Jun 2026 10:22:26 +1000 Subject: [PATCH 027/541] fix(desktop): gate OAuth remote connect on AT-or-RT, not access token alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop OAuth remote-gateway path gated connectivity on hasOauthSessionCookie(), which checks only the access-token cookie (hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend() and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing a full IDP re-login every ~15 min — even though a valid 24h refresh-token cookie (hermes_session_rt) was sitting in the same jar. The desktop OAuth code (2026-06-04) was written against the obsolete "contract v1 issues no refresh token" model, two days after #37247 re-introduced server-side transparent refresh: Portal now issues a 24h rotating, reuse-detected refresh token, and the gateway middleware (_attempt_refresh) rotates a fresh AT from the RT on the next authenticated request. So an expired-AT/live-RT session is fully connectable — the desktop just never let the request through. Fix: - connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession() (true when EITHER a live AT or RT cookie is present). Keep cookiesHaveSession() AT-only for callers that need that specific signal. - main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth branch now early-outs only when NEITHER cookie is present, otherwise uses the ws-ticket mint as the authoritative liveness probe (that POST carries the RT cookie and triggers the server-side AT rotation). A real 401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout report against the same AT-or-RT notion. - Remove the stale "contract v1 / NO refresh token" docstrings in cookies.py and the verify_session comments in the Nous provider that contradicted #37247. Tests: +57 lines in connection-config.test.cjs covering the RT-only "still connectable" case. node --test: 32/32. dashboard-auth + nous-provider Python suites: 223/223. Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/) are comment/docstring-only here, but this touches outside apps/desktop/ so it needs Teknium review. --- apps/desktop/electron/connection-config.cjs | 48 +++++++++++- .../electron/connection-config.test.cjs | 57 +++++++++++++- apps/desktop/electron/main.cjs | 78 ++++++++++++++++--- hermes_cli/dashboard_auth/cookies.py | 51 +++++++----- plugins/dashboard_auth/nous/__init__.py | 12 +-- 5 files changed, 208 insertions(+), 38 deletions(-) diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index c10a657d1970..44ffb3d7a637 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -18,11 +18,24 @@ * this via the public `/api/status` field `auth_required: true`. */ -// Bare + prefixed variants of the access-token cookie the gateway may set, +// Bare + prefixed variants of the session cookies the gateway may set, // depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix // → __Secure-, loopback HTTP → bare). Mirrors // hermes_cli/dashboard_auth/cookies.py. +// +// Two cookies are in play (see that module): +// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its +// Max-Age tracks the access-token TTL, so the cookie jar drops it the +// instant the AT expires. +// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating, +// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie +// has lapsed but the RT cookie is still present, the gateway middleware +// transparently rotates a fresh AT on the next authenticated request +// (POST /api/auth/ws-ticket), so the session is still LIVE even with no +// AT cookie. A liveness check that looked only at the AT cookie would +// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession. const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'] +const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'] function normalizeRemoteBaseUrl(rawUrl) { const value = String(rawUrl || '').trim() @@ -150,21 +163,52 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) { } /** - * True if any cookie in `cookies` is a hermes session access-token cookie + * True if any cookie in `cookies` is a hermes session ACCESS-token cookie * with a non-empty value. `cookies` is an array of {name, value} (the shape * Electron's session.cookies.get returns). + * + * Note: this is AT-only. A session whose AT cookie has lapsed but whose RT + * cookie is still alive is STILL connectable (the gateway refreshes the AT on + * the next request) — use `cookiesHaveLiveSession` for a connectivity/display + * check. `cookiesHaveSession` remains exported for callers that specifically + * need to know whether an unexpired access token is present right now. */ function cookiesHaveSession(cookies) { if (!Array.isArray(cookies)) return false return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value) } +/** + * True if the cookie jar holds a credential that can yield an authenticated + * request — EITHER a live access-token cookie OR a refresh-token cookie. The + * RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware + * transparently rotates a fresh AT from the RT on the next authenticated + * request. Gating connectivity on the AT alone would force a full IDP + * re-login every ~15 min even though a valid 24h RT is sitting in the jar. + * + * This answers "should we even attempt to connect / show as signed in?", not + * "is the access token unexpired?". The authoritative liveness check is still + * the actual ws-ticket mint at connect time (which surfaces a true 401 when + * the RT is also dead/revoked). + */ +function cookiesHaveLiveSession(cookies) { + if (!Array.isArray(cookies)) return false + return cookies.some( + c => + c && + c.value && + (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)) + ) +} + module.exports = { AT_COOKIE_VARIANTS, + RT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, cookiesHaveSession, + cookiesHaveLiveSession, normalizeRemoteBaseUrl, resolveAuthMode, resolveTestWsUrl, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 33f81f3b66ed..6819e6407e12 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -15,10 +15,12 @@ const assert = require('node:assert/strict') const { AT_COOKIE_VARIANTS, + RT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, cookiesHaveSession, + cookiesHaveLiveSession, normalizeRemoteBaseUrl, resolveAuthMode, resolveTestWsUrl, @@ -131,7 +133,10 @@ test('cookiesHaveSession is false for an empty value', () => { assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false) }) -test('cookiesHaveSession ignores unrelated cookies', () => { +test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => { + // cookiesHaveSession is deliberately access-token-only — a lone RT cookie + // is NOT an access token, so this returns false. Connectivity callers must + // use cookiesHaveLiveSession instead (see below). assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false) assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false) }) @@ -146,6 +151,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => { assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']) }) +test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => { + assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']) +}) + +// --- cookiesHaveLiveSession (AT or RT — the connectivity check) --- + +test('cookiesHaveLiveSession is true for a live access-token cookie', () => { + assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true) + assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true) + assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true) +}) + +test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => { + // This is the bug-fix case: the AT cookie has lapsed (dropped from the jar) + // but the 24h RT cookie is still alive. The session is still connectable — + // the gateway rotates a fresh AT from the RT on the next request. + assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true) + assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true) + assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true) +}) + +test('cookiesHaveLiveSession is true when both AT and RT are present', () => { + assert.equal( + cookiesHaveLiveSession([ + { name: 'hermes_session_at', value: 'a' }, + { name: 'hermes_session_rt', value: 'r' } + ]), + true + ) +}) + +test('cookiesHaveLiveSession is false for empty values', () => { + assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false) + assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false) + assert.equal( + cookiesHaveLiveSession([ + { name: 'hermes_session_at', value: '' }, + { name: 'hermes_session_rt', value: '' } + ]), + false + ) +}) + +test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => { + assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false) + assert.equal(cookiesHaveLiveSession(null), false) + assert.equal(cookiesHaveLiveSession(undefined), false) + assert.equal(cookiesHaveLiveSession([]), false) +}) + // --- tokenPreview --- test('tokenPreview returns null for empty', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 0a877719e3a0..a78260b81d5d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -33,6 +33,7 @@ const { buildGatewayWsUrl, buildGatewayWsUrlWithTicket, cookiesHaveSession, + cookiesHaveLiveSession, normalizeRemoteBaseUrl, resolveAuthMode, resolveTestWsUrl, @@ -3167,8 +3168,16 @@ function installMediaPermissions() { // * WebSocket upgrades require a single-use ``?ticket=`` minted at // ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=`` // path is unconditionally rejected by gated gateways. -// * Nous Portal contract v1 issues NO refresh token; the access cookie has -// a ~15-min TTL. On 401 we must re-run the login round trip. +// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token +// alongside the ~15-min access token (Portal NAS #293 / hermes #37247). +// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min, +// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie +// is still alive, the gateway middleware transparently rotates a fresh AT +// on the next authenticated request — so connectivity must NOT be gated on +// the AT cookie alone. We probe liveness by actually minting a ws-ticket +// (which triggers that server-side refresh) and treat a real 401 as +// "needs re-login"; the AT-or-RT cookie presence check is only a cheap +// "is the user signed in at all?" gate / display signal. // --------------------------------------------------------------------------- const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' @@ -3179,8 +3188,9 @@ function getOauthSession() { return oauthSession } -// Bare + prefixed variants of the access-token cookie live in -// connection-config.cjs (cookiesHaveSession). See that module for details. +// Bare + prefixed variants of the session cookies live in +// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See +// that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() @@ -3201,6 +3211,30 @@ async function hasOauthSessionCookie(baseUrl) { } } +// Like hasOauthSessionCookie, but returns true when EITHER a live access-token +// cookie OR a (longer-lived) refresh-token cookie is present. This is the right +// "is the user signed in at all?" check: an expired AT with a live RT is still +// a connectable session because the gateway rotates a fresh AT server-side on +// the next authenticated request. Gating on the AT alone forces a needless full +// re-login every ~15 min. Used for the Settings "connected" indicator and as a +// cheap early-out before attempting a network round-trip in resolveRemoteBackend. +async function hasLiveOauthSession(baseUrl) { + const sess = getOauthSession() + if (!sess) return false + const parsed = new URL(baseUrl) + try { + const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveLiveSession(cookies) + } catch { + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveLiveSession(cookies) + } catch { + return false + } + } +} + async function clearOauthSession(baseUrl) { const sess = getOauthSession() if (!sess) return @@ -3536,7 +3570,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon let remoteOauthConnected = false if (authMode === 'oauth' && remoteUrl) { try { - remoteOauthConnected = await hasOauthSessionCookie(remoteUrl) + // Display signal: treat a live RT cookie as "connected" even if the AT + // cookie has lapsed — the gateway refreshes the AT on the next request, + // so the session is still usable. The authoritative liveness check is + // the ws-ticket mint in resolveRemoteBackend at actual connect time. + remoteOauthConnected = await hasLiveOauthSession(remoteUrl) } catch { remoteOauthConnected = false } @@ -3621,11 +3659,22 @@ async function resolveRemoteBackend() { const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' if (authMode === 'oauth') { - // OAuth gateway: auth comes from the session cookie in the OAuth partition. - // Verify the cookie is present, then mint a single-use WS ticket (the - // gateway rejects ?token= in gated mode). A missing cookie / 401 means the - // user needs to (re-)log in via Settings → Gateway. - if (!(await hasOauthSessionCookie(baseUrl))) { + // OAuth gateway: auth comes from the session cookies in the OAuth + // partition. Liveness is NOT "is the access-token cookie present?" — + // Portal issues a 24h rotating refresh token (hermes #37247), and the + // gateway middleware transparently rotates a fresh ~15-min access token + // from it on the next authenticated request. So a session with an expired + // AT cookie but a live RT cookie is still perfectly connectable. + // + // We therefore: + // 1. cheap early-out ONLY when the jar holds NEITHER an AT nor an RT + // cookie — a genuinely signed-out user — to avoid a pointless network + // round-trip and give a clear "sign in" message. + // 2. otherwise probe liveness by actually minting a ws-ticket. That POST + // carries the cookie jar (incl. the RT cookie); the gateway refreshes + // the AT server-side and returns a ticket. A real 401 here means the + // RT is also dead/revoked → genuine re-login needed. + if (!(await hasLiveOauthSession(baseUrl))) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' @@ -3636,6 +3685,10 @@ async function resolveRemoteBackend() { let ticket try { + // This mint is the authoritative liveness check. If only the RT cookie + // is alive, the gateway rotates a fresh AT cookie back onto the partition + // via Set-Cookie (Electron's persistent session absorbs it), so the very + // next request is already re-authed — no user-visible re-login. ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( @@ -4388,7 +4441,10 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) - return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false } + // Report against the SAME liveness notion the Settings indicator uses + // (AT-or-RT) so a logout that left any session cookie behind is reflected + // as still-connected rather than silently signed-out. + return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false } }) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index f8fc77f24269..90c7cf34d19f 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -2,13 +2,18 @@ Three cookies in play: - hermes_session_at: the OAuth access token - (HttpOnly, lifetime = token TTL) + (HttpOnly, lifetime = token TTL, ~15 min) - hermes_session_rt: the OAuth refresh token - (HttpOnly, lifetime = 30 days) - **DEPRECATED in OAuth contract v1** — Nous Portal - does not issue refresh tokens; we keep the cookie - name and clear semantics for forward compatibility - and to flush stale cookies from old browsers. + (HttpOnly, lifetime = 24h, ROTATING + reuse-detected) + Nous Portal issues a rotating refresh token for the + dashboard auth-code grant (Portal NAS #293 / hermes + #37247). ``set_session_cookies`` writes this cookie + whenever the provider returns a non-empty + ``refresh_token``; the middleware uses it to rotate a + fresh access token transparently on AT expiry. A + provider that omits the refresh token (empty string) + degrades gracefully to access-token-only sessions — + the RT cookie is simply not written. - hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider hint (HttpOnly, lifetime = 10 minutes) @@ -39,13 +44,15 @@ The setters and readers BOTH consult the active prefix because the cookie *name* changes — a reader that looked up the bare name when the setter wrote ``__Secure-hermes_session_at`` would never find the value. -.. deprecated:: contract v1 - ``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1 - default) and silently skips writing the RT cookie in that case. - ``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT - cookie so users carrying a stale cookie from an earlier deployment get - it cleared on logout / session expiry. The full refresh-flow machinery - was rewritten as "401 → redirect to /login" in Phase 6. +Refresh-token handling: + ``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted + it) and silently skips writing the RT cookie in that case, so a + refresh-token-less provider degrades to access-token-only sessions. + ``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT + cookie on logout / session expiry so a stale cookie from an earlier + deployment gets cleared. The transparent rotation flow ("expired AT + + live RT → rotate server-side, else 401 → /login") lives in + ``middleware._attempt_refresh``. """ from __future__ import annotations @@ -66,7 +73,13 @@ PKCE_COOKIE = "hermes_session_pkce" # practice — a single request emits exactly one variant). _NAME_VARIANTS = ("__Host-", "__Secure-", "") -# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS +# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's +# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the +# real authority — once the RT itself expires/rotates out, a refresh attempt +# returns 400 → RefreshExpiredError → clean re-login, regardless of how long +# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie +# lifetime to a server-side TTL that can change independently; revisit if the +# stale-cookie refresh churn ever matters.) _RT_MAX_AGE = 30 * 24 * 60 * 60 _PKCE_MAX_AGE = 10 * 60 @@ -126,11 +139,11 @@ def set_session_cookies( ``access_token_expires_in`` is in seconds. Use the provider's reported TTL for the access token. - ``refresh_token`` is accepted for backward / forward compatibility but - SKIPPED when empty — Nous Portal contract v1 issues no refresh tokens - so a ``Session.refresh_token == ""`` from the provider means we don't - persist anything. If a future contract revision starts emitting refresh - tokens, this helper will write the RT cookie again with no other change. + ``refresh_token`` is written as the RT cookie when non-empty. Nous Portal + issues a 24h rotating refresh token (hermes #37247); a provider that + omits it returns ``Session.refresh_token == ""`` and we simply don't + persist the RT cookie — the session then behaves as access-token-only + until the AT expires. No other branch changes between the two cases. ``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``) or ``""`` for a direct deploy. It influences both the cookie name diff --git a/plugins/dashboard_auth/nous/__init__.py b/plugins/dashboard_auth/nous/__init__.py index 4133b18f59c1..480617916daf 100644 --- a/plugins/dashboard_auth/nous/__init__.py +++ b/plugins/dashboard_auth/nous/__init__.py @@ -348,9 +348,10 @@ class NousDashboardAuthProvider(DashboardAuthProvider): def verify_session(self, *, access_token: str) -> Optional[Session]: - # Contract: returns None on expiry/invalidity (middleware then - # triggers redirect-to-login since refresh_session can never succeed - # under V1); raises ProviderError if the IDP is unreachable. + # Contract: returns None on expiry/invalidity (the middleware then + # tries refresh_session with the RT cookie, falling back to + # redirect-to-login if that also fails); raises ProviderError if the + # IDP is unreachable. try: claims = self._verify_jwt(access_token) except InvalidCodeError: @@ -359,8 +360,9 @@ class NousDashboardAuthProvider(DashboardAuthProvider): except ProviderError: # JWKS unreachable, etc. Bubble up so middleware emits 503. raise - # verify_session has no access to the original refresh_token; pass - # "" because in contract V1 there is none anyway. + # verify_session validates the AT in isolation and has no access to the + # refresh token (it lives in a separate cookie the middleware reads); + # pass "" here — the RT-driven rotation path is middleware's job. return self._session_from_claims(access_token, "", claims) def revoke_session(self, *, refresh_token: str) -> None: From b1e399de95894643c4d67105cd3bd84746c211b8 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 5 Jun 2026 15:37:19 +1000 Subject: [PATCH 028/541] fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the published Docker image, both the `--tui` banner and the dashboard-embedded TUI report `1 commit behind — run docker pull nousresearch/hermes-agent:latest to update` even though the container has no git repo and no way to compute a commit delta. Root cause: two independent update-detection paths, only one of which knows it's running in Docker. - `recommended_update_command()` → `detect_install_method()` reads the `.install_method` stamp that `docker/stage2-hook.sh` writes at boot → returns "docker", so the *command string* correctly says `docker pull`. - `banner.check_for_updates()` (the source of the "N commits behind" *count*) has no notion of the docker install method. It only detects a build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git` dir (excluded from the image by .dockerignore). Neither matches, so it silently falls through to `check_via_pypi()`, whose PyPI-version mismatch flag (1) is then rendered verbatim by the CLI banner (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes version` as "1 commit behind" — a phantom count, no commit math involved. `hermes update` already refuses to run in-place in the container. The dashboard's REST `/api/hermes/update/check` endpoint already short-circuits docker (returns behind=None + the docker guidance). This mirrors that guard inside `check_for_updates()` so the banner/TUI/version surfaces agree: when `detect_install_method() == "docker"`, return None before any git/pypi probe (and before writing a cache entry). None makes the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`) stay false, so the badge/line disappears entirely — matching the System page. Fix is in one place (check_for_updates) because all three consumers route through it via get_update_result()/_update_result. Tests: test_check_for_updates_docker_returns_none asserts None + no git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks guards against over-broadening (pip still version-checks). Mutation-tested: removing the guard fails the docker test. Verified against a real `docker build` of the image — see PR description. --- hermes_cli/banner.py | 19 +++++++++ tests/hermes_cli/test_update_check.py | 56 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index dec5aecdb8cf..1955b009df25 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -225,6 +225,25 @@ def check_for_updates() -> Optional[int]: cache_file = hermes_home / ".update_check" embedded_rev = os.environ.get("HERMES_REVISION") or None + # Docker images have no working tree to count commits against — the + # published image excludes `.git` (see .dockerignore) and sets no + # HERMES_REVISION (that's nix-only). Without this guard the checks below + # fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1) + # then gets rendered by the CLI banner and the TUI badge as a phantom + # "1 commit behind" — even though no git repo or commit math is involved, + # and `hermes update` correctly refuses to run in-place inside the + # container anyway. The dashboard's REST `/api/hermes/update/check` + # endpoint already short-circuits docker the same way (web_server.py); + # mirror that here so the banner/TUI surfaces agree. Returning None makes + # both the Rich banner (build_welcome_banner) and the Ink badge + # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. + try: + from hermes_cli.config import detect_install_method + if detect_install_method() == "docker": + return None + except Exception: + pass + # Read cache — invalidate if the embedded rev OR installed version has # changed since the last check. The version guard matters for pip installs: # `check_via_pypi()` compares against VERSION, so a `pip install --upgrade` diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index c7cc1c867241..47c018cbbdb8 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -131,6 +131,62 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch): assert mock_run.call_count >= 1 +def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch): + """Inside the Docker image, check_for_updates() must short-circuit to None. + + Regression: the published image excludes .git (.dockerignore) and sets no + HERMES_REVISION (nix-only), so without a docker guard check_for_updates() + falls through to check_via_pypi(), whose version-mismatch flag (1) gets + rendered by both the Rich banner and the Ink TUI badge as a phantom + "1 commit behind" — despite there being no git repo or commit math in the + container, and `hermes update` correctly refusing to run there. The guard + must return None (so the > 0 render guards stay false) AND not reach the + git/pypi probes or write a cache entry. + """ + import hermes_cli.banner as banner + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cache_file = tmp_path / ".update_check" + + with patch("hermes_cli.config.detect_install_method", return_value="docker"), \ + patch("hermes_cli.banner.subprocess.run") as mock_run, \ + patch("hermes_cli.banner.check_via_pypi") as mock_pypi: + result = banner.check_for_updates() + + assert result is None + # Neither the git probe nor the PyPI probe should have run. + mock_run.assert_not_called() + mock_pypi.assert_not_called() + # And no phantom "behind" count should be cached for the next 6h. + assert not cache_file.exists() + + +def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch): + """The docker guard must NOT over-broaden: a pip install still version-checks. + + Invariant guarding against the guard firing for non-docker methods — pip + installs legitimately reach check_via_pypi() and surface a real update. + """ + import hermes_cli.banner as banner + + # No local git checkout -> the PyPI (pip-install) path is exercised. + fake_banner = tmp_path / "hermes_cli" / "banner.py" + fake_banner.parent.mkdir(parents=True, exist_ok=True) + fake_banner.touch() + monkeypatch.setattr(banner, "__file__", str(fake_banner)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_REVISION", raising=False) + + with patch("hermes_cli.config.detect_install_method", return_value="pip"), \ + patch("hermes_cli.banner.subprocess.run") as mock_run, \ + patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi: + result = banner.check_for_updates() + + assert result == 1 + mock_pypi.assert_called_once() + mock_run.assert_not_called() + + def test_prefetch_non_blocking(): """prefetch_update_check() should return immediately without blocking.""" import hermes_cli.banner as banner From ad69d3edc7359310233fa789b300bfcc5a5c3f7a Mon Sep 17 00:00:00 2001 From: Baris Sencan <988213+isair@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:10:48 -0700 Subject: [PATCH 029/541] fix(terminal): guard os.getcwd() against a deleted CWD `os.getcwd()` raises FileNotFoundError when the process's working directory was removed out from under it (e.g. a scratch workspace cleaned up mid-session), crashing terminal env setup. Extract a `_safe_getcwd()` helper that falls back to TERMINAL_CWD, then the user's home, on FileNotFoundError, and route all three `os.getcwd()` call sites in terminal_tool.py through it (local default_cwd, the Docker cwd-passthrough source, and the debug-config print) so the same crash can't resurface at a sibling site. Adds unit tests for the real-cwd path and both fallback branches. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- tests/tools/test_terminal_task_cwd.py | 24 ++++++++++++++++++++++++ tools/terminal_tool.py | 20 +++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_terminal_task_cwd.py b/tests/tools/test_terminal_task_cwd.py index 1947836fb476..b49e8e1e6fae 100644 --- a/tests/tools/test_terminal_task_cwd.py +++ b/tests/tools/test_terminal_task_cwd.py @@ -218,3 +218,27 @@ def test_registering_non_cwd_override_leaves_live_env_cwd_untouched(monkeypatch) terminal_tool.register_task_env_overrides(task_id, {"modal_image": "custom:latest"}) assert fake_env.cwd == "/workspace/keep" + + +def test_safe_getcwd_returns_real_cwd(monkeypatch): + monkeypatch.setattr(terminal_tool.os, "getcwd", lambda: "/home/user/project") + assert terminal_tool._safe_getcwd() == "/home/user/project" + + +def test_safe_getcwd_falls_back_to_terminal_cwd_when_cwd_deleted(monkeypatch): + def _boom(): + raise FileNotFoundError("[Errno 2] No such file or directory") + + monkeypatch.setattr(terminal_tool.os, "getcwd", _boom) + monkeypatch.setenv("TERMINAL_CWD", "/srv/work") + assert terminal_tool._safe_getcwd() == "/srv/work" + + +def test_safe_getcwd_falls_back_to_home_when_no_terminal_cwd(monkeypatch): + def _boom(): + raise FileNotFoundError() + + monkeypatch.setattr(terminal_tool.os, "getcwd", _boom) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.setattr(terminal_tool.os.path, "expanduser", lambda p: "/home/me") + assert terminal_tool._safe_getcwd() == "/home/me" diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 14577b9bd8d2..3e81eff9f676 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1030,6 +1030,20 @@ def _parse_env_var(name: str, default: str, converter=int, type_label: str = "in ) +def _safe_getcwd() -> str: + """Return the current working directory, tolerating a deleted CWD. + + ``os.getcwd()`` raises FileNotFoundError when the process's working + directory has been removed out from under it (e.g. a scratch workspace + that was cleaned up mid-session). Fall back to TERMINAL_CWD, then the + user's home directory, so terminal setup never crashes on a stale CWD. + """ + try: + return os.getcwd() + except FileNotFoundError: + return os.getenv("TERMINAL_CWD") or os.path.expanduser("~") + + def _get_env_config() -> Dict[str, Any]: """Get terminal environment configuration from environment variables.""" # Default image with Python and Node.js for maximum compatibility @@ -1042,7 +1056,7 @@ def _get_env_config() -> Dict[str, Any]: # remote home, and everything else starts in the backend's default # root-like cwd. if env_type == "local": - default_cwd = os.getcwd() + default_cwd = _safe_getcwd() elif env_type == "ssh": default_cwd = "~" else: @@ -1058,7 +1072,7 @@ def _get_env_config() -> Dict[str, Any]: host_cwd = None host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") if env_type == "docker" and mount_docker_cwd: - docker_cwd_source = os.getenv("TERMINAL_CWD") or os.getcwd() + docker_cwd_source = os.getenv("TERMINAL_CWD") or _safe_getcwd() candidate = os.path.abspath(os.path.expanduser(docker_cwd_source)) if ( any(candidate.startswith(p) for p in host_prefixes) @@ -2516,7 +2530,7 @@ if __name__ == "__main__": print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}") print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}") - print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}") + print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}") from hermes_constants import display_hermes_home as _dhh print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}") From c8e80cd0bfdbbfa0b14296ef59a1c3d353917add Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:33:16 -0600 Subject: [PATCH 030/541] fix(update): require managed marker before destructive clean --- hermes_cli/main.py | 65 +++++++++++++++-------- tests/hermes_cli/test_update_autostash.py | 48 +++++++++++++++-- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 08e610d177f8..f2df6e778d13 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8149,21 +8149,21 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool: - """Discard working-tree dirt on a managed (non-fork) clone. + """Discard working-tree dirt on an explicitly managed checkout. - On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or - ~/.hermes/hermes-agent) the user never edits the source tree, so any - "dirty" state is pure git artifact: CRLF renormalization, npm lockfile - churn, or files left behind when a directory was deleted upstream (e.g. - apps/bootstrap-installer/). Stashing that dirt and re-applying it after a - pull is actively dangerous — the stash/restore cycle has been observed to - clobber freshly-pulled source files (apps/desktop/ deletion → + On a Desktop/bootstrap-managed install the user never edits the source + tree, so any "dirty" state is pure git artifact: CRLF renormalization, npm + lockfile churn, or files left behind when a directory was deleted upstream + (e.g. apps/bootstrap-installer/). Stashing that dirt and re-applying it + after a pull is actively dangerous — the stash/restore cycle has been + observed to clobber freshly-pulled source files (apps/desktop/ deletion → "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html"). - For a managed clone the correct move is to throw the dirt away with - ``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's - update path), NOT preserve it. Forks keep the stash machinery because - their local edits are intentional. + For an explicitly managed checkout the correct move is to throw the dirt + away with ``git reset --hard HEAD`` + ``git clean -fd`` (mirroring + install.ps1's update path), NOT preserve it. Ordinary source checkouts, + including upstream-origin checkouts, keep the stash machinery because + their local edits may be intentional. Returns True if the tree was cleaned (or was already clean), False on a git failure (caller should fall back to the stash path). @@ -8353,6 +8353,7 @@ OFFICIAL_REPO_URLS = { } OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" +MANAGED_CHECKOUT_MARKERS = (".hermes-bootstrap-complete",) def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: @@ -8388,6 +8389,19 @@ def _is_fork(origin_url: Optional[str]) -> bool: return True +def _is_managed_update_checkout(origin_url: Optional[str], cwd: Path) -> bool: + """Return True when this official checkout is safe to clean destructively. + + The destructive clean path is only safe for checkouts Hermes explicitly + owns. An official ``origin`` alone is not enough proof: contributors can + also work from upstream-origin source checkouts with intentional local + files. + """ + if not origin_url or _is_fork(origin_url): + return False + return any((cwd / marker).is_file() for marker in MANAGED_CHECKOUT_MARKERS) + + def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: """Check if an 'upstream' remote already exists.""" try: @@ -10227,9 +10241,12 @@ def _cmd_update_impl(args, gateway_mode: bool): # lockfile churn) update with a clean tree. _discard_lockfile_churn(git_cmd, PROJECT_ROOT) - # Detect if we're updating from a fork (before any branch logic) + # Detect if we're updating from a fork, and whether this official-origin + # checkout has an explicit Hermes-owned marker that makes destructive + # worktree cleanup safe. origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) + is_managed_checkout = _is_managed_update_checkout(origin_url, PROJECT_ROOT) if is_fork: print("⚠ Updating from fork:") @@ -10295,11 +10312,12 @@ def _cmd_update_impl(args, gateway_mode: bool): else f"branch '{current_branch}'" ) print(f" ⚠ Currently on {label} — switching to {branch} for update...") - # Stash before checkout so uncommitted work isn't lost — but on a - # managed (non-fork) clone there's nothing to preserve, so discard - # git-artifact dirt instead (a dirty tree would otherwise block the - # checkout). Forks keep the stash so their edits survive. - if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT): + # Stash before checkout so uncommitted work isn't lost — but on an + # explicitly managed checkout there's nothing to preserve, so + # discard git-artifact dirt instead (a dirty tree would otherwise + # block the checkout). Other checkouts keep the stash so their + # edits survive. + if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): auto_stash_ref = None else: auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) @@ -10336,13 +10354,14 @@ def _cmd_update_impl(args, gateway_mode: bool): print(f" {track_result.stderr.strip().splitlines()[0]}") sys.exit(1) else: - # On a managed (non-fork) clone the user never edits the source - # tree, so any dirt is git artifact (CRLF, lockfile churn, + # On an explicitly managed checkout the user never edits the + # source tree, so any dirt is git artifact (CRLF, lockfile churn, # upstream-deleted dirs). Throw it away rather than stash/restore # it — the stash/restore cycle has clobbered freshly-pulled source - # (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall - # through to the stash path so their intentional edits survive. - if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT): + # (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Other + # checkouts fall through to the stash path so their intentional + # edits survive. + if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): auto_stash_ref = None else: auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 6cd08a439db9..3ba19965591f 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -635,15 +635,16 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path): - """On a non-fork (managed) clone, working-tree dirt is discarded via + """On an explicitly managed clone, working-tree dirt is discarded via _clean_managed_worktree, NOT preserved via stash/restore. The stash/restore cycle has clobbered freshly-pulled source files - (apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A managed clone - has nothing the user authored, so the correct move is to throw the - git-artifact dirt away and pull cleanly. + (apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A checkout with + the Desktop/bootstrap marker has nothing the user authored, so the correct + move is to throw the git-artifact dirt away and pull cleanly. """ _setup_update_mocks(monkeypatch, tmp_path) + (tmp_path / ".hermes-bootstrap-complete").write_text("{}", encoding="utf-8") monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) # Official origin → not a fork. monkeypatch.setattr( @@ -677,6 +678,45 @@ def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_pa assert len(restore_calls) == 0 +def test_cmd_update_official_checkout_without_managed_marker_stashes(monkeypatch, tmp_path): + """An upstream-origin source checkout is not safe to clean destructively + unless Hermes wrote an explicit managed-checkout marker.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr( + hermes_main, + "_get_origin_url", + lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git", + ) + clean_calls = [] + monkeypatch.setattr( + hermes_main, + "_clean_managed_worktree", + lambda *a, **kw: clean_calls.append(1) or True, + ) + stash_calls = [] + monkeypatch.setattr( + hermes_main, + "_stash_local_changes_if_needed", + lambda *a, **kw: stash_calls.append(1) or "abc123", + ) + restore_calls = [] + monkeypatch.setattr( + hermes_main, + "_restore_stashed_changes", + lambda *a, **kw: restore_calls.append(1) or True, + ) + + side_effect, _ = _make_update_side_effect(commit_count="0") + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace()) + + assert len(clean_calls) == 0 + assert len(stash_calls) == 1 + assert len(restore_calls) == 1 + + def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path): """A fork (non-official origin) keeps the stash machinery so the user's intentional local edits survive the update.""" From b0d234f068952e7bc198759ae3ca2cda99bae491 Mon Sep 17 00:00:00 2001 From: Acean <27935764+liuboacean@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:13:11 -0700 Subject: [PATCH 031/541] fix(cron): don't crash on `cron list` when a job's repeat is null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cron_list` read `job.get("repeat", {})`, but the dict-default only applies to a MISSING key. A one-shot job persisted with `"repeat": null` returns None, and the next `.get("times")` raised AttributeError, taking down the whole `cron list` output. Coalesce with `or {}` so a present-but-null repeat renders as ∞ like the other cron readers already do. Adds a regression test. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- hermes_cli/cron.py | 5 ++++- tests/hermes_cli/test_cron.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index fa0acd819886..683fc73fb73d 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -81,7 +81,10 @@ def cron_list(show_all: bool = False): state = job.get("state", "scheduled" if job.get("enabled", True) else "paused") next_run = job.get("next_run_at", "?") - repeat_info = job.get("repeat", {}) + # `repeat` may be present-but-null in the job record (e.g. a one-shot + # job persisted with "repeat": null), so coalesce to {} rather than + # relying on the dict-default, which only applies to a missing key. + repeat_info = job.get("repeat") or {} repeat_times = repeat_info.get("times") repeat_completed = repeat_info.get("completed", 0) repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞" diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 49628f1a438d..aa4f6b116f14 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -111,3 +111,19 @@ class TestCronCommandLifecycle: assert jobs[0]["skills"] == ["blogwatcher", "maps"] assert jobs[0]["name"] == "Skill combo" assert jobs[0]["profile"] == "default" + + def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys): + """A one-shot job can be persisted with ``"repeat": null``. `cron + list` must render it as ∞ rather than crashing on .get(...)\\.get.""" + from cron.jobs import load_jobs, save_jobs + + create_job(prompt="One shot", schedule="every 1h") + # Force the present-but-null shape that .get("repeat", {}) mishandles. + jobs = load_jobs() + jobs[0]["repeat"] = None + save_jobs(jobs) + + cron_command(Namespace(cron_command="list", all=True)) + + out = capsys.readouterr().out + assert "Repeat: ∞" in out From 9ab9c923da8a610b259631c77206bc65dac9988d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 5 Jun 2026 18:34:19 +1000 Subject: [PATCH 032/541] docs(dashboard): clarify auth provider suitability + registration across dashboard/Docker/Desktop docs (#39633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(dashboard): clarify auth provider suitability + document dashboard registration - Add a 'Registering a dashboard' subsection under the Nous Research provider covering both the 'hermes dashboard register' CLI command and the Portal /local-dashboards GUI page. - Note that the Nous provider is the one suitable for public-internet exposure (logins verified against your Nous account). - Add a warning that the username/password provider is for trusted networks / VPN only and is not suitable for direct public-internet exposure; point readers to the Nous / OIDC / custom OAuth providers. - Surface the same distinction in the two-provider intro list. * docs(dashboard): count three bundled auth providers, add self-hosted OIDC to intro 'Two providers ship in the box' undercounted — the bundled plugins/dashboard_auth/self_hosted (generic OpenID Connect) is a third. List all three in the gated-mode intro and link each to its section. * docs(dashboard): extend auth provider updates to Docker and Desktop pages - docker.md: list all three bundled gate providers (was username/password + OAuth only), adding the self-hosted OIDC provider and its env vars, and note username/password is not for public-internet exposure. - desktop.md: reframe the remote-backend connection so OAuth (Nous Portal) is the preferred option for any backend reachable beyond the local machine, with username/password positioned for local / trusted-network use only. Cover the 'Sign in with ' OAuth flow in the in-app steps and scope the VPN warning to the password path. * docs(dashboard): align env-var, CLI, and remote-Desktop recipe with provider changes - environment-variables.md: reframe the Web Dashboard & Hermes Desktop intro (OAuth preferred for remote/public, username/password for trusted networks), add the self-hosted OIDC env vars (HERMES_DASHBOARD_OIDC_*) that were missing from the table, and note hermes dashboard register provisions the OAuth client_id. - cli-commands.md: document the 'hermes dashboard register' subcommand (flags, behavior, /local-dashboards GUI alternative). - web-dashboard.md: apply the OAuth-preferred reframe to the bottom 'Connecting Hermes Desktop to a remote backend' recipe and scope its VPN warning to the username/password path, matching desktop.md. * docs(dashboard): move 'recommended remote Desktop path' framing from username/password to OAuth The gated-mode intro list claimed the username/password provider was the recommended path for a remote Hermes Desktop connection, contradicting the OAuth-preferred framing established elsewhere. Move that recommendation onto the OAuth (Nous Portal) item so the docs are consistent: OAuth is the recommended provider for any remote/internet-facing backend; username/password is for trusted networks only. * docs(dashboard): drop unreleased managed/hosted-install provisioning notes Remove the 'not available in managed/hosted installs, where the client id is provisioned by the hosting platform' line from the dashboard register docs (web-dashboard.md, cli-commands.md) and the 'provisioned by the Nous Portal for hosted deploys' clause from the HERMES_DASHBOARD_OAUTH_CLIENT_ID env-var row — that platform-provisioning path is unreleased. * docs(dashboard): drop --portal-url / HERMES_DASHBOARD_PORTAL_URL from user docs The portal-URL override targets a non-production Nous Portal and only works for internal Nous usage — it won't function for end users (the access token must be issued by the same portal). Remove it from the register CLI flags, the Nous-provider config/env tables, and the verify-the-gate example so users aren't pointed at an option that can't work for them. * docs(dashboard): add worked examples for Nous and username/password providers The self-hosted OIDC provider already had a full 'Worked example: Keycloak' walkthrough; the Nous and username/password providers only had scattered config snippets. Add parallel '#### Worked example' sections for both (register/run/login + /api/status verification), mirroring the Keycloak example's structure so all three bundled providers read consistently. * docs(env): move HERMES_DESKTOP_REMOTE_URL to end of the dashboard auth table It was sitting between the HERMES_DASHBOARD_BASIC_AUTH_* block and the HERMES_DASHBOARD_OAUTH/OIDC block, splitting the dashboard-side vars. As the only desktop-side var in the table, it belongs at the end so the dashboard provider vars (basic, OAuth, OIDC) stay grouped together. * docs(dashboard): remove Fly.io references from dashboard auth docs Fly.io is the internal hosting implementation for hosted Hermes — it shouldn't leak into user-facing dashboard auth docs. Reword the OAuth provider intro, the env-var-path rationale, the public-URL-override section, the cookie Secure note, and the verify-the-gate example to generic 'hosting platform' / 'reverse proxy' / 'TLS terminator' phrasing. Left the legitimate user-facing Fly.io mentions in telegram.md (a deliberate cloud-deployment walkthrough) and work-with-skills.md (a generic example) untouched. --- website/docs/reference/cli-commands.md | 16 +++ .../docs/reference/environment-variables.md | 12 +- website/docs/user-guide/desktop.md | 15 ++- website/docs/user-guide/docker.md | 8 +- .../docs/user-guide/features/web-dashboard.md | 115 +++++++++++++++--- 5 files changed, 140 insertions(+), 26 deletions(-) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 945dd73d59df..12e3c6a84d52 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1361,6 +1361,22 @@ hermes dashboard hermes dashboard --port 8080 --no-open ``` +### `hermes dashboard register` + +Register this install as a self-hosted dashboard with your Nous Portal account, so the dashboard's OAuth (Nous) auth gate can be used. Resolves your existing Nous login (run `hermes setup` first if you're not logged in), creates an OAuth client, writes `HERMES_DASHBOARD_OAUTH_CLIENT_ID` into `~/.hermes/.env`, and prints how to engage the login gate. You can also register, name, and revoke dashboards from the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page. + +| Option | Default | Description | +|--------|---------|-------------| +| `--name` | auto-generated | Human-readable label for the dashboard | +| `--redirect-uri` | — | Public HTTPS OAuth redirect URI for an internet-facing host, e.g. `https://hermes.example.com/auth/callback`. Omit for localhost-only use. | + +```bash +hermes dashboard register +# ✓ Registered dashboard "swift_falcon" +# …writes HERMES_DASHBOARD_OAUTH_CLIENT_ID to ~/.hermes/.env +``` + + ## `hermes profile` ```bash diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 35429050db5d..011fa3bcf79b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -418,9 +418,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI ### Web Dashboard & Hermes Desktop -Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id`/`portal_url` are better set under `dashboard.oauth` in `config.yaml` (env wins when set). +Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id` is better set under `dashboard.oauth` in `config.yaml` (env wins when set). -The recommended way to expose a dashboard for a remote Hermes Desktop connection is the bundled **username/password** provider: set the `HERMES_DASHBOARD_BASIC_AUTH_*` vars below and run `hermes dashboard --host 0.0.0.0`. The non-loopback bind engages the auth gate, and Desktop signs in with the username and password. +Three dashboard-auth providers ship in the box. For a remote Hermes Desktop connection or any internet-facing dashboard, the recommended provider is **OAuth (Nous Portal)** — set `HERMES_DASHBOARD_OAUTH_CLIENT_ID` (provision it with `hermes dashboard register`). The bundled **username/password** provider (`HERMES_DASHBOARD_BASIC_AUTH_*`) is the quickest option for a backend on a trusted LAN or behind a VPN, but is not suitable for direct public-internet exposure. To authenticate against your own identity provider, use the **self-hosted OIDC** provider (`HERMES_DASHBOARD_OIDC_*`). Either way, a non-loopback bind (`hermes dashboard --host 0.0.0.0`) engages the auth gate. See [Web Dashboard → Authentication](/user-guide/features/web-dashboard#authentication-gated-mode) for the full picture. | Variable | Description | |----------|-------------| @@ -429,10 +429,12 @@ The recommended way to expose a dashboard for a remote Hermes Desktop connection | `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. | | `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly so sessions survive restarts / span multiple workers; blank → random per-process (you'll be logged out on every restart). Overrides `dashboard.basic_auth.secret`. | | `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. | -| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in with your username and password from the Gateway settings panel. | -| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. | -| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. | +| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard, activating the Nous (`plugins/dashboard_auth/nous`) provider. Overrides `dashboard.oauth.client_id`. Provision it with `hermes dashboard register`. | | `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. | +| `HERMES_DASHBOARD_OIDC_ISSUER` | OIDC issuer URL for the bundled self-hosted OIDC provider (`plugins/dashboard_auth/self_hosted`). Required to activate it. Overrides `dashboard.oauth.self_hosted.issuer`. | +| `HERMES_DASHBOARD_OIDC_CLIENT_ID` | Public OIDC client id (authorization-code + PKCE) for the self-hosted OIDC provider. Required to activate it. Overrides `dashboard.oauth.self_hosted.client_id`. | +| `HERMES_DASHBOARD_OIDC_SCOPES` | Requested OIDC scopes for the self-hosted OIDC provider (default `openid profile email`). Overrides `dashboard.oauth.self_hosted.scopes`. | +| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in from the Gateway settings panel (OAuth redirect or username/password, whichever the backend advertises). | ### Microsoft Graph (Teams Meetings) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 3c9e16a4a37d..f4508c7e43a1 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -102,7 +102,14 @@ By default the app starts and manages its own **local** backend. You can instead "Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps. ::: -The connection has two halves: on the backend you protect the dashboard with a **username and password**, and in the app you enter the backend's URL and sign in with those credentials. Binding the dashboard to a non-loopback address automatically engages its auth gate, so the username/password provider is what lets the desktop app through. +The connection has two halves: on the backend you protect the dashboard with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the dashboard to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through. + +**Pick a provider based on where the backend lives:** + +- **OAuth (Nous Portal) — preferred for anything reachable beyond your own machine.** Logins are verified against your Nous account, so this is the option suitable for a VPS, a public host, or any remote backend. Register the dashboard with `hermes dashboard register` (or the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page) to provision its OAuth client, then sign in from the app with **Sign in with Nous Research**. A self-hosted OIDC provider works the same way if you run your own identity provider. +- **Username/password — local / trusted-network use only.** The simplest option when the backend is on the same trusted LAN or reachable only over a VPN (e.g. Tailscale). It protects a single shared credential with no external identity provider, so **do not use it for a dashboard exposed to the public internet** — reach for OAuth there instead. + +The rest of this section shows the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Web Dashboard → Default provider: Nous Research](./features/web-dashboard.md#default-provider-nous-research). ### On the backend (the remote machine) @@ -134,7 +141,7 @@ Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUT Running the dashboard as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot. :::warning -The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. Even behind a username and password, never expose it directly to the open internet — put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL so only your tailnet can reach it. +The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected dashboard directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead. ::: ### In the app @@ -142,10 +149,10 @@ The dashboard reads and writes your `.env` (API keys, secrets) and can run agent **Settings → Gateway → Remote gateway:** 1. **Remote URL** — `http://:9119` (path prefixes like `/hermes` work if you front it with a reverse proxy) -2. **Sign in** — the app detects that the backend requires a username and password and shows a **Sign in** button. Click it, enter the credentials from step 1, and the app authenticates against the backend's login page. +2. **Sign in** — the app detects which provider the backend advertises and adapts the button. For a username/password backend it shows a **Sign in** button that opens a credential form (enter the credentials from step 1). For an OAuth backend it shows **Sign in with ``** (e.g. *Sign in with Nous Research*), which runs the provider's browser sign-in. Either way the app ends up with an authenticated session against the backend. 3. **Save and reconnect** — switches the desktop shell onto the remote backend. The session refreshes automatically; you stay signed in across restarts when `HERMES_DASHBOARD_BASIC_AUTH_SECRET` is set. -You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in with your username and password from the Gateway settings panel. +You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in from the Gateway settings panel. ### Troubleshooting diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index c86fff431a8c..f564461b2cbd 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -118,7 +118,13 @@ The dashboard's auth gate engages automatically when both of the following are t 1. The bind host is non-loopback (e.g. the default `0.0.0.0` inside the container), **and** 2. A `DashboardAuthProvider` plugin is registered. -The simplest way to satisfy the second condition is the bundled **username/password** provider: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). For hosted/public deploys the OAuth (`dashboard_auth/nous`) provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set. Either way the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for both providers. +There are three bundled ways to satisfy the second condition: + +- **Username/password** — the simplest for a self-hosted / on-prem / homelab container on a trusted network or behind a VPN: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). Not suitable for direct public-internet exposure. +- **OAuth (Nous Portal)** — for hosted/public deploys: the `dashboard_auth/nous` provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set. +- **Self-hosted OIDC** — to authenticate against your own identity provider via standard OpenID Connect: the `dashboard_auth/self_hosted` provider activates when `HERMES_DASHBOARD_OIDC_ISSUER` + `HERMES_DASHBOARD_OIDC_CLIENT_ID` are set. + +Whichever you choose, the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for all three providers. If no provider is registered and the bind is non-loopback, the dashboard **fails closed at startup** with a specific error pointing at the missing env var. The `HERMES_DASHBOARD_INSECURE=1` escape hatch disables the gate entirely (the bind host alone never implies `--insecure`), but it serves an unauthenticated dashboard — configure a provider instead unless you have your own auth layer in front. diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 50292bbf0a6d..0563223aac71 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -493,10 +493,11 @@ same auth gate as the rest of `/api/`. ## Authentication (gated mode) -When the dashboard is bound to a public or non-loopback address — anything other than `127.0.0.1` / `localhost` — Hermes Agent engages an auth gate. Every request must carry a verified session cookie or it's bounced to the login page. Two providers ship in the box: +When the dashboard is bound to a public or non-loopback address — anything other than `127.0.0.1` / `localhost` — Hermes Agent engages an auth gate. Every request must carry a verified session cookie or it's bounced to the login page. Three providers ship in the box: -- **[Username/password](#usernamepassword-provider-no-oauth-idp)** — the simplest way to put auth on a self-hosted / on-prem / homelab dashboard (and the recommended path for a [remote Hermes Desktop connection](#connecting-hermes-desktop-to-a-remote-backend)). No external identity provider. -- **OAuth (Nous Portal)** — for hosted deployments (typically Fly.io) where the dashboard is reachable over the public internet. +- **[Username/password](#usernamepassword-provider-no-oauth-idp)** — the simplest way to put auth on a self-hosted / on-prem / homelab dashboard. No external identity provider. **Use it only on a trusted network or behind a VPN — not for public-internet exposure.** +- **[OAuth (Nous Portal)](#default-provider-nous-research)** — for hosted deployments and any dashboard reachable over the public internet, and the recommended path for a [remote Hermes Desktop connection](#connecting-hermes-desktop-to-a-remote-backend). Every login is verified against your Nous account, so this is the provider suitable for internet-facing use. +- **[Self-hosted OIDC](#self-hosted-oidc-provider)** — for bringing your own identity provider via standard OpenID Connect (Keycloak, Auth0, Okta, Google, GitHub via an OIDC bridge, etc.). No Nous Portal involved; suitable for public-internet exposure when fronted by a conformant OIDC server. Operator-owned dashboards bound to loopback are unaffected — no auth, no login page. @@ -524,6 +525,22 @@ If the gate would engage but **no** `DashboardAuthProvider` is registered (no No The bundled `plugins/dashboard_auth/nous` plugin is **always installed** and auto-loaded. It auto-registers a `DashboardAuthProvider` named `nous` when a client ID is configured. +Because every login is verified against Nous Portal and protected by your Nous account, **the Nous provider is the one suitable for exposing a dashboard to the public internet.** + +#### Registering a dashboard + +To use the Nous provider you need an OAuth client ID (shape `agent:{id}`). There are two ways to get one: + +- **CLI — `hermes dashboard register`.** Run it on the host where the dashboard lives. It resolves your existing Nous login (run `hermes setup` first if you're not logged in), registers a self-hosted OAuth client with the Portal, and writes `HERMES_DASHBOARD_OAUTH_CLIENT_ID` into `~/.hermes/.env` for you. Optional flags: `--name` (a human-readable label, otherwise auto-generated) and `--redirect-uri` (a public HTTPS callback URL for an internet-facing host). + + ```bash + hermes dashboard register + # ✓ Registered dashboard "swift_falcon" + # …writes HERMES_DASHBOARD_OAUTH_CLIENT_ID to ~/.hermes/.env + ``` + +- **GUI — the Local Dashboards page.** Open [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) in the Nous Portal to register, name, manage, and revoke self-hosted dashboards from the browser. Copy the resulting `agent:{id}` client ID into `HERMES_DASHBOARD_OAUTH_CLIENT_ID` (env) or `dashboard.oauth.client_id` (config.yaml). This is also where you revoke a dashboard registered via the CLI. + #### Configuration The plugin reads from two surfaces, with the environment variable winning when set non-empty: @@ -534,19 +551,17 @@ The plugin reads from two surfaces, with the environment variable winning when s dashboard: oauth: client_id: agent:01HXYZ… # required to engage the gate - portal_url: https://portal.nousresearch.com # optional; defaults to production ``` **Environment variables** — operator overrides: | Env var | Overrides | Format | Provisioned by | |---------|-----------|--------|----------------| -| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | `dashboard.oauth.client_id` | `agent:{instance_id}` | Nous Portal at Fly.io provisioning time | -| `HERMES_DASHBOARD_PORTAL_URL` | `dashboard.oauth.portal_url` | URL (default: `https://portal.nousresearch.com`) | Portal — override only for staging or a custom deployment | +| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | `dashboard.oauth.client_id` | `agent:{instance_id}` | `hermes dashboard register` | -Per the Hermes Agent convention (`~/.hermes/.env` is for API keys / secrets only), **`config.yaml` is the recommended place to set these values** for local dev, on-prem, and any deployment you control directly. The environment-variable path exists so Fly.io's platform-secret injection can push per-deploy `client_id`s without anyone having to edit `config.yaml` inside the image — that's its primary purpose. +Per the Hermes Agent convention (`~/.hermes/.env` is for API keys / secrets only), **`config.yaml` is the recommended place to set these values** for local dev, on-prem, and any deployment you control directly. The environment-variable path exists so a hosting platform's secret injection can push per-deploy `client_id`s without anyone having to edit `config.yaml` inside the image — that's its primary purpose. -Empty environment values are treated as unset, so a provisioned-but-not-populated Fly secret can't accidentally shadow a valid `config.yaml` entry. +Empty environment values are treated as unset, so a provisioned-but-not-populated platform secret can't accidentally shadow a valid `config.yaml` entry. If neither source provides a client_id, the plugin reports the specific reason and the dashboard's fail-closed bind error tells you exactly what to fix: @@ -566,12 +581,45 @@ Or pass --insecure to skip the auth gate (NOT recommended on untrusted networks). ``` +#### Worked example: Nous Research + +From a logged-in Hermes install to a Nous-gated dashboard in three steps. + +**1. Log in and register the dashboard.** `hermes dashboard register` uses your existing Nous login to provision an OAuth client and writes `HERMES_DASHBOARD_OAUTH_CLIENT_ID` into `~/.hermes/.env` for you: + +```bash +hermes setup # if you're not already logged into Nous Portal +hermes dashboard register +# ✓ Registered dashboard "swift_falcon" +# …writes HERMES_DASHBOARD_OAUTH_CLIENT_ID to ~/.hermes/.env +``` + +**2. Run the dashboard on a reachable address.** A non-loopback bind without `--insecure` engages the OAuth gate, and the `client_id` just written activates the `nous` provider: + +```bash +hermes dashboard --host 0.0.0.0 --port 9119 --no-open +``` + +**3. Log in.** Open `http://:9119/`, you'll be bounced to `/login`. Click **Sign in with Nous Research** → authenticate at the Portal → land back on the authenticated dashboard. Verify the gate from any machine: + +```bash +curl -s http://:9119/api/status | jq '.auth_required, .auth_providers' +# true +# ["nous"] +``` + +`GET /api/auth/me` then returns the verified session (`provider: nous`). For an internet-facing host, register with `--redirect-uri https://hermes.example.com/auth/callback` and set `HERMES_DASHBOARD_PUBLIC_URL` so the OAuth callback resolves to your public URL (see [Public URL override](#public-url-override)). + ### Username/password provider (no OAuth IDP) If you don't want to wire up an OAuth identity provider — a self-hosted "just put a password on my dashboard" deployment — the bundled `plugins/dashboard_auth/basic` plugin registers a `DashboardAuthProvider` named `basic` that authenticates with a **username and password** instead of an OAuth redirect. It plugs into the same gate as the OAuth provider: the gate engages on a non-loopback bind without `--insecure`, the login page renders a credential form for this provider (instead of a "Log in with X" button), and everything downstream of login — session cookies, transparent refresh, WS tickets, logout, the audit log — is identical to the OAuth path. Sessions are stateless HMAC-signed tokens the provider mints itself, so there's **no database and no external IDP**. Password hashing uses stdlib `scrypt` (no third-party dependency). +:::warning Use this on trusted networks only — not the public internet +The username/password provider is intended for self-hosted / on-prem / homelab dashboards on a **trusted network**, or reachable only over a **VPN**. It protects a single shared credential with no external identity provider, MFA, or per-user accounts behind it, so it is **not suitable for exposing a dashboard directly to the public internet**. For an internet-facing dashboard, use the [Nous Research provider](#default-provider-nous-research) (or your own [self-hosted OIDC](#self-hosted-oidc-provider) / [custom OAuth](#custom-providers) provider) instead. +::: + #### Configuration Like the Nous provider, it reads from `config.yaml` (canonical) with environment variables winning when set non-empty. It activates only when `username` plus either `password_hash` (preferred) or `password` are configured — otherwise it's a no-op, so OAuth users and loopback/`--insecure` operators are unaffected. @@ -607,6 +655,40 @@ When `secret` is empty, a random per-process signing key is generated. That's fi The `/auth/password-login` endpoint is rate-limited per client IP (default 10 attempts/minute → HTTP 429) and returns a single generic `401 Invalid credentials` for both unknown users and wrong passwords, so it can't be used as a username-enumeration oracle. +#### Worked example: username/password + +From nothing to a password-gated dashboard on a trusted network in three steps. + +**1. Set credentials in `~/.hermes/.env`.** Hash the password so no plaintext sits at rest, and set a stable signing secret so sessions survive restarts: + +```bash +# Compute a scrypt hash of your chosen password: +HASH=$(python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('choose-a-strong-password'))") + +cat >> ~/.hermes/.env <:9119/`, you'll be bounced to `/login` — a **credential form** (not a "Sign in with X" button). Enter `admin` / your password → land on the authenticated dashboard. Verify the gate from any machine: + +```bash +curl -s http://:9119/api/status | jq '.auth_required, .auth_providers' +# true +# ["basic"] +``` + +`GET /api/auth/me` then returns the verified session (`provider: basic`). Keep this behind a VPN — see the warning above; for a public host use the [Nous Research](#default-provider-nous-research) or [self-hosted OIDC](#self-hosted-oidc-provider) provider instead. + #### Writing your own password provider `basic` is just one implementation of an extension point. Any plugin can register a password provider: set `supports_password = True` on your `DashboardAuthProvider` subclass and implement `complete_password_login(*, username, password) -> Session` (raise `InvalidCredentialsError` on rejection, `ProviderError` if your backing store is down). The OAuth `start_login` / `complete_login` methods can be left as `NotImplementedError` stubs for a pure-password provider. This is the path for LDAP-bind, a credentials database, or any other non-redirect auth scheme — the framework handles the form, the route, the cookies, and refresh for you. @@ -739,9 +821,9 @@ engages the OAuth gate. ### Public URL override -By default, the dashboard reconstructs the OAuth callback URL from the request — `X-Forwarded-Host` + `X-Forwarded-Proto` + `X-Forwarded-Prefix` (when uvicorn is configured with `proxy_headers=True`, which `start_server` enables under the gate). This works out of the box on Fly.io, which sets all three headers correctly. +By default, the dashboard reconstructs the OAuth callback URL from the request — `X-Forwarded-Host` + `X-Forwarded-Proto` + `X-Forwarded-Prefix` (when uvicorn is configured with `proxy_headers=True`, which `start_server` enables under the gate). This works out of the box behind a reverse proxy that sets all three headers correctly. -For deploys behind reverse proxies that don't reliably forward those headers (manual nginx setups, on-prem ingresses, custom-domain Fly deploys with partial proxy chains), set `dashboard.public_url` (or `HERMES_DASHBOARD_PUBLIC_URL`) to the **complete public URL** the dashboard is reached at: +For deploys behind reverse proxies that don't reliably forward those headers (manual nginx setups, on-prem ingresses, custom-domain deploys with partial proxy chains), set `dashboard.public_url` (or `HERMES_DASHBOARD_PUBLIC_URL`) to the **complete public URL** the dashboard is reached at: ```yaml dashboard: @@ -755,7 +837,7 @@ Same precedence as the other dashboard settings — env wins over `config.yaml`: | Surface | Override path | When to use | |---------|---------------|-------------| | `dashboard.public_url` in `config.yaml` | `HERMES_DASHBOARD_PUBLIC_URL` | Local dev / on-prem (canonical) | -| `HERMES_DASHBOARD_PUBLIC_URL` env var | — | Fly.io platform secrets / CI | +| `HERMES_DASHBOARD_PUBLIC_URL` env var | — | Hosting-platform secrets / CI | | (unset) | — | Default — reconstruct from `X-Forwarded-*` headers | Validation rejects values without `http://` / `https://` scheme, without a host, or containing quote / angle / whitespace / control characters. A malformed value silently falls through to header reconstruction so the login flow keeps working rather than dispatching the user to a hostile URL. @@ -783,7 +865,7 @@ Access tokens have a 15-minute TTL. **There is no refresh token in contract v1** | `hermes_session_pkce` | 10 min | HttpOnly; holds the PKCE verifier + provider hint during the round trip | | `hermes_session_rt` | unused in v1 | Reserved for forward-compat; not written when `refresh_token` is empty | -All three are `Path=/` and `SameSite=Lax`. The `Secure` flag is set when the dashboard is reached over HTTPS (detected via the request URL scheme — honours `X-Forwarded-Proto` from Fly's TLS terminator under `proxy_headers=True`). +All three are `Path=/` and `SameSite=Lax`. The `Secure` flag is set when the dashboard is reached over HTTPS (detected via the request URL scheme — honours `X-Forwarded-Proto` from an upstream TLS terminator under `proxy_headers=True`). ### Logout @@ -820,8 +902,7 @@ The login page lists all registered providers; multiple providers can be stacked ### Verifying the gate is on ```bash -# Quick env-var path (Fly.io shape). HERMES_DASHBOARD_PORTAL_URL is -# optional — defaults to production. +# Quick env-var path. HERMES_DASHBOARD_OAUTH_CLIENT_ID=agent:test \ hermes dashboard --host 0.0.0.0 @@ -846,7 +927,9 @@ The dashboard's React StatusPage shows the same fields under "Web server". A sid Hermes Desktop can drive a Hermes backend running on another machine (a VPS, a home server, a Mini behind Tailscale). In the app this lives under **Settings → Gateway → Remote gateway**, which asks for a **Remote URL** and a way to **Sign in**. (For the desktop app itself — install, settings, chat — see the [Hermes Desktop](/user-guide/desktop) page.) -You protect the remote dashboard with a **username and password** (the bundled [username/password provider](#usernamepassword-provider-no-oauth-idp)). Binding the dashboard to a non-loopback address engages its auth gate; the username/password provider is what the desktop app signs in against. Once signed in, Desktop reuses the session for the chat WebSocket automatically — there is no token to copy or paste. +You protect the remote dashboard with one of the bundled auth providers, and the desktop app signs in against whichever one the backend advertises. For a backend reachable beyond your own machine — a VPS, a public host, anything internet-facing — the recommended provider is **OAuth (Nous Portal)** (register it with [`hermes dashboard register`](#registering-a-dashboard) and sign in with *Sign in with Nous Research*). The bundled [username/password provider](#usernamepassword-provider-no-oauth-idp) is the quickest option when the backend is on a trusted LAN or reachable only over a VPN, but is **not suitable for direct public-internet exposure**. Binding the dashboard to a non-loopback address engages its auth gate; once signed in, Desktop reuses the session for the chat WebSocket automatically — there is no token to copy or paste. + +The recipe below uses the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Default provider: Nous Research](#default-provider-nous-research). ### On the backend (the remote machine) @@ -870,7 +953,7 @@ Prefer no plaintext at rest? Use `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` wit If you run the dashboard as a systemd service, `~/.hermes/.env` is picked up automatically when the unit has `EnvironmentFile=%h/.hermes/.env`, so the credentials are in the environment at boot. :::warning -The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. Even behind a username and password, never expose it directly to the open internet. Put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL. Only devices on your tailnet can reach it. +The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown here is for a trusted network — never expose a password-protected dashboard directly to the open internet. Put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL. Only devices on your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead. ::: ### In Hermes Desktop From 3278b423d5f094f9854795b3ee6f06e2beb0732e Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:22:40 -0700 Subject: [PATCH 033/541] fix(dashboard): strip session token from subprocess env Add HERMES_DASHBOARD_SESSION_TOKEN to the Hermes-managed subprocess environment blocklist so dashboard authorization material does not propagate into shell, PTY, or background process launches. Extend the local environment blocklist regression coverage to prove the dashboard session token is stripped like other Hermes-managed secrets. --- tests/tools/test_local_env_blocklist.py | 2 ++ tools/environments/local.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 0e0520387e10..7d7545fa8c54 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -176,6 +176,7 @@ class TestProviderEnvBlocklist: "HASS_TOKEN": "ha-secret", "EMAIL_PASSWORD": "email-secret", "FIRECRAWL_API_KEY": "fc-secret", + "HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-session-secret", "BROWSERBASE_PROJECT_ID": "bb-project", "ELEVENLABS_API_KEY": "el-secret", "GITHUB_TOKEN": "ghp_secret", @@ -362,6 +363,7 @@ class TestBlocklistCoverage: "EMAIL_SMTP_HOST", "EMAIL_HOME_ADDRESS", "EMAIL_HOME_ADDRESS_NAME", + "HERMES_DASHBOARD_SESSION_TOKEN", "GATEWAY_ALLOWED_USERS", "GH_TOKEN", "GITHUB_APP_ID", diff --git a/tools/environments/local.py b/tools/environments/local.py index 4cc65d80af53..68ff2577473a 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -175,6 +175,7 @@ def _build_provider_env_blocklist() -> frozenset: "EMAIL_SMTP_HOST", "EMAIL_HOME_ADDRESS", "EMAIL_HOME_ADDRESS_NAME", + "HERMES_DASHBOARD_SESSION_TOKEN", "GATEWAY_ALLOWED_USERS", "GH_TOKEN", "GITHUB_APP_ID", From b459bac02c98ff08247861f37b53eb7bc20aee76 Mon Sep 17 00:00:00 2001 From: harjoth Date: Fri, 5 Jun 2026 02:34:52 -0700 Subject: [PATCH 034/541] fix(cli): gitignore Desktop bootstrap marker so hermes update stops autostashing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop bootstrap installer writes `.hermes-bootstrap-complete` into the managed git checkout root. Because it wasn't gitignored, `hermes update`'s `git stash push --include-untracked` treated it as a local change and created an autostash on every run — prompting the user to restore "local changes" that were really Hermes-managed runtime state (and risking the marker getting stranded in a stash, which re-triggers Desktop bootstrap). Add the marker to .gitignore; `git stash -u` and `git status --porcelain` both skip ignored files, so the updater now sees a clean tree. Fixes #38529 --- .gitignore | 6 +++ tests/hermes_cli/test_update_autostash.py | 47 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.gitignore b/.gitignore index b0abe3140b11..1efce4b83f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,12 @@ docs/superpowers/* # logs, and per-session caches are never artifacts of the codebase. .hermes/ +# Desktop/bootstrap install marker written into the managed checkout root by the +# bootstrap installer. It is Hermes-managed runtime state, never a code change — +# ignore it so `hermes update`'s `git stash push --include-untracked` does not +# treat it as a local edit and autostash it on every run (#38529). +.hermes-bootstrap-complete + # Tool Search live-test harness output — non-deterministic model transcripts, # regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo. scripts/out/ diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 3ba19965591f..16946834ab11 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -819,3 +819,50 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, out = capsys.readouterr().out assert "preserved in stash" in out + + +def test_bootstrap_marker_not_autostashed_by_update(tmp_path): + """#38529: the Desktop bootstrap marker must be git-ignored so that + ``hermes update``'s ``git stash push --include-untracked`` does not sweep it + into an autostash on every run. + + Behavioral + hermetic: build a throwaway repo that adopts the project's real + ``.gitignore`` (the contract under test), drop the marker, and confirm the + same stash invocation the updater uses leaves it untouched. + """ + import shutil + import subprocess + + if shutil.which("git") is None: + pytest.skip("git not available") + + repo_gitignore = Path(hermes_main.__file__).resolve().parents[1] / ".gitignore" + + def git(*args): + return subprocess.run( + ["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True + ) + + git("init", "-q") + git("config", "user.email", "t@example.com") + git("config", "user.name", "t") + (tmp_path / ".gitignore").write_text(repo_gitignore.read_text()) + (tmp_path / "tracked.txt").write_text("x\n") + git("add", "-A") + git("commit", "-qm", "init") + + marker = tmp_path / ".hermes-bootstrap-complete" + marker.write_text("") + + # Exact flags used by hermes update (hermes_cli/main.py). + git("stash", "push", "--include-untracked", "-m", "hermes-update-autostash") + + assert marker.exists(), ( + ".hermes-bootstrap-complete was swept into the update autostash — it must " + "be listed in .gitignore so `git stash -u` skips it (#38529)." + ) + # It must not even register as a dirty/untracked change. + status = subprocess.run( + ["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True + ).stdout + assert ".hermes-bootstrap-complete" not in status From 397d492b3e58ad6cc725fe6ca39294dd78c763f0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:35:25 -0700 Subject: [PATCH 035/541] =?UTF-8?q?chore(release):=20map=20harjoth.khara@g?= =?UTF-8?q?mail.com=20=E2=86=92=20harjothkhara=20for=20#38550=20salvage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 4112796ae008..1fee7cd0260a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "harjoth.khara@gmail.com": "harjothkhara", "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "dirtyren@users.noreply.github.com": "dirtyren", "zhaolei.vc@bytedance.com": "zhaoleibd", From 2a82519b0da9bf16976340fd7810832a4bdf79e4 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 5 Jun 2026 15:08:31 +0700 Subject: [PATCH 036/541] fix(models): don't silently default Nous to the most expensive flagship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider is configured but no model is selected (e.g. a profile sets provider: nous with no model), the gateway/CLI fall back to get_default_model_for_provider(), which returned the first curated catalog entry. The Nous Portal list is ordered most-capable-first, so entry [0] is anthropic/claude-opus-4.8 — the single most expensive model ($5/$25 per Mtok). A misconfigured profile therefore silently routed every call to the flagship and billed it for traffic the user never opted into. Pin the silent (non-interactive) default for metered aggregators to the cheapest curated tier via _PROVIDER_SILENT_DEFAULT_OVERRIDES so a missing model can never auto-escalate to the flagship. The interactive default (GUI onboarding / `hermes model`) keeps using the richer free/paid-tier-aware resolver. Fixes the unexpected anthropic/claude-opus-4.8 charges reported for a free-tier Nous account whose new profile had no default model. --- hermes_cli/models.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0ca52f6bfaf6..8d5c610aeef5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1150,17 +1150,46 @@ _PROVIDER_ALIASES = { } +# Cost-safe overrides for the *silent* auto-default +# (``get_default_model_for_provider``). Most providers' curated lists lead with a +# sensible default, but Nous Portal is a per-token *metered aggregator* whose +# list is ordered best-/most-capable-first — entry [0] is the priciest flagship +# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the +# non-interactive fallback when a profile sets ``provider: nous`` with no model +# silently bills the most expensive model for traffic the user never opted into +# (a missing default escalated to Opus and billed 863 requests before the user +# noticed). Pin the silent default to the cheapest curated tier instead so a +# missing model can never escalate to the flagship. +# +# This is deliberately a fixed, side-effect-free default for the hot resolution +# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the +# richer free/paid-tier-aware resolver — see ``get_recommended_default_model`` +# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can +# hit the Portal; this fallback must stay cheap and network-free. +_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = { + "nous": "nvidia/nemotron-3-super-120b-a12b", +} + + def get_default_model_for_provider(provider: str) -> str: - """Return the default model for a provider, or empty string if unknown. + """Return a cost-safe default model for a provider, or "" if unknown. - Uses the first entry in _PROVIDER_MODELS as the default. This is the - model a user would be offered first in the ``hermes model`` picker. + Used as a NON-INTERACTIVE fallback when a provider is configured but no + model was ever selected (e.g. ``hermes auth add openai-codex`` without + ``hermes model``, or a profile that sets ``provider`` with no ``model``). - Used as a fallback when the user has configured a provider but never - selected a model (e.g. ``hermes auth add openai-codex`` without - ``hermes model``). + For most providers this is the first entry in ``_PROVIDER_MODELS`` — the + same model the ``hermes model`` picker offers first. For metered aggregators + whose curated list is ordered most-capable-first, that entry is also the + most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such + providers carry an explicit low-cost override in + ``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never + auto-escalate to the flagship. """ models = _PROVIDER_MODELS.get(provider, []) + override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider) + if override and override in models: + return override return models[0] if models else "" From ef5e48f3fd79d68677c26c1f37438a620654f313 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 5 Jun 2026 15:08:31 +0700 Subject: [PATCH 037/541] test(models): guard Nous silent default against expensive-flagship escalation Assert get_default_model_for_provider("nous") never returns the priciest catalog entry (anthropic/claude-opus-4.8) and that an override pointing at a model absent from the catalog falls back to catalog order. Regression for the silent flagship-billing footgun. --- tests/test_empty_model_fallback.py | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py index 32689b325ee5..a15666ecb604 100644 --- a/tests/test_empty_model_fallback.py +++ b/tests/test_empty_model_fallback.py @@ -30,6 +30,46 @@ class TestGetDefaultModelForProvider: # Custom providers don't have entries in _PROVIDER_MODELS assert get_default_model_for_provider("some-random-custom") == "" + def test_nous_silent_default_is_not_the_expensive_flagship(self): + """Nous Portal is a metered aggregator whose curated list is ordered + most-capable-first, so entry [0] is the priciest flagship + (anthropic/claude-opus-4.8). The silent fallback (provider set, no model) + must NOT escalate to it — otherwise an unconfigured profile silently + bills the most expensive model. Regression for the billing footgun. + """ + from hermes_cli.models import ( + _PROVIDER_MODELS, + _PROVIDER_SILENT_DEFAULT_OVERRIDES, + get_default_model_for_provider, + ) + + result = get_default_model_for_provider("nous") + assert result, "nous must resolve to a usable default model" + assert "opus" not in result.lower(), ( + f"silent default escalated to an expensive flagship: {result!r}" + ) + assert result != _PROVIDER_MODELS["nous"][0], ( + "silent default must not be the most-capable/priciest catalog entry" + ) + # The override must point at a model that actually exists in the catalog. + assert result == _PROVIDER_SILENT_DEFAULT_OVERRIDES["nous"] + assert result in _PROVIDER_MODELS["nous"] + + def test_override_falls_back_to_catalog_when_missing(self): + """If an override model is no longer in the catalog, fall back to [0] + rather than returning a stale/absent id.""" + from unittest.mock import patch + + from hermes_cli import models as models_mod + + with patch.dict( + models_mod._PROVIDER_SILENT_DEFAULT_OVERRIDES, + {"openai-codex": "does-not-exist-model"}, + clear=False, + ): + result = models_mod.get_default_model_for_provider("openai-codex") + assert result == models_mod._PROVIDER_MODELS["openai-codex"][0] + class TestGatewayEmptyModelFallback: """Test that _resolve_session_agent_runtime fills in empty model from provider catalog.""" From 3da44dbda7cc127bc803fe9212acd53b567c4683 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:36:20 -0700 Subject: [PATCH 038/541] fix(models): use deepseek-v4-flash as Nous silent default Follow-up on the salvaged fix: point the Nous silent-default override at deepseek/deepseek-v4-flash (a cheap chat model) instead of the nvidia nemotron entry. Keeps the no-model-configured fallback off the priciest flagship while landing on a low-cost, broadly-capable default. --- hermes_cli/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8d5c610aeef5..b9b3c819c16c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1158,7 +1158,7 @@ _PROVIDER_ALIASES = { # non-interactive fallback when a profile sets ``provider: nous`` with no model # silently bills the most expensive model for traffic the user never opted into # (a missing default escalated to Opus and billed 863 requests before the user -# noticed). Pin the silent default to the cheapest curated tier instead so a +# noticed). Pin the silent default to a low-cost curated model instead so a # missing model can never escalate to the flagship. # # This is deliberately a fixed, side-effect-free default for the hot resolution @@ -1167,7 +1167,7 @@ _PROVIDER_ALIASES = { # in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can # hit the Portal; this fallback must stay cheap and network-free. _PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = { - "nous": "nvidia/nemotron-3-super-120b-a12b", + "nous": "deepseek/deepseek-v4-flash", } From 8a9ded5b21b11355fe08425e2d1c7763acd17842 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:10:40 -0700 Subject: [PATCH 039/541] =?UTF-8?q?feat(discord):=20voice-channel=20mixer?= =?UTF-8?q?=20=E2=80=94=20ambient=20idle=20bed=20+=20verbal=20acks=20that?= =?UTF-8?q?=20overlap=20TTS=20(#39659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS Discord voice mode can now feel conversational: the bot speaks a short acknowledgement before it starts working, and a subtle ambient 'thinking' bed plays underneath while tools run, ducking under speech and swelling back — the Grok-voice-mode feel. discord.py plays only one audio stream per voice connection, so this adds a software mixer (VoiceMixer, a discord.AudioSource) installed once per guild on join. It sums an ambient loop, verbal acks, and TTS replies into that single 20ms/48kHz/stereo stream (numpy int16 add + clip), so they overlap instead of stop-and-swap. Speech ducks the ambient gain down and releases it smoothly. - plugins/platforms/discord/voice_mixer.py: VoiceMixer + MixerChild (gain, loop, fade, duck/release), decode_to_pcm (ffmpeg), synth_ambient_pcm (no asset needed — synthesised pad). - adapter: install mixer on join, tear down on leave, route play_in_voice_channel through the mixer (legacy one-shot path kept as fallback), play_ack_in_voice, voice_mixer_active. Defensive getattr for the object.__new__ test helpers. - gateway/run.py: tool_start_callback fires a one-time verbal ack on the first tool call of a turn when in a voice channel (independent of the text tool-progress gate). No system-prompt or message-flow changes. - config: discord.voice_fx.* (OFF by default; ambient/duck/speech gains, ack phrases). All in config.yaml, not .env. - docs + tests (mixer unit + adapter integration). Verified: 19 new tests pass, existing voice suite green (2 pre-existing davey-module env failures unchanged), and a real-mixer E2E confirms ambient streams, TTS overlaps it, acks layer in, and teardown is clean. * fix(discord): make voice mixer numpy import lazy (numpy is voice-extra-only) numpy ships in the optional 'voice' extra, not [all,dev], so a module-level 'import numpy' broke CI test collection (and would break the always-imported Discord adapter on any install without the voice extra). Defer numpy to the functions that actually mix audio via _require_numpy(); guard the test module with pytest.importorskip('numpy'). --- gateway/run.py | 46 +++ hermes_cli/config.py | 22 ++ plugins/platforms/discord/adapter.py | 212 ++++++++++- plugins/platforms/discord/voice_mixer.py | 378 +++++++++++++++++++ tests/gateway/test_discord_voice_mixer.py | 264 +++++++++++++ website/docs/user-guide/messaging/discord.md | 31 ++ 6 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 plugins/platforms/discord/voice_mixer.py create mode 100644 tests/gateway/test_discord_voice_mixer.py diff --git a/gateway/run.py b/gateway/run.py index 7887ec23c3a2..7fc4da59f5e3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17024,6 +17024,47 @@ class GatewayRunner: last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated + # ── Discord voice "verbal ack before tool calls" ──────────────── + # When the bot is in a voice channel with the continuous mixer + # installed (discord.voice_fx.enabled), speak a short phrase ("let me + # look into that") over the ambient idle bed on the FIRST tool call of + # the turn. Fires from tool_start_callback (independent of the + # tool-progress text gate), at most once per turn. No-op on every + # other platform / when not in a voice channel. + _voice_ack_fired = [False] + _voice_ack_guild: List[Optional[int]] = [None] + if source.platform == Platform.DISCORD: + _va = self.adapters.get(Platform.DISCORD) + # source.chat_id is the linked text channel; resolve the guild whose + # voice connection is bound to it (mirrors DiscordAdapter.play_tts). + _vtc = getattr(_va, "_voice_text_channels", None) + if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"): + for _gid, _tc in _vtc.items(): + if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid): + _voice_ack_guild[0] = _gid + break + _voice_ack_loop = asyncio.get_running_loop() + + def voice_ack_callback(call_id, tool_name, args): + """tool_start_callback: speak a one-time ack in the voice channel.""" + if _voice_ack_fired[0] or _voice_ack_guild[0] is None: + return + if not _run_still_current(): + return + _voice_ack_fired[0] = True + _adapter = self.adapters.get(Platform.DISCORD) + if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"): + return + try: + safe_schedule_threadsafe( + _adapter.play_ack_in_voice(_voice_ack_guild[0]), + _voice_ack_loop, + logger=logger, + log_message="voice ack scheduling error", + ) + except Exception as _ack_err: + logger.debug("voice ack schedule failed: %s", _ack_err) + # Auto-cleanup of temporary progress bubbles (Telegram + any adapter # that implements ``delete_message``). When enabled via # ``display.platforms..cleanup_progress: true``, message IDs @@ -17834,6 +17875,11 @@ class GatewayRunner: # Per-message state — callbacks and reasoning config change every # turn and must not be baked into the cached agent constructor. agent.tool_progress_callback = progress_callback if tool_progress_enabled else None + # Discord voice verbal-ack hook (fires once per turn on first tool + # call; armed only when in a voice channel with the mixer running). + agent.tool_start_callback = ( + voice_ack_callback if _voice_ack_guild[0] is not None else None + ) agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None agent.stream_delta_callback = _stream_delta_cb agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d0b4493ddd55..a37c073c8a46 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1841,6 +1841,28 @@ DEFAULT_CONFIG = { # real memory cost. Default 32 MiB matches the historical hardcoded # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. "max_attachment_bytes": 33554432, + # Voice-channel audio effects (the continuous mixer). OFF by default. + # When enabled, the bot installs a software mixer on the outgoing voice + # stream so a low ambient "thinking" bed, verbal acknowledgements, and + # TTS replies can OVERLAP (ducking the ambient under speech) instead of + # stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer; + # this is implemented in plugins/platforms/discord/voice_mixer.py. + "voice_fx": { + "enabled": False, # master switch for the mixer subsystem + "ambient_enabled": True, # play the idle "thinking" bed while tools run + "ambient_path": "", # custom loop audio file; "" = synthesised pad + "ambient_gain": 0.18, # idle bed loudness, 0.0–1.0 + "duck_gain": 0.06, # ambient loudness while speech plays + "speech_gain": 1.0, # TTS / ack loudness, 0.0–1.0 + "ack_enabled": True, # speak a short phrase before the first tool call + "ack_phrases": [ # picked at random; set [] to disable phrases + "Let me look into that.", + "One moment.", + "Checking on that now.", + "Give me a sec.", + "On it.", + ], + }, }, # WhatsApp platform settings (gateway mode) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 117651765760..fa0f81c9b2e5 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -600,6 +600,12 @@ class DiscordAdapter(BasePlatformAdapter): self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop self._voice_input_callback: Optional[Callable] = None # set by run.py self._on_voice_disconnect: Optional[Callable] = None # set by run.py + # Phase 3: continuous voice mixer (ambient idle bed + ducked speech). + # Installed once per guild on join; lets acks / TTS / the "thinking" + # loop overlap in one outgoing stream instead of stop-and-swap. + self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer + self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed + self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config() # Track threads where the bot has participated so follow-up messages # in those threads don't require @mention. Persisted to disk so the # set survives gateway restarts. @@ -1925,6 +1931,160 @@ class DiscordAdapter(BasePlatformAdapter): # Voice channel methods (join / leave / play) # ------------------------------------------------------------------ + def _load_voice_fx_config(self) -> Dict[str, Any]: + """Read voice mixer / ambient / ack settings from config.yaml. + + All settings live under ``discord.voice_fx`` in config.yaml (NOT the + .env file — these are behavioral, not secrets). The feature is OFF by + default; users opt in with ``discord.voice_fx.enabled: true``. + + Returns a dict with safe defaults so callers never KeyError. + """ + defaults: Dict[str, Any] = { + "enabled": False, # master switch for the mixer subsystem + "ambient_enabled": True, # idle "thinking" bed while tools run + "ambient_path": "", # optional custom loop file; "" = synthesised + "ambient_gain": 0.18, # idle bed loudness (0..1) + "duck_gain": 0.06, # ambient loudness while speech plays + "speech_gain": 1.0, # TTS / ack loudness + "ack_enabled": True, # speak a short phrase before tool calls + "ack_phrases": [ + "Let me look into that.", + "One moment.", + "Checking on that now.", + "Give me a sec.", + "On it.", + ], + } + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() or {} + fx = ((cfg.get("discord") or {}).get("voice_fx") or {}) + if isinstance(fx, dict): + for k, v in fx.items(): + if k in defaults and v is not None: + defaults[k] = v + except Exception as e: + logger.debug("Could not load discord.voice_fx config: %s", e) + return defaults + + def _get_ambient_pcm(self) -> Optional[bytes]: + """Return decoded 48k/stereo/s16le PCM for the ambient idle bed. + + Uses a custom file when ``ambient_path`` is set and decodable, else a + synthesised pad. Cached after first build. + """ + if self._ambient_pcm_cache is not None: + return self._ambient_pcm_cache + if not self._voice_fx_cfg.get("ambient_enabled"): + return None + try: + from voice_mixer import decode_to_pcm, synth_ambient_pcm + except ImportError: + from .voice_mixer import decode_to_pcm, synth_ambient_pcm + + pcm: Optional[bytes] = None + path = (self._voice_fx_cfg.get("ambient_path") or "").strip() + if path and os.path.isfile(path): + pcm = decode_to_pcm(path) + if not pcm: + logger.warning("Ambient file %s failed to decode; using synth bed", path) + if not pcm: + pcm = synth_ambient_pcm() + self._ambient_pcm_cache = pcm + return pcm + + async def _install_voice_mixer(self, guild_id: int, vc) -> None: + """Create a VoiceMixer, start the ambient bed, and play it on the VC. + + The mixer runs continuously for the life of the connection: one + ``vc.play(mixer)`` call, never stopped until leave. + """ + try: + from voice_mixer import VoiceMixer + except ImportError: + from .voice_mixer import VoiceMixer + + mixer = VoiceMixer( + ambient_gain=float(self._voice_fx_cfg.get("ambient_gain", 0.18)), + duck_gain=float(self._voice_fx_cfg.get("duck_gain", 0.06)), + speech_gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)), + ) + ambient = await asyncio.to_thread(self._get_ambient_pcm) + if ambient: + mixer.set_ambient(ambient) + + def _after(error): + if error: + logger.error("Voice mixer stream error (guild=%d): %s", guild_id, error) + + if vc.is_playing(): + vc.stop() + vc.play(mixer, after=_after) + self._voice_mixers[guild_id] = mixer + logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient)) + + async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool: + """Speak a short acknowledgement over the ambient bed. + + Called from the gateway's tool-progress hook on the first tool call of + a turn, so the user hears "let me look into that" before the bot goes + quiet to work. No-op unless the mixer is installed and acks enabled. + """ + if not self._voice_fx_cfg.get("ack_enabled"): + return False + mixer = self._voice_mixers.get(guild_id) + if mixer is None: + return False + if phrase is None: + import random + phrases = self._voice_fx_cfg.get("ack_phrases") or ["One moment."] + phrase = random.choice(phrases) + + # Synthesise the ack via the configured TTS provider, then layer it. + import uuid as _uuid + audio_path = os.path.join( + tempfile.gettempdir(), "hermes_voice", + f"ack_{_uuid.uuid4().hex[:12]}.mp3", + ) + os.makedirs(os.path.dirname(audio_path), exist_ok=True) + try: + from tools.tts_tool import text_to_speech_tool + result_json = await asyncio.to_thread( + text_to_speech_tool, text=phrase, output_path=audio_path + ) + result = json.loads(result_json) + actual = result.get("file_path", audio_path) + if not result.get("success") or not os.path.isfile(actual): + return False + try: + from voice_mixer import decode_to_pcm + except ImportError: + from .voice_mixer import decode_to_pcm + pcm = await asyncio.to_thread(decode_to_pcm, actual) + if not pcm: + return False + mixer.play_speech( + pcm, gain=float(self._voice_fx_cfg.get("speech_gain", 1.0)) + ) + self._reset_voice_timeout(guild_id) + return True + except Exception as e: + logger.debug("play_ack_in_voice failed: %s", e) + return False + finally: + for p in {audio_path, locals().get("actual")}: + if p and os.path.isfile(p): + try: + os.unlink(p) + except OSError: + pass + + def voice_mixer_active(self, guild_id: int) -> bool: + """True when a continuous mixer is installed for this guild.""" + mixers = getattr(self, "_voice_mixers", None) + return bool(mixers) and mixers.get(guild_id) is not None + async def join_voice_channel(self, channel) -> bool: """Join a Discord voice channel. Returns True on success.""" if not self._client or not DISCORD_AVAILABLE: @@ -1957,6 +2117,15 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.warning("Voice receiver failed to start: %s", e) + # Phase 3: install the continuous mixer (ambient bed + ducked + # speech). Best-effort — if it fails we fall back to the legacy + # one-shot FFmpegPCMAudio playback path in play_in_voice_channel. + if getattr(self, "_voice_fx_cfg", {}).get("enabled"): + try: + await self._install_voice_mixer(guild_id, vc) + except Exception as e: + logger.warning("Voice mixer failed to start: %s", e) + return True async def leave_voice_channel(self, guild_id: int) -> None: @@ -1970,8 +2139,17 @@ class DiscordAdapter(BasePlatformAdapter): if listen_task: listen_task.cancel() + # Tear down the mixer (stops the continuous outgoing stream). + if getattr(self, "_voice_mixers", None) is not None: + self._voice_mixers.pop(guild_id, None) + vc = self._voice_clients.pop(guild_id, None) if vc and vc.is_connected(): + try: + if vc.is_playing(): + vc.stop() + except Exception: + pass await vc.disconnect() task = self._voice_timeout_tasks.pop(guild_id, None) if task: @@ -1983,11 +2161,43 @@ class DiscordAdapter(BasePlatformAdapter): PLAYBACK_TIMEOUT = 120 async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool: - """Play an audio file in the connected voice channel.""" + """Play an audio file in the connected voice channel. + + When the continuous mixer is installed for this guild, the clip is + decoded to PCM and layered over the ambient bed (ducking it) so the + reply can overlap the idle "thinking" loop seamlessly. Otherwise we + fall back to the legacy one-shot FFmpegPCMAudio path. + """ vc = self._voice_clients.get(guild_id) if not vc or not vc.is_connected(): return False + # ── Mixer path (overlap + ducking) ────────────────────────────── + mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None + if mixer is not None: + try: + from voice_mixer import decode_to_pcm + except ImportError: + from .voice_mixer import decode_to_pcm + pcm = await asyncio.to_thread(decode_to_pcm, audio_path) + if pcm: + speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0)) + mixer.play_speech(pcm, gain=speech_gain) + # Block until the speech child drains so callers serialise + # replies (mirrors legacy semantics) but the ambient keeps + # playing underneath the whole time. + wait_start = time.monotonic() + while mixer.speech_active: + if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT: + logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT) + mixer.stop_speech() + break + await asyncio.sleep(0.05) + self._reset_voice_timeout(guild_id) + return True + logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path) + + # ── Legacy one-shot path (no mixer) ───────────────────────────── # Pause voice receiver while playing (echo prevention) receiver = self._voice_receivers.get(guild_id) if receiver: diff --git a/plugins/platforms/discord/voice_mixer.py b/plugins/platforms/discord/voice_mixer.py new file mode 100644 index 000000000000..c8f5b7eef3aa --- /dev/null +++ b/plugins/platforms/discord/voice_mixer.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +""" +Continuous PCM audio mixer for Discord voice channels. + +discord.py (Rapptz) ships no audio mixer: ``VoiceClient.play()`` accepts a +single :class:`discord.AudioSource` and raises ``ClientException`` if called +while already playing. One opus stream per connection, one source feeding it. + +This module adds software mixing *upstream* of that single stream. A +:class:`VoiceMixer` is itself a ``discord.AudioSource`` that discord.py polls +every 20 ms via :meth:`read`. Internally it sums the 20 ms PCM frames of any +number of child sources, clamps to int16, and returns one blended frame. +discord.py never knows several streams were combined underneath — it just +encodes and sends the single mixed frame. + +This gives us, for one voice connection at once: + + * an always-on low-volume **ambient/idle loop** (the "thinking" sound), + * a **speech** channel (TTS replies, verbal acknowledgements) that plays + *over* the ambient bed, automatically **ducking** the ambient gain down + while speech is active and restoring it when speech ends — the smooth + Grok-voice-mode feel, instead of stop-and-swap. + +Design notes +------------ +* The mixer is installed **once** per guild on join (``vc.play(mixer)``) and + runs continuously until the bot leaves. Children come and go; the mixer + itself never stops, so there is no ``is_playing()`` race between an + acknowledgement and the final reply. +* Frame format is Discord-native: 48 kHz, 2 channels, signed 16-bit LE, + 20 ms per frame == ``discord.opus.Encoder.FRAME_SIZE`` bytes + (3840 = 960 samples * 2 channels * 2 bytes). +* Mixing is a single vectorised int32 add + clip per 20 ms frame (numpy, + already a core dependency). CPU cost is negligible. +* :meth:`read` is called from discord.py's audio sender **thread**, while + children are added/removed from the asyncio event loop thread, so all + shared state is guarded by a plain ``threading.Lock``. + +The mixer NEVER touches the inbound receive path: it only produces the bot's +*outgoing* stream. The :class:`VoiceReceiver` decodes incoming SSRCs only, so +the mixer's output cannot echo back into transcription. +""" + +import logging +import threading +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: # numpy is an optional ("voice" extra) dep — never import at runtime top-level + import numpy as np + +logger = logging.getLogger(__name__) + + +def _require_numpy(): + """Import numpy lazily. + + numpy ships in the optional ``voice`` extra, not the base install, so this + module must import cleanly without it (the Discord adapter imports this + file unconditionally). Callers that actually mix audio call this; if the + voice extra isn't installed they get a clear error instead of a top-level + ImportError that would break the whole adapter import. + """ + import numpy as np # noqa: PLC0415 — intentional lazy import + return np + +# Discord-native frame geometry (matches discord.opus.Encoder). +SAMPLE_RATE = 48000 +CHANNELS = 2 +SAMPLE_WIDTH = 2 # bytes per sample (s16) +FRAME_LENGTH_MS = 20 +SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960 +FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes +SILENCE_FRAME = b"\x00" * FRAME_SIZE + + +class MixerChild: + """A single audio stream feeding into :class:`VoiceMixer`. + + Wraps raw 48 kHz / stereo / s16le PCM bytes. ``read_frame`` hands back one + 20 ms frame at a time, optionally looping, with a per-child gain applied. + """ + + __slots__ = ( + "name", "_pcm", "_pos", "loop", "gain", + "is_speech", "fade_frames", "_fade_done", "_finished", + ) + + def __init__( + self, + name: str, + pcm: bytes, + *, + loop: bool = False, + gain: float = 1.0, + is_speech: bool = False, + fade_in_ms: int = 0, + ): + # Pad to a whole number of frames so looping is seamless and the final + # partial frame doesn't click. + remainder = len(pcm) % FRAME_SIZE + if remainder: + pcm = pcm + b"\x00" * (FRAME_SIZE - remainder) + self.name = name + self._pcm = pcm + self._pos = 0 + self.loop = loop + self.gain = float(gain) + self.is_speech = is_speech + # Linear fade-in over N frames avoids a click when a loud child starts. + self.fade_frames = max(0, fade_in_ms // FRAME_LENGTH_MS) + self._fade_done = 0 + self._finished = False + + @property + def finished(self) -> bool: + return self._finished + + def read_frame(self) -> "Optional[np.ndarray]": + """Return the next 20 ms frame as an int16 ndarray, or None if done.""" + if self._finished: + return None + if self._pos >= len(self._pcm): + if self.loop and self._pcm: + self._pos = 0 + else: + self._finished = True + return None + + np = _require_numpy() + chunk = self._pcm[self._pos:self._pos + FRAME_SIZE] + self._pos += FRAME_SIZE + if len(chunk) < FRAME_SIZE: + chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk)) + + samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) + + gain = self.gain + if self.fade_frames and self._fade_done < self.fade_frames: + self._fade_done += 1 + gain *= self._fade_done / self.fade_frames + + if gain != 1.0: + samples = samples * gain + return samples + + +class VoiceMixer: + """A continuous ``discord.AudioSource`` that mixes N child streams. + + Use :meth:`set_ambient` to install/replace the looping idle bed and + :meth:`play_speech` to layer a one-shot clip over it (ducking the ambient + while it plays). Both are safe to call from the asyncio loop thread while + discord.py drains :meth:`read` from its sender thread. + """ + + # discord.AudioSource subclasses set is_opus()==False to receive PCM. + def is_opus(self) -> bool: # pragma: no cover - trivial + return False + + def __init__( + self, + *, + ambient_gain: float = 0.18, + duck_gain: float = 0.06, + speech_gain: float = 1.0, + duck_release_ms: int = 400, + ): + self._lock = threading.Lock() + self._ambient: Optional[MixerChild] = None + self._speech: List[MixerChild] = [] + self._ambient_gain = float(ambient_gain) + self._duck_gain = float(duck_gain) + self._speech_gain = float(speech_gain) + # When speech ends, ramp the ambient back up over this many frames + # instead of jumping, so the bed swells back smoothly. + self._duck_release_frames = max(1, duck_release_ms // FRAME_LENGTH_MS) + self._duck_release_left = 0 + self._closed = False + # Tracks whether speech is currently active, for external callers that + # want to avoid double-ducking or know when a reply is mid-flight. + self._speech_active = False + + # ------------------------------------------------------------------ + # Ambient (idle / "thinking") bed + # ------------------------------------------------------------------ + + def set_ambient(self, pcm: Optional[bytes], *, gain: Optional[float] = None) -> None: + """Install (or clear, with ``pcm=None``) the looping ambient bed.""" + with self._lock: + if gain is not None: + self._ambient_gain = float(gain) + if not pcm: + self._ambient = None + return + self._ambient = MixerChild( + "ambient", pcm, loop=True, + gain=self._effective_ambient_gain(), fade_in_ms=200, + ) + + def _effective_ambient_gain(self) -> float: + return self._duck_gain if self._speech_active else self._ambient_gain + + # ------------------------------------------------------------------ + # Speech (TTS replies, verbal acks) layered over the ambient bed + # ------------------------------------------------------------------ + + def play_speech(self, pcm: bytes, *, gain: Optional[float] = None, + fade_in_ms: int = 40) -> None: + """Layer a one-shot speech clip over the ambient bed (ducks ambient).""" + if not pcm: + return + with self._lock: + child = MixerChild( + "speech", pcm, loop=False, + gain=self._speech_gain if gain is None else float(gain), + is_speech=True, fade_in_ms=fade_in_ms, + ) + self._speech.append(child) + self._speech_active = True + self._duck_release_left = 0 + if self._ambient is not None: + self._ambient.gain = self._duck_gain + + @property + def speech_active(self) -> bool: + with self._lock: + return self._speech_active + + def stop_speech(self) -> None: + """Drop any in-flight speech immediately and release the duck.""" + with self._lock: + self._speech.clear() + self._begin_duck_release_locked() + + def _begin_duck_release_locked(self) -> None: + self._speech_active = False + self._duck_release_left = self._duck_release_frames + + # ------------------------------------------------------------------ + # AudioSource interface — called from discord.py's sender thread + # ------------------------------------------------------------------ + + def read(self) -> bytes: + """Return one 20 ms mixed PCM frame (always FRAME_SIZE bytes). + + Returning a non-empty frame keeps discord.py's player alive; we never + return b"" because that would stop the single underlying stream and we + want the mixer to run continuously for the lifetime of the connection. + """ + with self._lock: + if self._closed: + return SILENCE_FRAME + + np = _require_numpy() + acc: "Optional[np.ndarray]" = None + + # Speech children (drop exhausted ones; release duck when last ends) + if self._speech: + still_live: List[MixerChild] = [] + for child in self._speech: + frame = child.read_frame() + if frame is None: + continue + acc = frame if acc is None else acc + frame + still_live.append(child) + self._speech = still_live + if not self._speech and self._speech_active: + self._begin_duck_release_locked() + + # Ambient bed — ramp gain back up during duck-release. + if self._ambient is not None: + if self._duck_release_left > 0 and not self._speech_active: + self._duck_release_left -= 1 + frac = 1.0 - (self._duck_release_left / self._duck_release_frames) + self._ambient.gain = ( + self._duck_gain + + (self._ambient_gain - self._duck_gain) * frac + ) + elif not self._speech_active and self._duck_release_left == 0: + self._ambient.gain = self._ambient_gain + amb = self._ambient.read_frame() + if amb is not None: + acc = amb if acc is None else acc + amb + + if acc is None: + return SILENCE_FRAME + + np.clip(acc, -32768, 32767, out=acc) + return acc.astype(np.int16).tobytes() + + def cleanup(self) -> None: # called by discord.py when playback stops + with self._lock: + self._closed = True + self._ambient = None + self._speech.clear() + + +# ---------------------------------------------------------------------- +# PCM helpers +# ---------------------------------------------------------------------- + +def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]: + """Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg. + + Returns the raw PCM bytes, or None on failure. ffmpeg is already a hard + requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``). + """ + import subprocess + + try: + proc = subprocess.run( + [ + "ffmpeg", "-y", "-loglevel", "error", + "-i", path, + "-f", "s16le", + "-ar", str(SAMPLE_RATE), + "-ac", str(CHANNELS), + "pipe:1", + ], + capture_output=True, + timeout=timeout, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + logger.warning("decode_to_pcm failed for %s: %s", path, e) + return None + if proc.returncode != 0: + logger.warning( + "ffmpeg decode failed for %s (rc=%d): %s", + path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200], + ) + return None + return proc.stdout or None + + +def synth_ambient_pcm(seconds: float = 4.0) -> bytes: + """Synthesise a subtle looping ambient bed (no asset file required). + + A soft, slowly-pulsing low pad: two detuned sine partials with a gentle + tremolo, plus a touch of filtered noise. Designed to loop seamlessly + (whole number of cycles, zero-crossing endpoints) and sit quietly under + speech. Mono content duplicated to stereo. + """ + np = _require_numpy() + n = int(SAMPLE_RATE * seconds) + t = np.arange(n, dtype=np.float64) / SAMPLE_RATE + + # Choose base frequencies that complete whole cycles over the loop so the + # wrap point is click-free. + def _whole_cycle_freq(target: float) -> float: + cycles = max(1, round(target * seconds)) + return cycles / seconds + + f1 = _whole_cycle_freq(110.0) + f2 = _whole_cycle_freq(110.5) + trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo + + pad = ( + 0.55 * np.sin(2 * np.pi * f1 * t) + + 0.45 * np.sin(2 * np.pi * f2 * t) + ) + tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t))) + signal = pad * tremolo + + # Smooth filtered noise for air, kept very low. + rng = np.random.default_rng(7) + noise = rng.standard_normal(n) + kernel = np.ones(64) / 64.0 + noise = np.convolve(noise, kernel, mode="same") + signal = signal + 0.08 * noise + + # Normalise to a modest peak (mixer applies the real ambient gain on top). + peak = float(np.max(np.abs(signal))) or 1.0 + signal = (signal / peak) * 0.5 + + mono16 = (signal * 32767.0).astype(np.int16) + stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1) + return stereo16.tobytes() diff --git a/tests/gateway/test_discord_voice_mixer.py b/tests/gateway/test_discord_voice_mixer.py new file mode 100644 index 000000000000..dae6da27f4b1 --- /dev/null +++ b/tests/gateway/test_discord_voice_mixer.py @@ -0,0 +1,264 @@ +"""Tests for the Discord continuous voice mixer (ambient + ducked speech) +and the verbal-ack-before-tool-calls hook. + +The mixer (plugins/platforms/discord/voice_mixer.py) is pure-PCM and has no +discord.py dependency, so its core is tested directly. The adapter +integration (install on join, play routing, ack) is tested with the standard +``object.__new__(DiscordAdapter)`` helper used elsewhere in the voice suite. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# numpy ships only in the optional "voice" extra (not [all,dev]); the mixer +# math needs it, so skip this whole module when it isn't installed. +np = pytest.importorskip("numpy") + +# voice_mixer lives inside the discord plugin package dir; import by path the +# same way the adapter does. +_DISCORD_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "plugins", "platforms", "discord", +) +if _DISCORD_DIR not in sys.path: + sys.path.insert(0, _DISCORD_DIR) + +import voice_mixer as vm # noqa: E402 + + +# ===================================================================== +# Pure mixer unit tests +# ===================================================================== + +class TestVoiceMixerCore: + def test_frame_geometry_matches_discord(self): + # 20ms @ 48kHz stereo s16 == 3840 bytes (discord.opus.Encoder.FRAME_SIZE) + assert vm.FRAME_SIZE == 3840 + assert vm.SAMPLES_PER_FRAME == 960 + assert len(vm.SILENCE_FRAME) == vm.FRAME_SIZE + + def test_empty_mixer_returns_silence_frames(self): + mx = vm.VoiceMixer() + for _ in range(5): + frame = mx.read() + assert len(frame) == vm.FRAME_SIZE + assert frame == vm.SILENCE_FRAME + + def test_is_opus_false(self): + # discord.py sends raw PCM when is_opus() is False. + assert vm.VoiceMixer().is_opus() is False + + def test_ambient_loops_and_is_quiet(self): + mx = vm.VoiceMixer(ambient_gain=0.2) + amb = vm.synth_ambient_pcm(seconds=0.5) + assert len(amb) % vm.FRAME_SIZE == 0 # frame-aligned for seamless loop + mx.set_ambient(amb) + peaks = [int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16)))) + for _ in range(100)] # 2s >> 0.5s loop + # Produces audio after the fade-in and stays under the configured gain. + assert any(p > 0 for p in peaks[10:]) + assert max(peaks) < int(32767 * 0.5) + + def test_speech_audible_over_ambient_then_releases(self): + mx = vm.VoiceMixer(ambient_gain=0.2, duck_gain=0.05, duck_release_ms=200) + mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5)) + base = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16)))) + for _ in range(10)) + tone = (np.sin(2 * np.pi * 440 * np.arange(int(48000 * 0.4)) / 48000) + * 20000).astype(np.int16) + stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes() + mx.play_speech(stereo, fade_in_ms=0) + assert mx.speech_active + speech_peak = max(int(np.max(np.abs(np.frombuffer(mx.read(), dtype=np.int16)))) + for _ in range(15)) + assert speech_peak > base + # Drain past speech + release ramp; speech_active clears. + for _ in range(40): + mx.read() + assert not mx.speech_active + + def test_clipping_prevents_int16_wraparound(self): + mx = vm.VoiceMixer() + loud = (np.ones(vm.SAMPLES_PER_FRAME * 2) * 30000).astype(np.int16).tobytes() + mx.play_speech(loud, fade_in_ms=0) + mx.play_speech(loud, fade_in_ms=0) + out = np.frombuffer(mx.read(), dtype=np.int16) + assert int(out.max()) == 32767 # clamped, not wrapped to negative + assert int(out.min()) >= -32768 + + def test_stop_speech_clears_in_flight(self): + mx = vm.VoiceMixer() + tone = (np.ones(48000) * 10000).astype(np.int16) + stereo = np.repeat(tone[:, None], 2, axis=1).reshape(-1).tobytes() + mx.play_speech(stereo) + assert mx.speech_active + mx.stop_speech() + mx.read() + assert not mx.speech_active + + def test_set_ambient_none_clears(self): + mx = vm.VoiceMixer() + mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5)) + mx.set_ambient(None) + # No ambient, no speech -> silence. + assert mx.read() == vm.SILENCE_FRAME + + def test_cleanup_silences(self): + mx = vm.VoiceMixer() + mx.set_ambient(vm.synth_ambient_pcm(seconds=0.5)) + mx.cleanup() + assert mx.read() == vm.SILENCE_FRAME + + def test_pcm_not_frame_aligned_is_padded(self): + # Odd-length PCM must be padded to whole frames (no IndexError, no click). + mx = vm.VoiceMixer() + mx.play_speech(b"\x01\x02\x03", fade_in_ms=0) # 3 bytes << one frame + out = mx.read() + assert len(out) == vm.FRAME_SIZE + + def test_synth_ambient_is_stereo_and_frame_aligned(self): + pcm = vm.synth_ambient_pcm(seconds=1.0) + assert len(pcm) % (vm.CHANNELS * vm.SAMPLE_WIDTH) == 0 + assert len(pcm) % vm.FRAME_SIZE == 0 + + +# ===================================================================== +# Adapter integration +# ===================================================================== + +def _make_adapter(fx_cfg=None): + from plugins.platforms.discord.adapter import DiscordAdapter + from gateway.config import Platform, PlatformConfig + config = PlatformConfig(enabled=True, extra={}) + config.token = "fake-token" + adapter = object.__new__(DiscordAdapter) + adapter.platform = Platform.DISCORD + adapter.config = config + adapter._client = MagicMock() + adapter._voice_clients = {} + adapter._voice_locks = {} + adapter._voice_text_channels = {} + adapter._voice_sources = {} + adapter._voice_timeout_tasks = {} + adapter._voice_receivers = {} + adapter._voice_listen_tasks = {} + adapter._voice_mixers = {} + adapter._ambient_pcm_cache = None + adapter._voice_fx_cfg = fx_cfg if fx_cfg is not None else { + "enabled": True, "ambient_enabled": True, "ambient_path": "", + "ambient_gain": 0.18, "duck_gain": 0.06, "speech_gain": 1.0, + "ack_enabled": True, "ack_phrases": ["One moment."], + } + return adapter + + +class TestVoiceMixerActive: + def test_false_when_no_mixer(self): + adapter = _make_adapter() + assert adapter.voice_mixer_active(111) is False + + def test_true_when_mixer_present(self): + adapter = _make_adapter() + adapter._voice_mixers[111] = object() + assert adapter.voice_mixer_active(111) is True + + def test_false_when_attr_missing(self): + # Defensive getattr path (object.__new__ helper that forgot the attr). + from plugins.platforms.discord.adapter import DiscordAdapter + from gateway.config import Platform + bare = object.__new__(DiscordAdapter) + bare.platform = Platform.DISCORD + assert bare.voice_mixer_active(111) is False + + +class TestPlayInVoiceChannelMixerPath: + @pytest.mark.asyncio + async def test_routes_through_mixer_when_present(self): + adapter = _make_adapter() + vc = MagicMock() + vc.is_connected.return_value = True + adapter._voice_clients[111] = vc + + # speech_active returns True once (so play_speech is observed) then + # False so the wait loop exits promptly. + class _Mixer: + def __init__(self): + self._polls = 0 + self.play_speech = MagicMock() + + @property + def speech_active(self): + self._polls += 1 + return self._polls <= 1 + + mixer = _Mixer() + adapter._voice_mixers[111] = mixer + adapter._reset_voice_timeout = MagicMock() + + fake_pcm = b"\x00" * vm.FRAME_SIZE + with patch.object(vm, "decode_to_pcm", return_value=fake_pcm): + ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3") + assert ok is True + mixer.play_speech.assert_called_once() + # Legacy path must NOT have been used. + vc.play.assert_not_called() + + @pytest.mark.asyncio + async def test_falls_back_when_decode_fails(self): + adapter = _make_adapter() + vc = MagicMock() + vc.is_connected.return_value = True + vc.is_playing.return_value = False + adapter._voice_clients[111] = vc + adapter._voice_mixers[111] = MagicMock() + adapter._reset_voice_timeout = MagicMock() + adapter._voice_receivers[111] = MagicMock() + + with patch.object(vm, "decode_to_pcm", return_value=None), \ + patch("plugins.platforms.discord.adapter.discord") as mock_discord: + mock_discord.FFmpegPCMAudio.return_value = MagicMock() + mock_discord.PCMVolumeTransformer.return_value = MagicMock() + + # Make the legacy wait loop resolve immediately without leaving the + # real Event.wait() coroutine unawaited. + async def _fast(coro, *a, **k): + if hasattr(coro, "close"): + coro.close() + return None + with patch("asyncio.wait_for", _fast): + ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3") + # Fell through to legacy path -> vc.play called. + assert vc.play.called + + +class TestPlayAckInVoice: + @pytest.mark.asyncio + async def test_noop_when_ack_disabled(self): + adapter = _make_adapter({"ack_enabled": False}) + adapter._voice_mixers[111] = MagicMock() + assert await adapter.play_ack_in_voice(111) is False + + @pytest.mark.asyncio + async def test_noop_when_no_mixer(self): + adapter = _make_adapter() + assert await adapter.play_ack_in_voice(111) is False + + @pytest.mark.asyncio + async def test_plays_speech_when_armed(self, tmp_path): + adapter = _make_adapter() + mixer = MagicMock() + adapter._voice_mixers[111] = mixer + adapter._reset_voice_timeout = MagicMock() + + ack_file = tmp_path / "ack.mp3" + ack_file.write_bytes(b"id3") + import json as _json + with patch("tools.tts_tool.text_to_speech_tool", + return_value=_json.dumps({"success": True, "file_path": str(ack_file)})), \ + patch.object(vm, "decode_to_pcm", return_value=b"\x00" * vm.FRAME_SIZE): + ok = await adapter.play_ack_in_voice(111, phrase="Testing one two.") + assert ok is True + mixer.play_speech.assert_called_once() diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 60b3cacd61cc..6ffa44db6c57 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -683,6 +683,37 @@ For the full setup and operational guide, see: - [Voice Mode](/user-guide/features/voice-mode) - [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes) +### Voice Channel Audio Effects (ambient + verbal acks) + +When the bot is in a voice channel, you can give it a more conversational feel: a short verbal acknowledgement ("let me look into that") before it starts working, and a subtle ambient "thinking" bed that plays underneath while tools run — the speech ducks the ambient down and swells it back when finished, similar to Grok voice mode. + +discord.py plays only one audio stream per connection, so Hermes installs a software mixer on the outgoing stream that sums an ambient loop, acknowledgements, and TTS replies into that single stream — they overlap instead of cutting each other off. + +This is **off by default**. Enable it in `config.yaml`: + +```yaml +discord: + voice_fx: + enabled: true # master switch + ambient_enabled: true # idle "thinking" bed while tools run + ambient_path: "" # custom loop file (any audio format); "" = built-in synthesised pad + ambient_gain: 0.18 # idle bed loudness (0.0–1.0) + duck_gain: 0.06 # ambient loudness while the bot is speaking + speech_gain: 1.0 # TTS / acknowledgement loudness + ack_enabled: true # speak a short phrase before the first tool call of a turn + ack_phrases: # picked at random; set to [] to disable the spoken ack + - "Let me look into that." + - "One moment." + - "Checking on that now." +``` + +Notes: +- The acknowledgement fires at most once per turn, only when the bot is in a voice channel and the mixer is active. It uses your configured TTS provider. +- `ambient_path` accepts any file `ffmpeg` can decode; it's looped seamlessly. Leave it empty to use the built-in synthesised pad (no asset needed). +- All settings live in `config.yaml` (not `.env`) — they're behavioral, not secrets. +- When `voice_fx.enabled` is `false`, voice playback uses the original one-shot path and nothing changes. + + ## Forum Channels Discord forum channels (type 15) don't accept direct messages — every post in a forum must be a thread. Hermes auto-detects forum channels and creates a new thread post whenever it needs to send there, so `send_message`, TTS, images, voice messages, and file attachments all work without special handling from the agent. From 6bf55a473ee9b7f32fe94d60acac33482db76899 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Fri, 5 Jun 2026 12:52:28 +1000 Subject: [PATCH 040/541] Add CLI Telegram QR onboarding Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- hermes_cli/gateway.py | 51 +++ hermes_cli/setup.py | 117 +++++- hermes_cli/telegram_managed_bot.py | 358 ++++++++++++++++++ tests/hermes_cli/test_telegram_managed_bot.py | 323 ++++++++++++++++ 4 files changed, 833 insertions(+), 16 deletions(-) create mode 100644 hermes_cli/telegram_managed_bot.py create mode 100644 tests/hermes_cli/test_telegram_managed_bot.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 05dc69e23823..c857ae71fd34 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4382,6 +4382,35 @@ def _setup_standard_platform(platform: dict): if not prompt_yes_no(f" Reconfigure {label}?", False): return + auto_token_saved = False + auto_owner_user_id = None + if platform.get("key") == "telegram": + print() + print_info(" Telegram can be configured automatically with a managed bot:") + print_info(" [1] Automatic (scan QR → confirm in Telegram → done)") + print_info(" [2] Manual BotFather token") + choice = prompt(" Choice [1/2]", default="1") + if choice.strip() == "1": + try: + from hermes_cli.telegram_managed_bot import ( + auto_setup_telegram_bot_result, + is_valid_telegram_bot_token, + ) + except ImportError: + print_warning(" Automatic setup is unavailable in this install.") + else: + result = auto_setup_telegram_bot_result() + if result and is_valid_telegram_bot_token(result.token): + save_env_value(token_var, result.token) + print_success(" Saved TELEGRAM_BOT_TOKEN") + auto_token_saved = True + auto_owner_user_id = result.owner_user_id + else: + if result: + print_warning(" Automatic setup returned an invalid Telegram token.") + print() + print_info(" Falling back to manual setup...") + allowed_val_set = None # Track if user set an allowlist (for home channel offer) for var in platform["vars"]: @@ -4391,8 +4420,30 @@ def _setup_standard_platform(platform: dict): if existing and var["name"] != token_var: print_info(f" Current: {existing}") + if auto_token_saved and var["name"] == token_var: + print_info(" Token saved by automatic setup.") + continue + # Allowlist fields get special handling for the deny-by-default security model if var.get("is_allowlist"): + if "TELEGRAM" in var["name"] and auto_owner_user_id: + detected_id = str(auto_owner_user_id) + print_success(f" Detected your Telegram user ID: {detected_id}") + if prompt_yes_no(" Allow this Telegram account to use the bot?", True): + extra = prompt( + " Additional allowed user IDs (comma-separated, optional)", + password=False, + ) + ids = [detected_id] + for uid in extra.replace(" ", "").split(","): + if uid and uid not in ids: + ids.append(uid) + cleaned = ",".join(ids) + save_env_value(var["name"], cleaned) + print_success(" Saved — only these users can interact with the bot.") + allowed_val_set = cleaned + continue + print_info(" The gateway DENIES all users by default for security.") print_info(" Enter user IDs to create an allowlist, or leave empty") print_info(" and you'll be asked about open access next.") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8930d543ad0b..fe304dee0895 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1637,6 +1637,52 @@ def setup_agent_settings(config: dict): # ============================================================================= +_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$") + + +def _is_valid_telegram_bot_token(token: str) -> bool: + return bool(_TELEGRAM_BOT_TOKEN_RE.match(token)) + + +def _setup_telegram_auto_result(): + """Attempt automatic Telegram bot creation via managed QR onboarding.""" + try: + from hermes_cli.telegram_managed_bot import auto_setup_telegram_bot_result + except ImportError: + return None + + profile_name: str | None = None + try: + hermes_home = str(get_hermes_home()) + if "/profiles/" in hermes_home: + profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1] + except Exception: + pass + + return auto_setup_telegram_bot_result(profile_name=profile_name) + + +def _setup_telegram_auto() -> str | None: + """Attempt automatic Telegram bot creation and return only the token.""" + result = _setup_telegram_auto_result() + return result.token if result else None + + +def _prompt_telegram_bot_token() -> str | None: + print_info("Create a bot via @BotFather on Telegram") + while True: + token = prompt("Telegram bot token", password=True) + if not token: + return None + if not _is_valid_telegram_bot_token(token): + print_error( + "Invalid token format. Expected: : " + "(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)" + ) + continue + return token + + def _setup_telegram(): """Configure Telegram bot credentials and allowlist.""" print_header("Telegram") @@ -1655,20 +1701,40 @@ def _setup_telegram(): print_success("Telegram allowlist configured") return - print_info("Create a bot via @BotFather on Telegram") - import re + print_info("How would you like to create your Telegram bot?") + print() + print_info(" [1] Automatic (recommended)") + print_info(" Scan a QR code → confirm in Telegram → done.") + print_info(" No token copy-paste needed.") + print() + print_info(" [2] Manual") + print_info(" Create a bot via @BotFather yourself and paste the token.") + print() - while True: - token = prompt("Telegram bot token", password=True) + choice = prompt("Choice [1/2]", default="1") + token = None + setup_result = None + + if choice.strip() == "1": + setup_result = _setup_telegram_auto_result() + if setup_result: + token = setup_result.token + if not _is_valid_telegram_bot_token(token): + print_error("Automatic setup returned an invalid Telegram bot token.") + token = None + setup_result = None + else: + token = None if not token: - return - if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token): - print_error( - "Invalid token format. Expected: : " - "(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)" - ) - continue - break + print() + print_info("Falling back to manual setup...") + print() + + if not token: + token = _prompt_telegram_bot_token() + if not token: + return + save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") @@ -1678,11 +1744,30 @@ def _setup_telegram(): print_info(" 1. Message @userinfobot on Telegram") print_info(" 2. It will reply with your numeric ID (e.g., 123456789)") print() - allowed_users = prompt( - "Allowed user IDs (comma-separated, leave empty for open access)" - ) + + detected_user_id = getattr(setup_result, "owner_user_id", None) + if detected_user_id: + detected_id = str(detected_user_id) + print_success(f"Detected your Telegram user ID: {detected_id}") + if prompt_yes_no("Allow this Telegram account to use the bot?", True): + extra = prompt("Additional allowed user IDs (comma-separated, optional)") + ids = [detected_id] + for uid in extra.replace(" ", "").split(","): + if uid and uid not in ids: + ids.append(uid) + allowed_users = ",".join(ids) + else: + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) + else: + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) + if allowed_users: - save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) + allowed_users = allowed_users.replace(" ", "") + save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users) print_success("Telegram allowlist configured - only listed users can use the bot") else: print_info("⚠️ No allowlist set - anyone who finds your bot can use it!") diff --git a/hermes_cli/telegram_managed_bot.py b/hermes_cli/telegram_managed_bot.py new file mode 100644 index 000000000000..89395d5ff6bb --- /dev/null +++ b/hermes_cli/telegram_managed_bot.py @@ -0,0 +1,358 @@ +"""Telegram Managed Bot onboarding client. + +Uses Telegram's Managed Bots feature to create a user-owned child bot without +manual BotFather token copy-paste. Hermes talks only to the Nous onboarding +service; the raw Telegram token is saved locally after one-time retrieval. +""" + +from __future__ import annotations + +import os +import re +import secrets +import sys +import time +import urllib.parse +from dataclasses import dataclass +from typing import Optional + +import httpx + +# Default pairing API base URL (Nous-hosted Cloudflare Worker). +# Override for PoC/staging with TELEGRAM_ONBOARDING_URL. +DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com" +TELEGRAM_ONBOARDING_URL_ENV = "TELEGRAM_ONBOARDING_URL" + +# The Nous-hosted manager bot username (without @). The backend returns the +# actual deep link, so this is only used by local helpers/tests. +DEFAULT_MANAGER_BOT = "HermesSetupBot" + +DEFAULT_BOT_NAME = "Hermes Agent" +DEFAULT_POLL_TIMEOUT = 180 +POLL_INTERVAL = 2 + +_USERNAME_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" +_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$") + + +@dataclass(frozen=True) +class TelegramPairing: + """Pairing record returned by the Telegram onboarding service.""" + + pairing_id: str + poll_token: str + suggested_username: str + deep_link: str + qr_payload: str + expires_at: str | None = None + + +@dataclass(frozen=True) +class TelegramBotSetupResult: + """Successful Telegram onboarding result returned by the setup service.""" + + token: str + bot_username: str | None = None + owner_user_id: int | None = None + + +def _api_url(api_url: str | None = None) -> str: + """Resolve the onboarding API URL, honoring the PoC env override.""" + return ( + api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL + ).rstrip("/") + + +def is_valid_telegram_bot_token(token: object) -> bool: + """Return True when *token* has Telegram's bot-token shape.""" + return isinstance(token, str) and bool(_TELEGRAM_BOT_TOKEN_RE.match(token)) + + +def _parse_owner_user_id(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + if isinstance(value, str) and value.isdecimal(): + parsed = int(value) + return parsed if parsed > 0 else None + return None + + +def render_qr_terminal(url: str) -> str: + """Render a URL as a QR code string suitable for terminal output.""" + try: + import io + + import qrcode # type: ignore[import-untyped] + + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=1, + border=1, + ) + qr.add_data(url) + qr.make(fit=True) + + buf = io.StringIO() + qr.print_ascii(out=buf, invert=True) + return buf.getvalue() + except ImportError: + return "" + + +def print_qr_code(url: str, *, include_link: bool = True) -> None: + """Print a QR code to stdout, with URL fallback if qrcode is missing.""" + qr_text = render_qr_terminal(url) + if qr_text: + print(qr_text) + else: + print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)") + if include_link: + print(f" Link: {url}") + + +def generate_username_slug(length: int = 16) -> str: + """Generate a base32-ish slug for Telegram username correlation. + + Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while + keeping ``hermes__bot`` under Telegram's 32-character username limit. + """ + return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length)) + + +def generate_bot_username(profile_name: Optional[str] = None) -> str: + """Generate a secure suggested bot username like ``hermes__bot``. + + ``profile_name`` is accepted for backward compatibility with the original + PoC, but is intentionally not embedded in the username. The username has to + carry enough entropy for backend correlation. + """ + _ = profile_name + return f"hermes_{generate_username_slug()}_bot" + + +def generate_deep_link( + manager_bot: str = DEFAULT_MANAGER_BOT, + suggested_username: Optional[str] = None, + suggested_name: Optional[str] = None, +) -> str: + """Build a ``t.me/newbot`` deep link for managed bot creation.""" + manager = manager_bot.lstrip("@") + username = suggested_username or generate_bot_username() + base_url = ( + "https://t.me/newbot/" + f"{urllib.parse.quote(manager)}/" + f"{urllib.parse.quote(username)}" + ) + + if suggested_name: + params = urllib.parse.urlencode({"name": suggested_name}) + return f"{base_url}?{params}" + return base_url + + +def generate_pairing_nonce() -> str: + """Generate a legacy-compatible random nonce string. + + The new protocol uses service-created ``pairing_id`` + bearer + ``poll_token`` instead of a path nonce, but this helper is harmless and + still useful for callers/tests that need a generic random id. + """ + return secrets.token_hex(16) + + +def create_pairing( + api_url: str | None = None, + bot_name: str = DEFAULT_BOT_NAME, + timeout: float = 10.0, +) -> TelegramPairing | None: + """Create a Telegram onboarding pairing. + + ``POST /v1/telegram/pairings`` returns the deep link, QR payload, public + pairing id, and secret poll token. The token is only used as a bearer + credential while polling. + """ + try: + resp = httpx.post( + f"{_api_url(api_url)}/v1/telegram/pairings", + json={"bot_name": bot_name}, + timeout=timeout, + ) + if resp.status_code not in (200, 201): + return None + data = resp.json() + except (httpx.HTTPError, ValueError): + return None + + required = ("pairing_id", "poll_token", "suggested_username", "deep_link") + if not all(isinstance(data.get(key), str) and data.get(key) for key in required): + return None + + qr_payload = data.get("qr_payload") or data["deep_link"] + if not isinstance(qr_payload, str): + return None + + expires_at = data.get("expires_at") + return TelegramPairing( + pairing_id=data["pairing_id"], + poll_token=data["poll_token"], + suggested_username=data["suggested_username"], + deep_link=data["deep_link"], + qr_payload=qr_payload, + expires_at=expires_at if isinstance(expires_at, str) else None, + ) + + +def poll_pairing_result_once( + api_url: str | None, + pairing: TelegramPairing, + timeout: float = 10.0, +) -> TelegramBotSetupResult | None: + """Poll the onboarding service once. Returns setup metadata when ready.""" + resp = httpx.get( + f"{_api_url(api_url)}/v1/telegram/pairings/{pairing.pairing_id}", + headers={"Authorization": f"Bearer {pairing.poll_token}"}, + timeout=timeout, + ) + if resp.status_code != 200: + return None + + data = resp.json() + if data.get("status") != "ready": + return None + token = data.get("token") + if not is_valid_telegram_bot_token(token): + return None + + bot_username = data.get("bot_username") + return TelegramBotSetupResult( + token=token, + bot_username=bot_username + if isinstance(bot_username, str) and bot_username + else None, + owner_user_id=_parse_owner_user_id(data.get("owner_user_id")), + ) + + +def poll_pairing_once( + api_url: str | None, + pairing: TelegramPairing, + timeout: float = 10.0, +) -> str | None: + """Poll the onboarding service once. Returns the token when ready.""" + result = poll_pairing_result_once(api_url, pairing, timeout=timeout) + return result.token if result else None + + +def poll_for_setup_result( + api_url: str | None, + pairing: TelegramPairing, + timeout: float = DEFAULT_POLL_TIMEOUT, + interval: float = POLL_INTERVAL, +) -> Optional[TelegramBotSetupResult]: + """Poll the pairing API until setup metadata is available or timeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + result = poll_pairing_result_once(api_url, pairing) + if result: + return result + except (httpx.HTTPError, ValueError): + pass + time.sleep(interval) + return None + + +def poll_for_token( + api_url: str | None, + pairing: TelegramPairing, + timeout: float = DEFAULT_POLL_TIMEOUT, + interval: float = POLL_INTERVAL, +) -> Optional[str]: + """Poll the pairing API until the bot token is available or timeout.""" + result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval) + return result.token if result else None + + +def auto_setup_telegram_bot_result( + api_url: str | None = None, + manager_bot: str = DEFAULT_MANAGER_BOT, + profile_name: Optional[str] = None, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, +) -> Optional[TelegramBotSetupResult]: + """Run the full automatic Telegram bot creation flow.""" + _ = manager_bot, profile_name + resolved_api_url = _api_url(api_url) + print() + print(f" Contacting Hermes Telegram onboarding service: {resolved_api_url}") + sys.stdout.flush() + pairing = create_pairing(resolved_api_url) + if not pairing: + print(" ✗ Could not reach the Hermes Telegram onboarding service.") + print(" Try the manual setup instead, or check your network.") + return None + + print(" ✓ Pairing created") + print(" Rendering QR code...") + sys.stdout.flush() + print() + print(" Scan this QR code with your phone, or open the link below:") + print() + print_qr_code(pairing.qr_payload, include_link=False) + print() + print(f" Link: {pairing.deep_link}") + print() + print(" When Telegram opens, tap 'Create Bot' to confirm.") + print(" (You can edit the bot display name before confirming)") + print() + + spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + start = time.monotonic() + deadline = start + poll_timeout + idx = 0 + + while time.monotonic() < deadline: + char = spinner_chars[idx % len(spinner_chars)] + elapsed = int(time.monotonic() - start) + remaining = max(0, int(poll_timeout - elapsed)) + sys.stdout.write( + f"\r {char} Waiting for bot creation... ({remaining}s remaining) " + ) + sys.stdout.flush() + idx += 1 + + try: + result = poll_pairing_result_once(resolved_api_url, pairing) + if result: + sys.stdout.write( + "\r ✓ Bot created successfully! \n" + ) + sys.stdout.flush() + return result + except (httpx.HTTPError, ValueError): + pass + time.sleep(POLL_INTERVAL) + + sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n") + sys.stdout.flush() + print(" The bot may still be created — check Telegram.") + print(" You can paste the token manually below, or re-run setup.") + return None + + +def auto_setup_telegram_bot( + api_url: str | None = None, + manager_bot: str = DEFAULT_MANAGER_BOT, + profile_name: Optional[str] = None, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, +) -> Optional[str]: + """Run automatic Telegram bot creation and return only the bot token.""" + result = auto_setup_telegram_bot_result( + api_url=api_url, + manager_bot=manager_bot, + profile_name=profile_name, + poll_timeout=poll_timeout, + ) + return result.token if result else None diff --git a/tests/hermes_cli/test_telegram_managed_bot.py b/tests/hermes_cli/test_telegram_managed_bot.py new file mode 100644 index 000000000000..1fa0ebfe014d --- /dev/null +++ b/tests/hermes_cli/test_telegram_managed_bot.py @@ -0,0 +1,323 @@ +"""Tests for hermes_cli.telegram_managed_bot — QR codes, deep links, pairing.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from hermes_cli.telegram_managed_bot import ( + DEFAULT_MANAGER_BOT, + TELEGRAM_ONBOARDING_URL_ENV, + TelegramBotSetupResult, + TelegramPairing, + create_pairing, + generate_bot_username, + generate_deep_link, + generate_pairing_nonce, + poll_for_setup_result, + poll_for_token, + print_qr_code, + render_qr_terminal, +) + + +VALID_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" +SECOND_VALID_TOKEN = "987654321:abcdefghijklmnopqrstuvwxyzABCDEF" + + +class TestGenerateBotUsername: + def test_secure_default_format(self): + name = generate_bot_username() + assert name.startswith("hermes_") + assert name.endswith("_bot") + assert len(name) == len("hermes_") + 16 + len("_bot") + assert len(name) <= 32 + + def test_profile_name_not_embedded(self): + name = generate_bot_username("work") + assert "work" not in name + assert name.startswith("hermes_") + assert name.endswith("_bot") + + def test_slug_uses_telegram_safe_base32_chars(self): + name = generate_bot_username() + slug = name.removeprefix("hermes_").removesuffix("_bot") + assert len(slug) == 16 + assert set(slug) <= set("abcdefghijklmnopqrstuvwxyz234567") + + def test_uniqueness(self): + names = {generate_bot_username() for _ in range(20)} + assert len(names) == 20 + + +class TestGenerateDeepLink: + def test_basic_format(self): + link = generate_deep_link( + manager_bot="TestBot", + suggested_username="my_bot", + ) + assert link == "https://t.me/newbot/TestBot/my_bot" + + def test_with_name(self): + link = generate_deep_link( + manager_bot="@TestBot", + suggested_username="my_bot", + suggested_name="My Agent", + ) + assert "https://t.me/newbot/TestBot/my_bot?" in link + assert "name=My+Agent" in link + + def test_defaults(self): + link = generate_deep_link() + assert f"https://t.me/newbot/{DEFAULT_MANAGER_BOT}/" in link + assert "hermes_" in link + + def test_name_url_encoded(self): + link = generate_deep_link( + manager_bot="Bot", + suggested_username="test_bot", + suggested_name="Hermes & Friends", + ) + assert "Hermes+%26+Friends" in link + + +class TestPairingNonce: + def test_length(self): + nonce = generate_pairing_nonce() + assert len(nonce) == 32 + + def test_hex_chars(self): + nonce = generate_pairing_nonce() + assert all(c in "0123456789abcdef" for c in nonce) + + def test_uniqueness(self): + nonces = {generate_pairing_nonce() for _ in range(100)} + assert len(nonces) == 100 + + +class TestQRCode: + def test_render_returns_string(self): + result = render_qr_terminal("https://example.com") + if result: + assert isinstance(result, str) + assert len(result) > 10 + + def test_render_graceful_without_qrcode(self): + with patch.dict("sys.modules", {"qrcode": None}): + render_qr_terminal("https://example.com") + + def test_print_qr_code_with_url(self, capsys): + print_qr_code("https://t.me/newbot/Bot/test_bot") + captured = capsys.readouterr() + assert "https://t.me/newbot/Bot/test_bot" in captured.out + + +class TestCreatePairing: + def test_success(self): + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = { + "pairing_id": "abcdefghijklmnop", + "poll_token": "secret-token", + "suggested_username": "hermes_abcdefghijklmnop_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent", + "expires_at": "2026-05-18T00:00:00.000Z", + } + + with patch( + "hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp + ) as post: + pairing = create_pairing("https://api.example.com", bot_name="Hermes Agent") + + assert pairing == TelegramPairing( + pairing_id="abcdefghijklmnop", + poll_token="secret-token", + suggested_username="hermes_abcdefghijklmnop_bot", + deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent", + qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot?name=Hermes+Agent", + expires_at="2026-05-18T00:00:00.000Z", + ) + post.assert_called_once_with( + "https://api.example.com/v1/telegram/pairings", + json={"bot_name": "Hermes Agent"}, + timeout=10.0, + ) + + def test_failure_status(self): + mock_resp = MagicMock() + mock_resp.status_code = 500 + with patch( + "hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp + ): + assert create_pairing("https://api.example.com") is None + + def test_invalid_payload(self): + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"pairing_id": "missing-poll-token"} + with patch( + "hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp + ): + assert create_pairing("https://api.example.com") is None + + def test_uses_env_override(self, monkeypatch): + monkeypatch.setenv(TELEGRAM_ONBOARDING_URL_ENV, "https://worker.example") + mock_resp = MagicMock() + mock_resp.status_code = 500 + with patch( + "hermes_cli.telegram_managed_bot.httpx.post", return_value=mock_resp + ) as post: + create_pairing() + assert post.call_args.args[0] == "https://worker.example/v1/telegram/pairings" + + +class TestPollForToken: + def pairing(self): + return TelegramPairing( + pairing_id="abcdefghijklmnop", + poll_token="secret-token", + suggested_username="hermes_abcdefghijklmnop_bot", + deep_link="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot", + qr_payload="https://t.me/newbot/HermesSetupBot/hermes_abcdefghijklmnop_bot", + ) + + def test_immediate_success(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "bot_username": "hermes_abcdefghijklmnop_bot", + "owner_user_id": 42, + "status": "ready", + "token": VALID_TOKEN, + } + + with patch( + "hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp + ) as get: + with patch("hermes_cli.telegram_managed_bot.time.sleep"): + token = poll_for_token( + "https://api.example.com", self.pairing(), timeout=5 + ) + + assert token == VALID_TOKEN + assert ( + get.call_args.args[0] + == "https://api.example.com/v1/telegram/pairings/abcdefghijklmnop" + ) + assert get.call_args.kwargs["headers"] == { + "Authorization": "Bearer secret-token" + } + + def test_setup_result_includes_owner_user_id(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "bot_username": "hermes_abcdefghijklmnop_bot", + "owner_user_id": 42, + "status": "ready", + "token": VALID_TOKEN, + } + + with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp): + with patch("hermes_cli.telegram_managed_bot.time.sleep"): + result = poll_for_setup_result( + "https://api.example.com", self.pairing(), timeout=5 + ) + + assert result == TelegramBotSetupResult( + token=VALID_TOKEN, + bot_username="hermes_abcdefghijklmnop_bot", + owner_user_id=42, + ) + + def test_setup_result_accepts_string_owner_user_id(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "bot_username": "hermes_abcdefghijklmnop_bot", + "owner_user_id": "42", + "status": "ready", + "token": VALID_TOKEN, + } + + with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp): + result = poll_for_setup_result( + "https://api.example.com", self.pairing(), timeout=5 + ) + + assert result == TelegramBotSetupResult( + token=VALID_TOKEN, + bot_username="hermes_abcdefghijklmnop_bot", + owner_user_id=42, + ) + + def test_invalid_ready_token_returns_none(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "bot_username": "hermes_abcdefghijklmnop_bot", + "owner_user_id": 42, + "status": "ready", + "token": "not-a-real-token", + } + + with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp): + with patch("hermes_cli.telegram_managed_bot.time.sleep"): + with patch( + "hermes_cli.telegram_managed_bot.time.monotonic" + ) as mock_time: + mock_time.side_effect = [0, 0, 999] + assert ( + poll_for_token( + "https://api.example.com", self.pairing(), timeout=1 + ) + is None + ) + + def test_timeout_returns_none(self): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "waiting"} + + with patch("hermes_cli.telegram_managed_bot.httpx.get", return_value=mock_resp): + with patch("hermes_cli.telegram_managed_bot.time.sleep"): + with patch( + "hermes_cli.telegram_managed_bot.time.monotonic" + ) as mock_time: + mock_time.side_effect = [0, 0, 999] + token = poll_for_token( + "https://api.example.com", self.pairing(), timeout=1 + ) + assert token is None + + def test_eventual_success(self): + not_ready = MagicMock() + not_ready.status_code = 200 + not_ready.json.return_value = {"status": "waiting"} + + ready = MagicMock() + ready.status_code = 200 + ready.json.return_value = {"status": "ready", "token": SECOND_VALID_TOKEN} + + call_count = 0 + + def fake_get(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + return not_ready + return ready + + with patch("hermes_cli.telegram_managed_bot.httpx.get", side_effect=fake_get): + with patch("hermes_cli.telegram_managed_bot.time.sleep"): + token = poll_for_token( + "https://api.example.com", self.pairing(), timeout=30 + ) + assert token == SECOND_VALID_TOKEN + + +class TestSetupTelegramAuto: + def test_setup_helper_exists(self): + from hermes_cli.setup import _setup_telegram_auto + + assert callable(_setup_telegram_auto) From ec46f5912e309330a68c49b273dc1346c1d018d0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:53:59 -0700 Subject: [PATCH 041/541] fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints Two distinct failures hit users on the gemini provider with only Google AI Studio keys set. 1. Truncation loop: build_gemini_request() only set maxOutputTokens when max_tokens was non-None. Hermes passes None to mean "unlimited", but Gemini's native generateContent does NOT treat an absent maxOutputTokens as full budget — it applies a low internal default and stops early with finishReason=MAX_TOKENS, truncating tool calls. The agent then retries 3x and refuses the incomplete call. Now default to the published 65,535 ceiling (shared by all current Gemini text models) when max_tokens=None. 2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles profile extra_body (Nous portal 'tags', reasoning, provider prefs) and sends it via the OpenAI client to whatever base_url is resolved. When a profile that emits extra_body (e.g. Nous) is active but the endpoint is a native Gemini base_url — typical when only Google creds exist and a fallback/aux call lands on Gemini — Google rejects the unknown 'tags' field with a non-retryable 400. Strip all non-thinking_config extra_body keys when the resolved endpoint is native Gemini. Verified E2E against real transport code: tags stripped on native Gemini, preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535 on None, explicit values respected. --- agent/conversation_loop.py | 55 +++++++++ agent/gemini_native_adapter.py | 19 ++++ agent/transports/chat_completions.py | 23 +++- tests/agent/test_gemini_native_adapter.py | 24 ++++ .../agent/transports/test_chat_completions.py | 50 +++++++++ tests/run_agent/test_413_compression.py | 105 +++++++++++++++++- tests/run_agent/test_run_agent.py | 3 + 7 files changed, 277 insertions(+), 2 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 8be763513fff..d01d5d4a8448 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2720,6 +2720,61 @@ def run_conversation( # compress history and retry, not abort immediately. status_code = getattr(api_error, "status_code", None) + # ── Respect disabled auto-compaction on overflow ────── + # Ported from anomalyco/opencode#30749. When the user has + # turned auto-compaction off (``compression.enabled: false``), + # NO automatic compaction trigger may fire — including the + # provider/request-size overflow recovery paths below + # (long-context-tier 429, 413 payload-too-large, and + # context-overflow). Without this guard the proactive + # threshold path correctly honours the setting (see the + # preflight check and the post-response ``should_compress`` + # gate) but a provider overflow error would still silently + # compress + rotate the session, bypassing the user's + # explicit choice. Surface a terminal error instead so the + # user can compact manually (``/compress``), start fresh + # (``/new``), switch to a larger-context model, or reduce + # attachments. Forced compaction via ``/compress`` + # (``force=True``) is unaffected — it never reaches this loop. + _overflow_reasons = { + FailoverReason.long_context_tier, + FailoverReason.payload_too_large, + FailoverReason.context_overflow, + } + if ( + classified.reason in _overflow_reasons + and not getattr(agent, "compression_enabled", True) + ): + agent._flush_status_buffer() + agent._vprint( + f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled " + f"(compression.enabled: false).", + force=True, + ) + agent._vprint( + f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, " + f"switch to a larger-context model, or reduce attachments.", + force=True, + ) + logger.error( + f"{agent.log_prefix}Context overflow ({classified.reason.value}) with " + f"auto-compaction disabled — not compressing." + ) + agent._persist_session(messages, conversation_history) + return { + "messages": messages, + "completed": False, + "api_calls": api_call_count, + "error": ( + "Context overflow and auto-compaction is disabled " + "(compression.enabled: false). Run /compress to compact manually, " + "/new to start fresh, or switch to a larger-context model." + ), + "partial": True, + "failed": True, + "compaction_disabled": True, + } + # ── Anthropic Sonnet long-context tier gate ─────────── # Anthropic returns HTTP 429 "Extra usage is required for # long context requests" when a Claude Max (or similar) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b0d903372cde..a0f8e9df5488 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -33,6 +33,13 @@ logger = logging.getLogger(__name__) DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +# Published max output-token ceiling shared by every current Gemini text model +# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller +# passes max_tokens=None, because Gemini's native API otherwise applies a low +# internal default and truncates output (unlike OpenAI-compat endpoints where +# an omitted limit means full budget). +GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535 + def is_native_gemini_base_url(base_url: str) -> bool: """Return True when the endpoint speaks Gemini's native REST API.""" @@ -414,6 +421,18 @@ def build_gemini_request( generation_config["temperature"] = temperature if max_tokens is not None: generation_config["maxOutputTokens"] = max_tokens + else: + # Gemini's native generateContent does NOT treat an omitted + # maxOutputTokens as "use the model's full output budget" — it applies + # a low internal default and the model stops early with + # finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes + # then retries 3× and refuses the incomplete call). Every current + # Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at + # 65,535 output tokens, so default to that ceiling when the caller + # passes None ("unlimited"). See the OpenAI-compat path where omitting + # the field genuinely means full budget — that assumption does not + # hold on the native API. + generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS if top_p is not None: generation_config["topP"] = top_p if stop: diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 7b1935528d92..0c17e309a8bd 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport): api_kwargs[k] = v if extra_body: - api_kwargs["extra_body"] = extra_body + # Native Gemini (generativelanguage.googleapis.com, non-/openai) + # speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body + # keys (tags, reasoning, provider, plugins, …) are unknown fields + # there and Gemini rejects the whole request with a non-retryable + # HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'"). + # This happens when a profile that emits extra_body (e.g. the Nous + # profile's portal `tags`) is active but the resolved endpoint is a + # Gemini base_url — typical when only Google credentials are set and + # a fallback/aux call lands on Gemini. The native client only reads + # thinking_config from extra_body, so drop everything else here. + try: + from agent.gemini_native_adapter import is_native_gemini_base_url + _native_gemini = is_native_gemini_base_url(params.get("base_url")) + except Exception: + _native_gemini = False + if _native_gemini: + extra_body = { + k: v for k, v in extra_body.items() + if k in ("thinking_config", "thinkingConfig") + } + if extra_body: + api_kwargs["extra_body"] = extra_body return api_kwargs diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index 4b066b4f454c..4f894c512a61 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -326,3 +326,27 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts(): assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0 assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1 assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id + + +def test_max_tokens_none_defaults_to_gemini_output_ceiling(): + """max_tokens=None must send the model's full output ceiling, not omit it. + + Gemini's native generateContent applies a low internal default when + maxOutputTokens is absent, truncating tool calls mid-stream. Hermes passes + None to mean "unlimited", so the adapter must translate that to the + published 65,535 ceiling rather than leaving the field unset. + """ + from agent.gemini_native_adapter import ( + build_gemini_request, + GEMINI_DEFAULT_MAX_OUTPUT_TOKENS, + ) + + req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=None) + assert req["generationConfig"]["maxOutputTokens"] == GEMINI_DEFAULT_MAX_OUTPUT_TOKENS == 65535 + + +def test_explicit_max_tokens_is_respected(): + from agent.gemini_native_adapter import build_gemini_request + + req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=4096) + assert req["generationConfig"]["maxOutputTokens"] == 4096 diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 255d46f4381c..0b54fe470599 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -859,3 +859,53 @@ class TestChatCompletionsCacheStats: r = SimpleNamespace(usage=SimpleNamespace(prompt_tokens_details=details)) result = transport.extract_cache_stats(r) assert result == {"cached_tokens": 500, "creation_tokens": 100} + + +class TestChatCompletionsGeminiNativeExtraBodyStrip: + """Profile extra_body (e.g. Nous portal tags) must not reach a native + Gemini endpoint — Google's REST API rejects unknown fields with HTTP 400. + """ + + def _nous_profile(self): + from providers import get_provider_profile + return get_provider_profile("nous") + + def test_tags_stripped_when_endpoint_is_native_gemini(self, transport): + kw = transport.build_kwargs( + "anthropic/claude-sonnet-4.6", + [{"role": "user", "content": "hi"}], + None, + provider_profile=self._nous_profile(), + base_url="https://generativelanguage.googleapis.com/v1beta", + session_id="s1", + max_tokens=None, + ) + eb = kw.get("extra_body") + assert not eb or "tags" not in eb + + def test_tags_preserved_on_nous_endpoint(self, transport): + kw = transport.build_kwargs( + "hermes-3-405b", + [{"role": "user", "content": "hi"}], + None, + provider_profile=self._nous_profile(), + base_url="https://inference.nousresearch.com/v1", + session_id="s1", + max_tokens=None, + ) + eb = kw.get("extra_body") + assert eb and "tags" in eb + + def test_tags_pass_through_on_gemini_openai_compat(self, transport): + # /openai compat endpoint is not "native" — unchanged behavior. + kw = transport.build_kwargs( + "anthropic/claude-sonnet-4.6", + [{"role": "user", "content": "hi"}], + None, + provider_profile=self._nous_profile(), + base_url="https://generativelanguage.googleapis.com/v1beta/openai", + session_id="s1", + max_tokens=None, + ) + eb = kw.get("extra_body") + assert eb and "tags" in eb diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index cadb26c449b3..2b8c32e297bf 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -94,7 +94,11 @@ def agent(): a._cached_system_prompt = "You are helpful." a._use_prompt_caching = False a.tool_delay = 0 - a.compression_enabled = False + # Default matches production (`compression.enabled` defaults to True). + # Overflow-recovery tests below verify that 413 / context-overflow + # errors DO trigger compression; the disabled-path behavior is + # covered explicitly by TestOverflowWithCompactionDisabled. + a.compression_enabled = True a.save_trajectories = False return a @@ -415,6 +419,13 @@ class TestPreflightCompression: def test_compress_context_emits_lifecycle_status_before_work(self, agent): """Direct context compression should tell gateway users why the turn paused.""" + # This test calls _compress_context directly and asserts the FIRST + # status event is the lifecycle "Compacting context" message. With + # compaction enabled the lazy feasibility probe would emit an + # aux-provider warning first (no aux key in the hermetic test env), + # displacing events[0]. The flag value is irrelevant to what this + # test asserts, so disable it to suppress the probe. + agent.compression_enabled = False events = [] agent.status_callback = lambda ev, msg: events.append((ev, msg)) @@ -802,3 +813,95 @@ class TestToolResultPreflightCompression: mock_compress.assert_called_once() assert result["completed"] is True + + +# --------------------------------------------------------------------------- +# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749) +# --------------------------------------------------------------------------- + +class TestOverflowWithCompactionDisabled: + """When ``compression.enabled`` is False, NO automatic compaction may + fire — including the provider/request-size overflow recovery paths. + + Ported from anomalyco/opencode#30749: the proactive token-threshold + path already honoured the setting, but provider overflow errors + (413 payload-too-large, context-overflow, long-context-tier 429) still + silently compressed + rotated the session. The fix surfaces a terminal + error so the user can compact manually, start fresh, or switch models. + """ + + @staticmethod + def _prefill(): + return [ + {"role": "user", "content": "previous question"}, + {"role": "assistant", "content": "previous answer"}, + ] + + def test_413_does_not_compress_when_disabled(self, agent): + """413 must NOT call _compress_context when compaction is disabled.""" + agent.compression_enabled = False + err_413 = _make_413_error() + # If the guard fails, a second (success) response would be consumed. + agent.client.chat.completions.create.side_effect = [err_413, _mock_response()] + + with ( + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session") as mock_persist, + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=self._prefill()) + + mock_compress.assert_not_called() + mock_persist.assert_called() + assert result.get("failed") is True + assert result.get("compaction_disabled") is True + assert "auto-compaction is disabled" in result["error"] + + def test_context_overflow_does_not_compress_when_disabled(self, agent): + """400 'prompt is too long' must NOT compress when compaction disabled.""" + agent.compression_enabled = False + err_400 = Exception( + "Error code: 400 - {'type': 'error', 'error': {'type': " + "'invalid_request_error', 'message': 'prompt is too long: " + "233153 tokens > 200000 maximum'}}" + ) + err_400.status_code = 400 + agent.client.chat.completions.create.side_effect = [err_400, _mock_response()] + + with ( + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello", conversation_history=self._prefill()) + + mock_compress.assert_not_called() + assert result.get("compaction_disabled") is True + + def test_413_still_compresses_when_enabled(self, agent): + """Control: with compaction enabled, 413 still triggers compression. + + Guards against the disabled-path guard accidentally swallowing the + enabled path. + """ + agent.compression_enabled = True + err_413 = _make_413_error() + ok_resp = _mock_response(content="Recovered", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [err_413, ok_resp] + + with ( + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + mock_compress.return_value = ( + [{"role": "user", "content": "hello"}], "compressed", + ) + result = agent.run_conversation("hello", conversation_history=self._prefill()) + + mock_compress.assert_called_once() + assert result["completed"] is True + assert result.get("compaction_disabled") is not True diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e9e7011dd1ed..ea66789fdd38 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3903,6 +3903,7 @@ class TestRunConversation: def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent): """GLM/Z.AI uses 'Prompt exceeds max length' for context overflow.""" self._setup_agent(agent) + agent.compression_enabled = True # this test verifies overflow→compression fires err_400 = Exception( "Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}" ) @@ -3937,6 +3938,7 @@ class TestRunConversation: to the generic 128K fallback tier. """ self._setup_agent(agent) + agent.compression_enabled = True # this test verifies overflow→compression fires agent.provider = "minimax" agent.model = "MiniMax-M2.7-highspeed" agent.base_url = "https://api.minimax.io/anthropic" @@ -3982,6 +3984,7 @@ class TestRunConversation: rely on compression — see #33669 / PR #33826. """ self._setup_agent(agent) + agent.compression_enabled = True # this test verifies overflow→compression fires agent.provider = "openrouter" agent.model = "some/unknown-model" agent.base_url = "https://openrouter.ai/api/v1" From 3cd1bd971f25c98698bcea9f1e846b09f27d14f8 Mon Sep 17 00:00:00 2001 From: Frowtek Date: Fri, 5 Jun 2026 07:30:19 +0300 Subject: [PATCH 042/541] fix(cli): require Chromium for local browser readiness in setup/status surfaces --- hermes_cli/nous_subscription.py | 45 +++++++- hermes_cli/setup.py | 4 +- tests/hermes_cli/test_nous_subscription.py | 108 ++++++++++++++++++ tests/hermes_cli/test_setup_model_provider.py | 40 +++++++ 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 65cc1ca574c8..d68f98658357 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -159,6 +159,33 @@ def _has_agent_browser() -> bool: return bool(agent_browser_bin or local_bin.exists()) +def _local_browser_runnable() -> bool: + """Return True when the *local* browser backend would actually start. + + The ``agent-browser`` CLI being present is necessary but not sufficient for + local mode: agent-browser also needs a Chromium build on disk (without one + it hangs on first use until the command timeout fires), unless the + Lightpanda engine is selected — text-only navigation needs no Chromium. + + This mirrors the local-mode tail of + :func:`tools.browser_tool.check_browser_requirements`, so the setup/status + surfaces advertise local browser readiness only when the runtime would + actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host + their own Chromium and therefore gate on :func:`_has_agent_browser` alone. + """ + if not _has_agent_browser(): + return False + try: + from tools.browser_tool import _chromium_installed, _using_lightpanda_engine + except Exception: + # If the runtime probe can't be imported, fall back to binary presence + # (prior behaviour) rather than crashing the setup/status surface. + return True + if _using_lightpanda_engine(): + return True + return _chromium_installed() + + def _browser_label(current_provider: str) -> str: mapping = { "browserbase": "Browserbase", @@ -188,13 +215,23 @@ def _resolve_browser_feature_state( browser_provider: str, browser_provider_explicit: bool, browser_local_available: bool, + browser_local_runnable: bool, direct_camofox: bool, direct_browserbase: bool, direct_browser_use: bool, direct_firecrawl: bool, managed_browser_available: bool, ) -> tuple[str, bool, bool, bool]: - """Resolve browser availability using the same precedence as runtime.""" + """Resolve browser availability using the same precedence as runtime. + + ``browser_local_available`` means "the agent-browser CLI is present" — the + only local requirement for cloud providers, which host their own Chromium. + ``browser_local_runnable`` additionally requires a usable local Chromium + build (or the Lightpanda engine), mirroring the local-mode tail of + :func:`tools.browser_tool.check_browser_requirements`. Local mode must gate + on the latter, or setup/status advertise a browser that fails on first use + when Chromium is missing. + """ if direct_camofox: return "camofox", True, bool(browser_tool_enabled), False @@ -223,7 +260,7 @@ def _resolve_browser_feature_state( return current_provider, False, False, False current_provider = "local" - available = bool(browser_local_available) + available = bool(browser_local_runnable) active = bool(browser_tool_enabled and available) return current_provider, available, active, False @@ -243,7 +280,7 @@ def _resolve_browser_feature_state( active = bool(browser_tool_enabled and available) return "browserbase", available, active, False - available = bool(browser_local_available) + available = bool(browser_local_runnable) active = bool(browser_tool_enabled and available) return "local", available, active, False @@ -445,6 +482,7 @@ def get_nous_subscription_features( tts_active = bool(tts_tool_enabled and tts_available) browser_local_available = _has_agent_browser() + browser_local_runnable = _local_browser_runnable() ( browser_current_provider, browser_available, @@ -455,6 +493,7 @@ def get_nous_subscription_features( browser_provider=browser_provider, browser_provider_explicit=browser_provider_explicit, browser_local_available=browser_local_available, + browser_local_runnable=browser_local_runnable, direct_camofox=direct_camofox, direct_browserbase=direct_browserbase, direct_browser_use=direct_browser_use, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index fe304dee0895..266eb9eaa392 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -415,7 +415,9 @@ def _print_setup_summary(config: dict, hermes_home): elif browser_provider == "Camofox": missing_browser_hint = "CAMOFOX_URL" elif browser_provider == "Local browser": - missing_browser_hint = "npm install -g agent-browser" + missing_browser_hint = ( + "npm install -g agent-browser && agent-browser install --with-deps" + ) tool_status.append( ("Browser Automation", False, missing_browser_hint) ) diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 752f6fabc28e..152e9e064776 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -257,6 +257,114 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch): assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"} +def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False): + """Common monkeypatches for local-browser readiness scenarios. + + ``chromium`` / ``lightpanda`` drive the runtime probes that + ``_local_browser_runnable`` reuses from ``tools.browser_tool`` (lazy import, + so patching the module attributes is enough). + """ + monkeypatch.setattr(ns, "get_env_value", lambda name: "") + monkeypatch.setattr( + ns, "get_nous_portal_account_info", lambda: _account(logged_in=False) + ) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False) + monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False) + monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: chromium) + monkeypatch.setattr( + "tools.browser_tool._using_lightpanda_engine", lambda: lightpanda + ) + + +def test_local_browser_unavailable_without_chromium(monkeypatch): + """agent-browser present but Chromium absent must NOT advertise local browser. + + The runtime (``check_browser_requirements``) refuses local mode without a + Chromium build, so the setup/status surface must report unavailable too — + otherwise the user sees "Browser Automation available" and the first real + call fails. Regression for the false-positive setup bug. + """ + _stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "local"}} + ) + + assert features.browser.available is False + assert features.browser.active is False + assert features.browser.managed_by_nous is False + assert features.browser.current_provider == "Local browser" + + +def test_local_browser_available_with_chromium(monkeypatch): + _stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=True) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "local"}} + ) + + assert features.browser.available is True + assert features.browser.active is True + assert features.browser.current_provider == "Local browser" + + +def test_local_browser_available_with_lightpanda_without_chromium(monkeypatch): + """Lightpanda is text-only and needs no Chromium, so it stays available. + + Guards against the fix over-correcting into a false-negative for the + legitimate Lightpanda-without-Chromium configuration. + """ + _stub_browser_probes( + monkeypatch, has_agent_browser=True, chromium=False, lightpanda=True + ) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "local"}} + ) + + assert features.browser.available is True + assert features.browser.active is True + + +def test_default_local_browser_unavailable_without_chromium(monkeypatch): + """The implicit (no cloud_provider) local fallthrough is gated on Chromium too.""" + _stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False) + + features = ns.get_nous_subscription_features({}) + + assert features.browser.available is False + assert features.browser.current_provider == "Local browser" + + +def test_cloud_browserbase_available_without_local_chromium(monkeypatch): + """Cloud providers host their own Chromium, so the new local gate must not + regress them: agent-browser binary present + Browserbase creds is enough.""" + env = {"BROWSERBASE_API_KEY": "bb-key", "BROWSERBASE_PROJECT_ID": "bb-project"} + monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, "")) + monkeypatch.setattr( + ns, "get_nous_portal_account_info", lambda: _account(logged_in=False) + ) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: True) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False) + monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False) + # Chromium absent locally — must not matter for a cloud provider. + monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False) + monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "browserbase"}} + ) + + assert features.browser.available is True + assert features.browser.active is True + assert features.browser.current_provider == "Browserbase" + + def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch): """A free-tool-pool user is offered the covered tools but NOT video gen.""" monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account()) diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 099f4eb94b27..b9db444ddc79 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -344,3 +344,43 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat assert "Browser Automation (Browserbase)" not in output assert "Browser Automation" in output assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output + + +def test_setup_summary_local_browser_unavailable_without_chromium( + tmp_path, monkeypatch, capsys +): + """End-to-end: agent-browser present but no Chromium in local mode must + render as unavailable with an install hint — not a false 'available'. + + Unlike the mocked-feature tests above, this drives the real + ``get_nous_subscription_features`` so the surface stays aligned with the + runtime gate in ``tools.browser_tool.check_browser_requirements``. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + cfg = load_config() + browser_cfg = cfg.get("browser") + if not isinstance(browser_cfg, dict): + browser_cfg = {} + cfg["browser"] = browser_cfg + browser_cfg["cloud_provider"] = "local" + save_config(cfg) + + # Only stub the readiness probes; the feature resolver itself is real. + monkeypatch.setattr("hermes_cli.nous_subscription._has_agent_browser", lambda: True) + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_portal_account_info", + lambda *a, **k: None, + ) + monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False) + monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False) + monkeypatch.setattr( + "agent.auxiliary_client.get_available_vision_backends", lambda: [] + ) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Browser Automation (Local browser)" not in output + assert "agent-browser install --with-deps" in output From 06268f11cc6c9d9c140cab9d669e9136acc8fbd0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:21:13 -0700 Subject: [PATCH 043/541] feat(gateway): explain /voice usage when toggled bare (#39766) A bare /voice silently toggled on/off with a one-line result, leaving users with no idea what the modes mean or that Discord also supports TTS-all and live voice-channel join/leave. Bare /voice now still toggles but appends a usage explainer covering on/off/tts/status, with the Discord voice-channel lines shown only on adapters that support them. Adds gateway.voice.help + gateway.voice.help_channels across all 16 locales (placeholders {toggle}/{channels}). --- gateway/run.py | 15 +++++++++++++-- locales/af.yaml | 2 ++ locales/de.yaml | 2 ++ locales/en.yaml | 2 ++ locales/es.yaml | 2 ++ locales/fr.yaml | 2 ++ locales/ga.yaml | 2 ++ locales/hu.yaml | 2 ++ locales/it.yaml | 2 ++ locales/ja.yaml | 2 ++ locales/ko.yaml | 2 ++ locales/pt.yaml | 2 ++ locales/ru.yaml | 2 ++ locales/tr.yaml | 2 ++ locales/uk.yaml | 2 ++ locales/zh-hant.yaml | 2 ++ locales/zh.yaml | 2 ++ 17 files changed, 45 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 7fc4da59f5e3..45d8f6a7a8c4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11998,13 +11998,24 @@ class GatewayRunner: self._save_voice_modes() if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) - return t("gateway.voice.enabled_short") + toggle_line = t("gateway.voice.enabled_short") else: self._voice_mode[voice_key] = "off" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) - return t("gateway.voice.disabled_short") + toggle_line = t("gateway.voice.disabled_short") + # Bare /voice still toggles, but append an explainer so users + # discover the on/off/tts/status subcommands (and, on Discord, + # live voice-channel join/leave). The toggle result is shown + # first via the {toggle} placeholder. + supports_voice_channels = adapter is not None and hasattr( + adapter, "join_voice_channel" + ) + channels = ( + t("gateway.voice.help_channels") if supports_voice_channels else "" + ) + return t("gateway.voice.help", toggle=toggle_line, channels=channels) async def _handle_voice_channel_join(self, event: MessageEvent) -> str: """Join the user's current Discord voice channel.""" diff --git a/locales/af.yaml b/locales/af.yaml index 9cf3c81c4992..bb3fae463ee7 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Af (slegs teks)" label_voice_only: "Aan (stemantwoord op stemboodskappe)" label_all: "TTS (stemantwoord op alle boodskappe)" + help: "{toggle}\n\n**Hoe /voice werk**\n• `/voice on` — stemantwoord wanneer jy 'n stemboodskap stuur\n• `/voice tts` — stemantwoord op *elke* boodskap\n• `/voice off` — terug na slegs-teks antwoorde\n• `/voice status` — wys die huidige modus\n• `/voice` (geen argument) — wissel vinnig tussen aan en af{channels}" + help_channels: "\n\n**Lewendige stemkanale (Discord)**\n• Sluit eers by 'n stemkanaal aan, dan `/voice channel` — ek sluit aan, luister en praat my antwoorde\n• `/voice leave` — ontkoppel van die stemkanaal" yolo: disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis." diff --git a/locales/de.yaml b/locales/de.yaml index c0a8efdd5fed..437a90a94764 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Aus (nur Text)" label_voice_only: "An (Sprachantwort auf Sprachnachrichten)" label_all: "TTS (Sprachantwort auf alle Nachrichten)" + help: "{toggle}\n\n**So funktioniert /voice**\n• `/voice on` — Sprachantwort, wenn du eine Sprachnachricht sendest\n• `/voice tts` — Sprachantwort auf *jede* Nachricht\n• `/voice off` — zurück zu reinen Textantworten\n• `/voice status` — aktuellen Modus anzeigen\n• `/voice` (ohne Argument) — schnelles Umschalten zwischen an und aus{channels}" + help_channels: "\n\n**Live-Sprachkanäle (Discord)**\n• Tritt zuerst einem Sprachkanal bei, dann `/voice channel` — ich trete bei, höre zu und spreche meine Antworten\n• `/voice leave` — vom Sprachkanal trennen" yolo: disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung." diff --git a/locales/en.yaml b/locales/en.yaml index a54cb7f14db5..1516977ccb68 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -358,6 +358,8 @@ gateway: label_off: "Off (text only)" label_voice_only: "On (voice reply to voice messages)" label_all: "TTS (voice reply to all messages)" + help: "{toggle}\n\n**How /voice works**\n• `/voice on` — voice reply when you send a voice message\n• `/voice tts` — voice reply to *every* message\n• `/voice off` — back to text-only replies\n• `/voice status` — show the current mode\n• `/voice` (no argument) — quick toggle between on and off{channels}" + help_channels: "\n\n**Live voice channels (Discord)**\n• Join a voice channel first, then `/voice channel` — I'll join, listen, and speak my replies\n• `/voice leave` — disconnect from the voice channel" yolo: disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval." diff --git a/locales/es.yaml b/locales/es.yaml index 47b9c01d6418..b22fc2ec4299 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Desactivado (solo texto)" label_voice_only: "Activado (responder con voz a mensajes de voz)" label_all: "TTS (responder con voz a todos los mensajes)" + help: "{toggle}\n\n**Cómo funciona /voice**\n• `/voice on` — respuesta de voz cuando envías un mensaje de voz\n• `/voice tts` — respuesta de voz a *cada* mensaje\n• `/voice off` — volver a respuestas solo de texto\n• `/voice status` — mostrar el modo actual\n• `/voice` (sin argumento) — alternar rápido entre activado y desactivado{channels}" + help_channels: "\n\n**Canales de voz en vivo (Discord)**\n• Únete primero a un canal de voz, luego `/voice channel` — me uno, escucho y digo mis respuestas\n• `/voice leave` — desconectar del canal de voz" yolo: disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación." diff --git a/locales/fr.yaml b/locales/fr.yaml index b577c1622189..8201df6c3f38 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Désactivé (texte seulement)" label_voice_only: "Activé (réponse vocale aux messages vocaux)" label_all: "TTS (réponse vocale à tous les messages)" + help: "{toggle}\n\n**Comment fonctionne /voice**\n• `/voice on` — réponse vocale quand vous envoyez un message vocal\n• `/voice tts` — réponse vocale à *chaque* message\n• `/voice off` — retour aux réponses texte uniquement\n• `/voice status` — afficher le mode actuel\n• `/voice` (sans argument) — bascule rapide entre activé et désactivé{channels}" + help_channels: "\n\n**Salons vocaux en direct (Discord)**\n• Rejoignez d'abord un salon vocal, puis `/voice channel` — je rejoins, j'écoute et je parle mes réponses\n• `/voice leave` — se déconnecter du salon vocal" yolo: disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation." diff --git a/locales/ga.yaml b/locales/ga.yaml index d20e4b27dc2f..eaf957a29127 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -347,6 +347,8 @@ gateway: label_off: "As (téacs amháin)" label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)" label_all: "TTS (freagra gutha do gach teachtaireacht)" + help: "{toggle}\n\n**Conas a oibríonn /voice**\n• `/voice on` — freagra gutha nuair a sheolann tú teachtaireacht gutha\n• `/voice tts` — freagra gutha do *gach* teachtaireacht\n• `/voice off` — ar ais go freagraí téacs amháin\n• `/voice status` — taispeáin an mód reatha\n• `/voice` (gan argóint) — scoránaigh go tapa idir air agus as{channels}" + help_channels: "\n\n**Cainéil gutha bheo (Discord)**\n• Téigh isteach i gcainéal gutha ar dtús, ansin `/voice channel` — téim isteach, éistim agus labhraím mo fhreagraí\n• `/voice leave` — dícheangail ón gcainéal gutha" yolo: disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha." diff --git a/locales/hu.yaml b/locales/hu.yaml index 6fa56967f4be..78b18ac19425 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Ki (csak szöveg)" label_voice_only: "Be (hangválasz hangüzenetekre)" label_all: "TTS (hangválasz minden üzenetre)" + help: "{toggle}\n\n**Hogyan működik a /voice**\n• `/voice on` — hangválasz, amikor hangüzenetet küldesz\n• `/voice tts` — hangválasz *minden* üzenetre\n• `/voice off` — vissza a csak szöveges válaszokhoz\n• `/voice status` — az aktuális mód megjelenítése\n• `/voice` (argumentum nélkül) — gyors váltás be és ki között{channels}" + help_channels: "\n\n**Élő hangcsatornák (Discord)**\n• Először lépj be egy hangcsatornába, majd `/voice channel` — csatlakozom, hallgatok és hangosan válaszolok\n• `/voice leave` — lecsatlakozás a hangcsatornáról" yolo: disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek." diff --git a/locales/it.yaml b/locales/it.yaml index 44a150f5f9e4..89d4e0796bb6 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Off (solo testo)" label_voice_only: "On (risposta vocale ai messaggi vocali)" label_all: "TTS (risposta vocale a tutti i messaggi)" + help: "{toggle}\n\n**Come funziona /voice**\n• `/voice on` — risposta vocale quando invii un messaggio vocale\n• `/voice tts` — risposta vocale a *ogni* messaggio\n• `/voice off` — torna alle risposte solo testo\n• `/voice status` — mostra la modalità attuale\n• `/voice` (senza argomento) — alterna rapidamente tra attivo e disattivo{channels}" + help_channels: "\n\n**Canali vocali dal vivo (Discord)**\n• Entra prima in un canale vocale, poi `/voice channel` — mi unisco, ascolto e parlo le mie risposte\n• `/voice leave` — disconnetti dal canale vocale" yolo: disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione." diff --git a/locales/ja.yaml b/locales/ja.yaml index e9244f29c632..1758746df020 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -343,6 +343,8 @@ gateway: label_off: "オフ (テキストのみ)" label_voice_only: "オン (音声メッセージにのみ音声で返信)" label_all: "TTS (すべてのメッセージに音声で返信)" + help: "{toggle}\n\n**/voice の使い方**\n• `/voice on` — 音声メッセージを送ると音声で返信\n• `/voice tts` — *すべての*メッセージに音声で返信\n• `/voice off` — テキストのみの返信に戻す\n• `/voice status` — 現在のモードを表示\n• `/voice`(引数なし)— オンとオフをすばやく切り替え{channels}" + help_channels: "\n\n**ライブ音声チャンネル (Discord)**\n• 先に音声チャンネルに参加してから `/voice channel` — 参加して聞き取り、音声で返信します\n• `/voice leave` — 音声チャンネルから切断" yolo: disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。" diff --git a/locales/ko.yaml b/locales/ko.yaml index 29d0e5121845..19fbd28cb30a 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -343,6 +343,8 @@ gateway: label_off: "꺼짐 (텍스트 전용)" label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)" label_all: "TTS (모든 메시지에 음성으로 응답)" + help: "{toggle}\n\n**/voice 사용법**\n• `/voice on` — 음성 메시지를 보내면 음성으로 답변\n• `/voice tts` — *모든* 메시지에 음성으로 답변\n• `/voice off` — 텍스트 전용 답변으로 복귀\n• `/voice status` — 현재 모드 표시\n• `/voice` (인자 없음) — 켜기와 끄기를 빠르게 전환{channels}" + help_channels: "\n\n**라이브 음성 채널 (Discord)**\n• 먼저 음성 채널에 들어간 다음 `/voice channel` — 제가 참여해 듣고 음성으로 답변합니다\n• `/voice leave` — 음성 채널에서 연결 해제" yolo: disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다." diff --git a/locales/pt.yaml b/locales/pt.yaml index 7e84b08989a7..191ad1413eca 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Desativado (apenas texto)" label_voice_only: "Ativado (resposta de voz a mensagens de voz)" label_all: "TTS (resposta de voz a todas as mensagens)" + help: "{toggle}\n\n**Como funciona o /voice**\n• `/voice on` — resposta por voz quando envias uma mensagem de voz\n• `/voice tts` — resposta por voz a *todas* as mensagens\n• `/voice off` — voltar às respostas apenas em texto\n• `/voice status` — mostrar o modo atual\n• `/voice` (sem argumento) — alternar rapidamente entre ligado e desligado{channels}" + help_channels: "\n\n**Canais de voz ao vivo (Discord)**\n• Entra primeiro num canal de voz, depois `/voice channel` — eu entro, ouço e falo as minhas respostas\n• `/voice leave` — desligar do canal de voz" yolo: disabled: "⚠️ Modo YOLO **DESATIVADO** nesta sessão — comandos perigosos exigirão aprovação." diff --git a/locales/ru.yaml b/locales/ru.yaml index 994319917799..ce526d7b47fd 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Выкл. (только текст)" label_voice_only: "Вкл. (голосовой ответ на голосовые сообщения)" label_all: "TTS (голосовой ответ на все сообщения)" + help: "{toggle}\n\n**Как работает /voice**\n• `/voice on` — голосовой ответ, когда вы отправляете голосовое сообщение\n• `/voice tts` — голосовой ответ на *каждое* сообщение\n• `/voice off` — вернуться к ответам только текстом\n• `/voice status` — показать текущий режим\n• `/voice` (без аргумента) — быстрое переключение между вкл и выкл{channels}" + help_channels: "\n\n**Живые голосовые каналы (Discord)**\n• Сначала зайдите в голосовой канал, затем `/voice channel` — я подключусь, буду слушать и отвечать голосом\n• `/voice leave` — отключиться от голосового канала" yolo: disabled: "⚠️ Режим YOLO для этого сеанса **ОТКЛЮЧЁН** — опасные команды потребуют одобрения." diff --git a/locales/tr.yaml b/locales/tr.yaml index b2793c41fa25..ecd23d8e9772 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Kapalı (yalnızca metin)" label_voice_only: "Açık (sesli mesajlara sesli yanıt)" label_all: "TTS (tüm mesajlara sesli yanıt)" + help: "{toggle}\n\n**/voice nasıl çalışır**\n• `/voice on` — sesli mesaj gönderdiğinde sesli yanıt\n• `/voice tts` — *her* mesaja sesli yanıt\n• `/voice off` — yalnızca metin yanıtlarına dön\n• `/voice status` — geçerli modu göster\n• `/voice` (argümansız) — açık ve kapalı arasında hızlı geçiş{channels}" + help_channels: "\n\n**Canlı ses kanalları (Discord)**\n• Önce bir ses kanalına katıl, sonra `/voice channel` — katılırım, dinlerim ve yanıtlarımı sesli söylerim\n• `/voice leave` — ses kanalından ayrıl" yolo: disabled: "⚠️ Bu oturumda YOLO modu **KAPALI** — tehlikeli komutlar onay gerektirecek." diff --git a/locales/uk.yaml b/locales/uk.yaml index ebe324e64dec..b564ec30545e 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -343,6 +343,8 @@ gateway: label_off: "Вимкнено (лише текст)" label_voice_only: "Увімкнено (голосова відповідь на голосові повідомлення)" label_all: "TTS (голосова відповідь на всі повідомлення)" + help: "{toggle}\n\n**Як працює /voice**\n• `/voice on` — голосова відповідь, коли ви надсилаєте голосове повідомлення\n• `/voice tts` — голосова відповідь на *кожне* повідомлення\n• `/voice off` — повернутися до відповідей лише текстом\n• `/voice status` — показати поточний режим\n• `/voice` (без аргументу) — швидке перемикання між увімк і вимк{channels}" + help_channels: "\n\n**Живі голосові канали (Discord)**\n• Спершу зайдіть у голосовий канал, потім `/voice channel` — я підключуся, слухатиму й відповідатиму голосом\n• `/voice leave` — від'єднатися від голосового каналу" yolo: disabled: "⚠️ Режим YOLO для цього сеансу **ВИМКНЕНО** — небезпечні команди потребуватимуть схвалення." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 3eb33b5ef067..a2210d2c2253 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -343,6 +343,8 @@ gateway: label_off: "關閉(僅文字)" label_voice_only: "開啟(僅對語音訊息進行語音回覆)" label_all: "TTS(對所有訊息進行語音回覆)" + help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 當你傳送語音訊息時以語音回覆\n• `/voice tts` — 對*每則*訊息都以語音回覆\n• `/voice off` — 恢復為純文字回覆\n• `/voice status` — 顯示目前模式\n• `/voice`(無參數)— 在開啟和關閉之間快速切換{channels}" + help_channels: "\n\n**即時語音頻道 (Discord)**\n• 先加入一個語音頻道,然後 `/voice channel` — 我會加入、聆聽並以語音回覆\n• `/voice leave` — 中斷語音頻道" yolo: disabled: "⚠️ 本工作階段 YOLO 模式 **已關閉** — 危險指令將需要批准。" diff --git a/locales/zh.yaml b/locales/zh.yaml index 63bf3c8b31d9..896a958778f1 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -343,6 +343,8 @@ gateway: label_off: "关闭(仅文本)" label_voice_only: "开启(仅对语音消息进行语音回复)" label_all: "TTS(对所有消息进行语音回复)" + help: "{toggle}\n\n**/voice 用法**\n• `/voice on` — 当你发送语音消息时用语音回复\n• `/voice tts` — 对*每条*消息都用语音回复\n• `/voice off` — 恢复为纯文本回复\n• `/voice status` — 显示当前模式\n• `/voice`(无参数)— 在开启和关闭之间快速切换{channels}" + help_channels: "\n\n**实时语音频道 (Discord)**\n• 先加入一个语音频道,然后 `/voice channel` — 我会加入、聆听并用语音回复\n• `/voice leave` — 断开语音频道" yolo: disabled: "⚠️ 本会话 YOLO 模式 **已关闭** — 危险命令将需要批准。" From d41427504ef04700027627c906d4d63ba5d11e71 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:46:02 -0700 Subject: [PATCH 044/541] feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * feat(delegation): uncap max_spawn_depth to match max_concurrent_children Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost (each level multiplies API spend) is the practical limiter, not a constant. - delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors at 1 instead of clamping to [1,3]; depth-limit error string reworded - config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling - docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1 - tests: convert clamp-above-3 change-detector into a no-ceiling invariant, drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert --- cli-config.yaml.example | 2 +- hermes_cli/config.py | 6 ++--- tests/tools/test_delegate.py | 14 ++++------ tools/delegate_tool.py | 26 +++++++++++-------- website/docs/guides/delegation-patterns.md | 4 +-- website/docs/user-guide/configuration.md | 4 +-- .../docs/user-guide/features/delegation.md | 4 +-- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index fb6912642ae9..81a47dcc6659 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -871,7 +871,7 @@ delegation: max_iterations: 50 # Max tool-calling turns per child (default: 50) # max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling). # WARNING: values above 10 multiply API cost linearly. - # max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat). + # max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat). # Raise to 2 to allow workers to spawn their own subagents. # Requires role="orchestrator" on intermediate agents. # orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a37c073c8a46..b18a8994e18f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = { # "low", "minimal", "none" (empty = inherit parent's level) "max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling # Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth - # and _get_orchestrator_enabled). Values are clamped to [1, 3] with a - # warning log if out of range. - "max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level) + # and _get_orchestrator_enabled). Floored at 1, no upper ceiling — + # raise deliberately, each level multiplies API cost. + "max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper) "orchestrator_enabled": True, # kill switch for role="orchestrator" # When a subagent hits a dangerous-command approval prompt, the parent's # prompt_toolkit TUI owns stdin — a thread-local input() call from the diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 3efe21389c59..b37bb35d9fc7 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -838,14 +838,13 @@ class TestBlockedTools(unittest.TestCase): def test_constants(self): from tools.delegate_tool import ( _get_max_spawn_depth, _get_orchestrator_enabled, - _MIN_SPAWN_DEPTH, _MAX_SPAWN_DEPTH_CAP, + _MIN_SPAWN_DEPTH, ) self.assertEqual(_get_max_concurrent_children(), 3) self.assertEqual(MAX_DEPTH, 1) self.assertEqual(_get_max_spawn_depth(), 1) # default: flat self.assertTrue(_get_orchestrator_enabled()) # default self.assertEqual(_MIN_SPAWN_DEPTH, 1) - self.assertEqual(_MAX_SPAWN_DEPTH_CAP, 3) class TestDelegationCredentialResolution(unittest.TestCase): @@ -2084,17 +2083,14 @@ class TestMaxSpawnDepth(unittest.TestCase): with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm: result = _get_max_spawn_depth() self.assertEqual(result, 1) - self.assertTrue(any("clamping to 1" in m for m in cm.output)) + self.assertTrue(any("below floor 1" in m for m in cm.output)) @patch("tools.delegate_tool._load_config", return_value={"max_spawn_depth": 99}) - def test_max_spawn_depth_clamped_above_three(self, mock_cfg): - import logging + def test_max_spawn_depth_no_upper_ceiling(self, mock_cfg): + """No upper ceiling — high values pass through unchanged (cost is the limiter).""" from tools.delegate_tool import _get_max_spawn_depth - with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm: - result = _get_max_spawn_depth() - self.assertEqual(result, 3) - self.assertTrue(any("clamping to 3" in m for m in cm.output)) + self.assertEqual(_get_max_spawn_depth(), 99) @patch("tools.delegate_tool._load_config", return_value={"max_spawn_depth": "not-a-number"}) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index d696cab41a94..40e19a0e4da9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -134,7 +134,9 @@ MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected u # Configurable depth cap consulted by _get_max_spawn_depth; MAX_DEPTH # stays as the default fallback and is still the symbol tests import. _MIN_SPAWN_DEPTH = 1 -_MAX_SPAWN_DEPTH_CAP = 3 +# No upper ceiling on spawn depth — like max_concurrent_children, depth has a +# floor of 1 and no ceiling. Deeper trees multiply API cost, so the default +# stays flat (MAX_DEPTH = 1); raising the config knob is an explicit opt-in. # --------------------------------------------------------------------------- @@ -392,7 +394,7 @@ def _get_child_timeout() -> float: def _get_max_spawn_depth() -> int: - """Read delegation.max_spawn_depth from config, clamped to [1, 3]. + """Read delegation.max_spawn_depth from config, floored at 1 (no ceiling). depth 0 = parent agent. max_spawn_depth = N means agents at depths 0..N-1 can spawn; depth N is the leaf floor. Default 1 is flat: @@ -400,9 +402,11 @@ def _get_max_spawn_depth() -> int: (blocked by this guard AND, for leaf children, by the delegation toolset strip in _strip_blocked_tools). - Raise to 2 or 3 to unlock nested orchestration. role="orchestrator" - removes the toolset strip for depth-1 children when + Raise to 2+ to unlock nested orchestration. role="orchestrator" + removes the toolset strip for spawning children when max_spawn_depth >= 2, enabling them to spawn their own workers. + Like max_concurrent_children, there is no upper ceiling — but each + extra level multiplies API cost, so raise it deliberately. """ cfg = _load_config() val = cfg.get("max_spawn_depth") @@ -417,16 +421,15 @@ def _get_max_spawn_depth() -> int: MAX_DEPTH, ) return MAX_DEPTH - clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival)) - if clamped != ival: + floored = max(_MIN_SPAWN_DEPTH, ival) + if floored != ival: logger.warning( - "delegation.max_spawn_depth=%d out of range [%d, %d]; " "clamping to %d", + "delegation.max_spawn_depth=%d below floor %d; using %d", ival, _MIN_SPAWN_DEPTH, - _MAX_SPAWN_DEPTH_CAP, - clamped, + floored, ) - return clamped + return floored def _get_orchestrator_enabled() -> bool: @@ -1982,7 +1985,8 @@ def delegate_task( f"Delegation depth limit reached (depth={depth}, " f"max_spawn_depth={max_spawn}). Raise " f"delegation.max_spawn_depth in config.yaml if deeper " - f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})." + f"nesting is required (no hard ceiling, but each level " + f"multiplies API cost)." ) } ) diff --git a/website/docs/guides/delegation-patterns.md b/website/docs/guides/delegation-patterns.md index 332282e6d4cf..7ff05e2a8a4b 100644 --- a/website/docs/guides/delegation-patterns.md +++ b/website/docs/guides/delegation-patterns.md @@ -218,14 +218,14 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff ## Constraints - **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1) -- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`. +- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (floor 1, no ceiling); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`. ### Tuning Concurrency and Depth | Config | Default | Range | Effect | |--------|---------|-------|--------| | `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call | -| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further | +| `max_spawn_depth` | 1 | >=1 | How many delegation levels can spawn further | Example: running 30 parallel workers with nested subagents: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d74587432d5b..11ed264a8c1e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1672,7 +1672,7 @@ delegation: # api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY) # api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect. max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var. - max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels. + max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3+ = deeper trees. orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth. ``` @@ -1686,7 +1686,7 @@ The delegation provider uses the same credential resolution as CLI/gateway start **Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter). -**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns. +**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (floor of 1, no upper ceiling). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees, and higher for deeper ones. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns. ## Clarify diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 34d9da817e0b..1d19c9fddce9 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -214,7 +214,7 @@ delegate_task( ``` - `role="leaf"` (default): child cannot delegate further — identical to the flat-delegation behavior. -- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3 for three levels (cap). +- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3+ for deeper trees. There is no upper ceiling — cost is the practical limit. - `delegation.orchestrator_enabled: false`: global kill switch that forces every child to `leaf` regardless of the `role` parameter. **Cost warning:** With `max_spawn_depth: 3` and `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. Each extra level multiplies spend — raise `max_spawn_depth` intentionally. @@ -264,7 +264,7 @@ For **durable long-running work** that must survive interrupts or outlive the cu delegation: max_iterations: 50 # Max turns per child (default: 50) # max_concurrent_children: 3 # Parallel children per batch (default: 3) - # max_spawn_depth: 1 # Tree depth (1-3, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3 for three levels. + # max_spawn_depth: 1 # Tree depth (floor 1, no ceiling, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3+ for deeper trees. # orchestrator_enabled: true # Disable to force all children to leaf role. model: "google/gemini-3-flash-preview" # Optional provider/model override provider: "openrouter" # Optional built-in provider From 947e21b3d69f34d33559c20ed86d596fd83f0782 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:50:04 -0700 Subject: [PATCH 045/541] fix(gateway): log silent file-delivery drops (#39767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent's reply references a deliverable file path that does not exist on disk, extract_local_files dropped it from native delivery with no log line — the most common reason a promised file never arrives over a messaging platform. Add an INFO log at that drop point so the gap is visible in gateway.log instead of vanishing. Also convert the two print() calls in Telegram's send_document / send_video exception handlers to logger.warning(exc_info=True). print() writes to stdout, which 'hermes logs' never captures, so outbound upload failures (oversized files, Bot API rejections) were invisible. --- gateway/platforms/base.py | 11 +++++++++++ gateway/platforms/telegram.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index b8f74485151f..76084b3cb686 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC): expanded = os.path.expanduser(raw) if os.path.isfile(expanded): found.append((raw, expanded)) + else: + # The reply mentions a deliverable-looking path that does not + # exist on disk, so it is silently dropped from native delivery. + # This is the most common reason a promised file never arrives + # (the model said "here's your file" but never wrote it, or + # referenced the wrong path). Log it so the gap is visible in + # gateway.log rather than vanishing without a trace. + logger.info( + "Skipping bare file path in reply (no file on disk): %s", + _log_safe_path(raw), + ) # Deduplicate by expanded path, preserving discovery order seen: set = set() diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index fa55f2db0042..d2b425b52b9f 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: - print(f"[{self.name}] Failed to send document: {e}") + logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True) return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata) async def send_video( @@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: - print(f"[{self.name}] Failed to send video: {e}") + logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True) return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata) async def send_image( From 72eb42d9ecdf5c20032a405326995c8c1680aa1c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:00:10 -0700 Subject: [PATCH 046/541] feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(update): require managed marker before destructive clean" This reverts commit c8e80cd0bfdbbfa0b14296ef59a1c3d353917add. * Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)" This reverts commit 8a19884bf3995a8d1144c828582de043abb4331c. * chore(install): keep npm ci desktop-build fix after stash revert The destructive-clean reverts (#38542/#39568) pulled the desktop workspace install back to bare `npm install`. The npm ci -> npm install fallback is orthogonal build-correctness (avoids the Windows workspace-hoisting flake where install reports up-to-date against a stale marker while node_modules is empty, breaking tsc -b). Preserve it. * feat(update): settable stash-or-discard for non-interactive local changes Adds updates.non_interactive_local_changes (stash | discard, default stash). Governs ONLY non-interactive updates (desktop/chat app, gateway, --yes) — interactive terminal updates always stash-and-ask, unchanged. - config.py: new key under existing updates section; _config_version 26->27. - main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY), reads the setting; new _discard_stashed_changes() drops the stash (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive). Post-pull restore site branches on it; the bail-out and up-to-date restores always preserve work. - web_server.py + apps/desktop settings: exposes it as a stash/discard select (Advanced section, In-App Update Local Changes). - docs + tests (discard drops, stash restores, interactive ignores setting, missing section defaults to stash). * fix(install.ps1): stash/restore instead of reset --hard on Windows update The PR reverted the destructive update path to stash/restore everywhere except scripts/install.ps1, whose managed-clone update path still ran `git reset --hard HEAD` before checkout — silently destroying agent-edited tracked source on Windows (the same #38542 data-loss class the PR fixes). - Replace `git reset --hard HEAD` with stash-before-checkout + restore-after-checkout, mirroring install.sh. Untracked files are included so agent-created dirs (e.g. tinker-atropos/) survive. - Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made the stash necessary; it's also load-bearing for a clean restore). - Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses `git pull --ff-only` so local commits are never clobbered. - Only prompt to restore when a real console is attached (UserInteractive + non-redirected stdin/stdout + ConsoleHost); the desktop Update button and bootstrap have no usable console, so they default to restore and never hang on Read-Host. - On restore conflict or a failed update, the stash is preserved with recovery instructions — work is never silently dropped. Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean; E2E non-conflicting restore applies+drops cleanly with ignored paths (node_modules) untouched; conflicting restore preserves the stash. --------- Co-authored-by: alt-glitch --- apps/desktop/src/app/settings/constants.ts | 13 +- hermes_cli/config.py | 20 +- hermes_cli/main.py | 191 +++++++++---------- hermes_cli/web_server.py | 11 ++ scripts/install.ps1 | 76 +++++++- scripts/install.sh | 56 ++++-- tests/hermes_cli/test_update_autostash.py | 205 +++++++++------------ website/docs/getting-started/updating.md | 18 ++ 8 files changed, 335 insertions(+), 255 deletions(-) diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 1a2891d7257c..99efb342589e 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record = { 'memory.provider': ['', 'builtin', 'honcho'], 'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'], 'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'], - 'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'] + 'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], + 'updates.non_interactive_local_changes': ['stash', 'discard'] } export const FIELD_LABELS: Record = { @@ -309,7 +310,8 @@ export const FIELD_LABELS: Record = { 'delegation.max_iterations': 'Subagent Turn Limit', 'delegation.max_concurrent_children': 'Parallel Subagents', 'delegation.child_timeout_seconds': 'Subagent Timeout', - 'delegation.reasoning_effort': 'Subagent Reasoning Effort' + 'delegation.reasoning_effort': 'Subagent Reasoning Effort', + 'updates.non_interactive_local_changes': 'In-App Update Local Changes' } export const FIELD_DESCRIPTIONS: Record = { @@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record = { 'voice.auto_tts': 'Automatically speak assistant responses.', 'stt.enabled': 'Enable local or provider-backed speech transcription.', 'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.', - 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.' + 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.', + 'updates.non_interactive_local_changes': + 'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.' } // Curated desktop config surface: only fields a user might tune from the app. @@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [ 'delegation.max_iterations', 'delegation.max_concurrent_children', 'delegation.child_timeout_seconds', - 'delegation.reasoning_effort' + 'delegation.reasoning_effort', + 'updates.non_interactive_local_changes' ] } ] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b18a8994e18f..644485d8d361 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2275,6 +2275,22 @@ DEFAULT_CONFIG = { # disable backups entirely, set ``pre_update_backup: false`` above # rather than ``backup_keep: 0``. "backup_keep": 5, + # What `hermes update` does with uncommitted local changes to the + # source tree when it runs NON-interactively — i.e. triggered from + # the desktop/chat app or the gateway, where there's no TTY to answer + # a restore prompt. Interactive (terminal) updates are unaffected: + # they always stash the changes and ask whether to restore, exactly + # as they always have. + # "stash" — auto-stash the changes, pull, then auto-restore them + # on top of the updated code (the safe default; nothing + # is ever lost — conflicts are preserved in a git stash). + # "discard" — auto-stash the changes and throw the stash away after + # the pull. Use this only if you never intend to keep + # local edits to the source tree on this machine. + # Stash-and-drop (not `reset --hard` + `clean -fd`) so + # ignored paths — node_modules, venv, build outputs — + # are never touched. + "non_interactive_local_changes": "stash", }, # Language Server Protocol — semantic diagnostics from real @@ -2404,7 +2420,7 @@ DEFAULT_CONFIG = { # Config schema version - bump this when adding new required fields - "_config_version": 26, + "_config_version": 27, } # ============================================================================= @@ -3959,7 +3975,7 @@ _KNOWN_ROOT_KEYS = { "fallback_providers", "credential_pool_strategies", "toolsets", "agent", "terminal", "display", "compression", "delegation", "auxiliary", "custom_providers", "context", "memory", "gateway", - "sessions", "streaming", + "sessions", "streaming", "updates", } # Valid fields inside a custom_providers list entry diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f2df6e778d13..7527b4d48af1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8148,59 +8148,6 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st return stash_ref -def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool: - """Discard working-tree dirt on an explicitly managed checkout. - - On a Desktop/bootstrap-managed install the user never edits the source - tree, so any "dirty" state is pure git artifact: CRLF renormalization, npm - lockfile churn, or files left behind when a directory was deleted upstream - (e.g. apps/bootstrap-installer/). Stashing that dirt and re-applying it - after a pull is actively dangerous — the stash/restore cycle has been - observed to clobber freshly-pulled source files (apps/desktop/ deletion → - "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html"). - - For an explicitly managed checkout the correct move is to throw the dirt - away with ``git reset --hard HEAD`` + ``git clean -fd`` (mirroring - install.ps1's update path), NOT preserve it. Ordinary source checkouts, - including upstream-origin checkouts, keep the stash machinery because - their local edits may be intentional. - - Returns True if the tree was cleaned (or was already clean), False on - a git failure (caller should fall back to the stash path). - """ - status = subprocess.run( - git_cmd + ["status", "--porcelain"], - cwd=cwd, - capture_output=True, - text=True, - ) - if status.returncode != 0: - return False - if not status.stdout.strip(): - return True - - print("→ Discarding working-tree changes on managed clone before update...") - reset = subprocess.run( - git_cmd + ["reset", "--hard", "HEAD"], - cwd=cwd, - capture_output=True, - text=True, - ) - if reset.returncode != 0: - return False - # Drop untracked files too (e.g. orphaned build artifacts), but never - # touch ignored paths — node_modules, venv, build outputs, and the like - # are expensive to rebuild and not git-artifact dirt. - subprocess.run( - git_cmd + ["clean", "-fd"], - cwd=cwd, - capture_output=True, - text=True, - ) - return True - - - def _resolve_stash_selector( git_cmd: list[str], cwd: Path, stash_ref: str ) -> Optional[str]: @@ -8341,6 +8288,54 @@ def _restore_stashed_changes( return True +def _discard_stashed_changes( + git_cmd: list[str], + cwd: Path, + stash_ref: str, +) -> bool: + """Throw away a stash created before an update, without applying it. + + Used only on a NON-interactive update when the user has set + ``updates.non_interactive_local_changes: discard`` — i.e. they've opted out + of keeping local source edits on this machine. Drops the stash entry + instead of re-applying it, so the working tree stays clean at the freshly + pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only + affects what was stashed (tracked changes + the untracked files we + explicitly captured) — ignored paths like node_modules/venv/build outputs + are never touched, since they were never stashed. + + Returns True if the stash was dropped, False on a git failure (in which + case the stash is left in place for safety). + """ + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print( + "⚠ Configured to discard local changes on non-interactive update, " + "but Hermes couldn't find the stash entry to drop." + ) + _print_stash_cleanup_guidance(stash_ref) + return False + + drop = subprocess.run( + git_cmd + ["stash", "drop", stash_selector], + cwd=cwd, + capture_output=True, + text=True, + ) + if drop.returncode != 0: + print( + "⚠ Configured to discard local changes, but Hermes couldn't drop " + "the saved stash entry." + ) + if drop.stderr.strip(): + print(f" {drop.stderr.strip().splitlines()[0]}") + _print_stash_cleanup_guidance(stash_ref, stash_selector) + return False + + print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).") + return True + + # ========================================================================= # Fork detection and upstream management for `hermes update` # ========================================================================= @@ -8353,7 +8348,6 @@ OFFICIAL_REPO_URLS = { } OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" -MANAGED_CHECKOUT_MARKERS = (".hermes-bootstrap-complete",) def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: @@ -8389,19 +8383,6 @@ def _is_fork(origin_url: Optional[str]) -> bool: return True -def _is_managed_update_checkout(origin_url: Optional[str], cwd: Path) -> bool: - """Return True when this official checkout is safe to clean destructively. - - The destructive clean path is only safe for checkouts Hermes explicitly - owns. An official ``origin`` alone is not enough proof: contributors can - also work from upstream-origin source checkouts with intentional local - files. - """ - if not origin_url or _is_fork(origin_url): - return False - return any((cwd / marker).is_file() for marker in MANAGED_CHECKOUT_MARKERS) - - def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: """Check if an 'upstream' remote already exists.""" try: @@ -10156,6 +10137,30 @@ def _cmd_update_impl(args, gateway_mode: bool): ) assume_yes = bool(getattr(args, "yes", False)) + # Whether this update is running without a human at the keyboard. + # Interactive terminal updates always stash-and-ask (unchanged behavior); + # only non-interactive updates (desktop/chat app, gateway, `--yes`) consult + # the `updates.non_interactive_local_changes` config setting to decide + # whether to auto-restore stashed local source changes or throw them away. + _non_interactive_update = ( + gateway_mode + or assume_yes + or not (sys.stdin.isatty() and sys.stdout.isatty()) + ) + discard_local_changes = False + if _non_interactive_update: + try: + from hermes_cli.config import load_config + + _update_cfg = (load_config() or {}).get("updates", {}) + if isinstance(_update_cfg, dict): + _mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower() + discard_local_changes = _mode == "discard" + except Exception as exc: + # Never let a config read failure change the safe default. + logger.debug("Could not read updates.non_interactive_local_changes: %s", exc) + discard_local_changes = False + print("⚕ Updating Hermes Agent...") print() @@ -10217,21 +10222,6 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - # On Windows, Git-for-Windows defaults to core.autocrlf=true, which - # renormalizes the repo's LF-only text files to CRLF in the working tree. - # On a managed, never-user-edited clone that makes tracked files read as - # "locally modified", which forces an autostash on every update (and the - # stash/restore cycle can clobber source files — see _stash_local_changes_ - # if_needed below). Pin autocrlf=false so the dirt is never created. This - # mirrors what install.ps1's update path already does (PR #38239). - if sys.platform == "win32" and git_dir.exists(): - subprocess.run( - git_cmd + ["config", "core.autocrlf", "false"], - cwd=PROJECT_ROOT, - check=False, - capture_output=True, - ) - # Discard npm lockfile churn before any stash/branch logic. npm rewrites # tracked package-lock.json files non-deterministically at install/build # time (platform-specific optional deps, ideallyInert annotations, etc.), @@ -10241,12 +10231,9 @@ def _cmd_update_impl(args, gateway_mode: bool): # lockfile churn) update with a clean tree. _discard_lockfile_churn(git_cmd, PROJECT_ROOT) - # Detect if we're updating from a fork, and whether this official-origin - # checkout has an explicit Hermes-owned marker that makes destructive - # worktree cleanup safe. + # Detect if we're updating from a fork (before any branch logic) origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) - is_managed_checkout = _is_managed_update_checkout(origin_url, PROJECT_ROOT) if is_fork: print("⚠ Updating from fork:") @@ -10312,15 +10299,8 @@ def _cmd_update_impl(args, gateway_mode: bool): else f"branch '{current_branch}'" ) print(f" ⚠ Currently on {label} — switching to {branch} for update...") - # Stash before checkout so uncommitted work isn't lost — but on an - # explicitly managed checkout there's nothing to preserve, so - # discard git-artifact dirt instead (a dirty tree would otherwise - # block the checkout). Other checkouts keep the stash so their - # edits survive. - if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): - auto_stash_ref = None - else: - auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + # Stash before checkout so uncommitted work isn't lost + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) checkout_result = subprocess.run( git_cmd + ["checkout", branch], cwd=PROJECT_ROOT, @@ -10354,17 +10334,7 @@ def _cmd_update_impl(args, gateway_mode: bool): print(f" {track_result.stderr.strip().splitlines()[0]}") sys.exit(1) else: - # On an explicitly managed checkout the user never edits the - # source tree, so any dirt is git artifact (CRLF, lockfile churn, - # upstream-deleted dirs). Throw it away rather than stash/restore - # it — the stash/restore cycle has clobbered freshly-pulled source - # (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Other - # checkouts fall through to the stash path so their intentional - # edits survive. - if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): - auto_stash_ref = None - else: - auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) prompt_for_restore = ( auto_stash_ref is not None @@ -10516,6 +10486,15 @@ def _cmd_update_impl(args, gateway_mode: bool): f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" ) print(f" Restore manually with: git stash apply") + elif discard_local_changes: + # Non-interactive update + user opted into discarding local + # source edits (updates.non_interactive_local_changes: + # discard). Throw the stash away instead of re-applying it. + _discard_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + ) else: _restore_stashed_changes( git_cmd, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3798f71e8bd0..98107dcbe959 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -439,6 +439,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Reasoning effort for delegated subagents", "options": ["", "low", "medium", "high"], }, + "updates.non_interactive_local_changes": { + "type": "select", + "description": ( + "When the chat app / gateway updates Hermes (no terminal prompt), " + "what to do with uncommitted local source edits. 'stash' keeps them " + "and re-applies them after the update; 'discard' throws them away. " + "Terminal updates always ask, regardless of this setting." + ), + "options": ["stash", "discard"], + }, } # Categories with fewer fields get merged into "general" to avoid tab sprawl. @@ -455,6 +465,7 @@ _CATEGORY_MERGE: Dict[str, str] = { "code_execution": "agent", "prompt_caching": "agent", "goals": "agent", + "updates": "general", # Only `telegram.reactions` currently lives under telegram — fold it in # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c6179e1b6974..fea73649ca5c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1063,6 +1063,7 @@ function Install-Repository { # EAP=Stop. We rely on $LASTEXITCODE for actual failures. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" + $autostashRef = "" try { # This is a MANAGED checkout, not a repo the user edits. Git for # Windows defaults to core.autocrlf=true, which renormalizes the @@ -1071,12 +1072,23 @@ function Install-Repository { # show as locally modified even though nobody touched them. A # bare `git checkout` then aborts with "Your local changes would # be overwritten by checkout", which is exactly the failure GUI - # users hit on update. Two-part fix: (1) stop creating the dirt - # by pinning autocrlf=false on this clone, (2) discard any - # pre-existing dirt with a hard reset before the checkout. Safe - # because nothing here is user-authored. + # users hit on update. Pin autocrlf=false so the dirt is never + # created in the first place. git -c windows.appendAtomically=false config core.autocrlf false 2>$null - git -c windows.appendAtomically=false reset --hard HEAD 2>$null + # Preserve any real local changes before the checkout instead of + # discarding them with `reset --hard HEAD`. The old hard reset + # silently destroyed agent-edited source on managed clones (the + # #38542 data-loss class). Stash + restore mirrors install.sh: + # nothing is lost, and a failed restore leaves the work in a + # git stash for manual recovery. Untracked files are included so + # agent-created dirs (e.g. tinker-atropos/) survive too. + $statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null + if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) { + $stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss") + Write-Info "Local changes detected, stashing before update..." + git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName" + if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" } + } git -c windows.appendAtomically=false fetch origin if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } # Precedence: Commit > Tag > Branch. Commit and Tag check @@ -1095,10 +1107,62 @@ function Install-Repository { } else { git -c windows.appendAtomically=false checkout $Branch if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" } - git -c windows.appendAtomically=false pull origin $Branch + git -c windows.appendAtomically=false pull --ff-only origin $Branch if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" } } + + if ($autostashRef) { + # Default to restoring so work is never silently dropped. + # Only prompt when we're certain a human can answer: an + # interactive session AND a real, non-redirected console on + # both stdin and stdout. The desktop "Update" button and + # bootstrap run the installer without a usable console -- in + # those cases Read-Host would hang or return empty, so we + # skip the prompt and just restore (the safe default). + $restoreNow = $true + $hasConsole = $false + try { + $hasConsole = ( + [Environment]::UserInteractive ` + -and (-not [Console]::IsInputRedirected) ` + -and (-not [Console]::IsOutputRedirected) ` + -and ($Host.Name -eq "ConsoleHost") + ) + } catch { $hasConsole = $false } + if ($hasConsole) { + Write-Warn "Local changes were stashed before updating." + Write-Warn "Restoring them may reapply local customizations onto the updated codebase." + $restoreAnswer = Read-Host "Restore local changes now? [Y/n]" + if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false } + } + + if ($restoreNow) { + Write-Info "Restoring local changes..." + git -c windows.appendAtomically=false stash apply $autostashRef + if ($LASTEXITCODE -eq 0) { + git -c windows.appendAtomically=false stash drop $autostashRef 2>$null + Write-Warn "Local changes were restored on top of the updated codebase." + Write-Warn "Review git diff / git status if Hermes behaves unexpectedly." + } else { + Write-Err "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." + Write-Info "Resolve manually with: git stash apply $autostashRef" + throw "git stash apply failed after update" + } + } else { + Write-Info "Skipped restoring local changes." + Write-Info "Your changes are still preserved in git stash." + Write-Info "Restore manually with: git stash apply $autostashRef" + } + $autostashRef = "" + } } finally { + if ($autostashRef) { + # We stashed but never reached the restore block (a fetch/ + # checkout/pull failure threw). Leave the stash in place and + # tell the user how to recover it -- never silently drop it. + Write-Warn "Update did not complete. Your local changes are preserved in git stash." + Write-Info "Restore manually with: git stash apply $autostashRef" + } $ErrorActionPreference = $prevEAP Pop-Location } diff --git a/scripts/install.sh b/scripts/install.sh index 06758f1f0b23..d735c6d75bc6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1097,24 +1097,50 @@ clone_repo() { log_info "Existing installation found, updating..." cd "$INSTALL_DIR" - # This is a managed clone the user never edits, so any working-tree - # dirt is git artifact (CRLF renormalization, npm lockfile churn, - # files left behind when a directory was deleted upstream such as - # apps/bootstrap-installer/). The old path stashed that dirt and - # re-applied it after the pull, but the stash/restore cycle has - # clobbered freshly-pulled source files (apps/desktop/ → - # "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html"). - # Discard the dirt with a hard reset instead — mirrors install.ps1's - # update path. Fork users customize via `hermes update`, which keeps - # the stash machinery; the installer is a managed-only entry point. - git fetch origin + local autostash_ref="" if [ -n "$(git status --porcelain)" ]; then - log_info "Discarding working-tree changes on managed clone before update..." - git reset --hard HEAD >/dev/null 2>&1 || true - git clean -fd >/dev/null 2>&1 || true + local stash_name + stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)" + log_info "Local changes detected, stashing before update..." + git stash push --include-untracked -m "$stash_name" + autostash_ref="stash@{0}" fi + + git fetch origin git checkout "$BRANCH" - git reset --hard "origin/$BRANCH" + git pull --ff-only origin "$BRANCH" + + if [ -n "$autostash_ref" ]; then + local restore_now="yes" + if [ -t 0 ] && [ -t 1 ]; then + echo + log_warn "Local changes were stashed before updating." + log_warn "Restoring them may reapply local customizations onto the updated codebase." + printf "Restore local changes now? [Y/n] " + read -r restore_answer + case "$restore_answer" in + ""|y|Y|yes|YES|Yes) restore_now="yes" ;; + *) restore_now="no" ;; + esac + fi + + if [ "$restore_now" = "yes" ]; then + log_info "Restoring local changes..." + if git stash apply "$autostash_ref"; then + git stash drop "$autostash_ref" >/dev/null + log_warn "Local changes were restored on top of the updated codebase." + log_warn "Review git diff / git status if Hermes behaves unexpectedly." + else + log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." + log_info "Resolve manually with: git stash apply $autostash_ref" + exit 1 + fi + else + log_info "Skipped restoring local changes." + log_info "Your changes are still preserved in git stash." + log_info "Restore manually with: git stash apply $autostash_ref" + fi + fi else log_error "Directory exists but is not a git repository: $INSTALL_DIR" log_info "Remove it or choose a different directory with --dir" diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 16946834ab11..8457784c78bc 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -592,10 +592,6 @@ def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatc hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) - # Force the stash path (not the managed-clone clean path) so this test - # exercises stash restore. A real fork, or a clone where the managed - # clean fails, falls through to stash. - monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", @@ -634,121 +630,6 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): assert len(checkout_calls) == 0 -def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path): - """On an explicitly managed clone, working-tree dirt is discarded via - _clean_managed_worktree, NOT preserved via stash/restore. - - The stash/restore cycle has clobbered freshly-pulled source files - (apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A checkout with - the Desktop/bootstrap marker has nothing the user authored, so the correct - move is to throw the git-artifact dirt away and pull cleanly. - """ - _setup_update_mocks(monkeypatch, tmp_path) - (tmp_path / ".hermes-bootstrap-complete").write_text("{}", encoding="utf-8") - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - # Official origin → not a fork. - monkeypatch.setattr( - hermes_main, "_get_origin_url", - lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "shouldnotbeused", - ) - restore_calls = [] - monkeypatch.setattr( - hermes_main, "_restore_stashed_changes", - lambda *a, **kw: restore_calls.append(1) or True, - ) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - # Managed clean path used; stash path never touched. - assert len(clean_calls) == 1 - assert len(stash_calls) == 0 - assert len(restore_calls) == 0 - - -def test_cmd_update_official_checkout_without_managed_marker_stashes(monkeypatch, tmp_path): - """An upstream-origin source checkout is not safe to clean destructively - unless Hermes wrote an explicit managed-checkout marker.""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - hermes_main, - "_get_origin_url", - lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, - "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, - "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "abc123", - ) - restore_calls = [] - monkeypatch.setattr( - hermes_main, - "_restore_stashed_changes", - lambda *a, **kw: restore_calls.append(1) or True, - ) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - assert len(clean_calls) == 0 - assert len(stash_calls) == 1 - assert len(restore_calls) == 1 - - -def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path): - """A fork (non-official origin) keeps the stash machinery so the user's - intentional local edits survive the update.""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - hermes_main, "_get_origin_url", - lambda *a, **kw: "https://github.com/someuser/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "abc123", - ) - monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True) - monkeypatch.setattr(hermes_main, "_sync_with_upstream_if_needed", lambda *a, **kw: None) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - # Fork: stash path used, managed clean NOT used. - assert len(stash_calls) == 1 - assert len(clean_calls) == 0 - - # --------------------------------------------------------------------------- # Fetch failure — friendly error messages # --------------------------------------------------------------------------- @@ -799,9 +680,6 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) - # Force the stash path so this test exercises the reset-failure handling - # of the stash branch (not the managed-clone clean path). - monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", @@ -821,6 +699,89 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, assert "preserved in stash" in out +# --------------------------------------------------------------------------- +# Non-interactive update.non_interactive_local_changes setting +# (chat app / gateway): "discard" throws stashed changes away, "stash" +# (default) restores them. Interactive terminal updates ignore the setting +# and always go through the restore path. +# --------------------------------------------------------------------------- + +def _setup_setting_test(monkeypatch, tmp_path, mode): + """Common wiring: real stash returns a ref, restore + discard are + recorded, and load_config reports the given non_interactive_local_changes + mode.""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr( + hermes_main, "_stash_local_changes_if_needed", + lambda *a, **kw: "abc123deadbeef", + ) + restore_calls = [] + discard_calls = [] + monkeypatch.setattr( + hermes_main, "_restore_stashed_changes", + lambda *a, **kw: restore_calls.append(1) or True, + ) + monkeypatch.setattr( + hermes_main, "_discard_stashed_changes", + lambda *a, **kw: discard_calls.append(1) or True, + ) + monkeypatch.setattr( + hermes_config, "load_config", + lambda *a, **kw: {"updates": {"non_interactive_local_changes": mode}}, + ) + side_effect, recorded = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + return restore_calls, discard_calls, recorded + + +def test_non_interactive_discard_throws_changes_away(monkeypatch, tmp_path): + """Gateway/chat-app update with discard mode drops the stash, never restores.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard") + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(discard_calls) == 1 + assert len(restore_calls) == 0 + + +def test_non_interactive_stash_restores_changes(monkeypatch, tmp_path): + """Gateway/chat-app update with the default stash mode restores, never discards.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash") + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + +def test_interactive_update_ignores_discard_setting(monkeypatch, tmp_path): + """An interactive (TTY) terminal update always restores — the discard + setting only governs non-interactive updates.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard") + # Force an interactive TTY so _non_interactive_update is False even though + # the config says discard. + monkeypatch.setattr(hermes_main.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(hermes_main.sys.stdout, "isatty", lambda: True) + + hermes_main.cmd_update(SimpleNamespace()) # no gateway, no --yes + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + +def test_non_interactive_defaults_to_stash_when_setting_absent(monkeypatch, tmp_path): + """A config with no update section falls back to stash (safe default).""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash") + # Override load_config to return a config with NO update section at all. + monkeypatch.setattr(hermes_config, "load_config", lambda *a, **kw: {"model": {}}) + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + def test_bootstrap_marker_not_autostashed_by_update(tmp_path): """#38529: the Desktop bootstrap marker must be git-ignored so that ``hermes update``'s ``git stash push --include-untracked`` does not sweep it diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 333d6d341729..330b730075da 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -59,6 +59,24 @@ hermes update --check --branch experimental # preview behindness only If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/` (`git checkout -B origin/`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches. +### Local changes on non-interactive updates + +When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates. + +When the update runs **without a terminal** — from the desktop/chat app's "Update" button or a gateway-triggered update — there's no prompt to answer. The `updates.non_interactive_local_changes` setting decides what happens to your stashed changes: + +```yaml +# ~/.hermes/config.yaml +updates: + non_interactive_local_changes: stash # default: keep + auto-restore + # non_interactive_local_changes: discard # throw local source edits away +``` + +- `stash` (default) — auto-stash, pull, then auto-restore your changes on top of the updated code. Nothing is lost; if a restore hits conflicts they're preserved in a git stash for manual recovery. +- `discard` — auto-stash and drop the stash after the pull, so the update always lands on a clean tree. Use this only on machines where you never intend to keep local edits to the Hermes source. It stash-drops (not `git reset --hard` + `git clean -fd`), so ignored paths like `node_modules`, `venv`, and build outputs are never touched. + +In the desktop app this is **Settings → Advanced → In-App Update Local Changes**. + ### Preview-only: `hermes update --check` 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". From db204ae2035021bb5f1e76004c13296853a139b6 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 07:08:43 -0500 Subject: [PATCH 047/541] fix(update): make ensure_uv() survive the update boundary (no first-run crash) (#39780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(update): make ensure_uv() survive the update boundary (no first-run crash) `hermes update` runs the `ensure_uv()` call site from the old, already-imported `hermes_cli.main` against the *freshly pulled* `managed_uv` (managed_uv is only ever lazily imported, so it loads from disk post-pull). `ensure_uv()`'s return arity flipped from a single path string to `(path, fresh_bootstrap)` (4df280d51) and back to a single string (fb853a178). Installs parked on a 2-tuple release unpack `uv_bin, fresh_bootstrap = ensure_uv()` against the new single-value module and crash the first update with `ValueError: not enough values to unpack (expected 2, got 1)` — inside the dependency-install step, *before* the PR #39763 subprocess hand-off can run. Return a `_UvResult` (a `str` subclass) that is usable as the bare path AND unpackable as `(path|None, fresh_bootstrap)`. Missing uv is `""` (falsy) instead of `None` so legacy 2-target call sites can unpack a failure without raising, while `if not uv_bin` keeps working for single-value callers. fresh_bootstrap is always False (the rebuild-venv path it gated was scrapped in fb853a178). * docs(update): correct the verified error string + mechanism for ensure_uv() A hermetic repro (old 2-target call site vs the freshly-pulled single-value module) shows the first-update crash is exactly the string from PR #39763's report: `ValueError: too many values to unpack (expected 2)` — not "not enough". The returned path is a plain `str`, which is iterable, so `uv_bin, fresh = ensure_uv()` walks its characters; the failure path's `None` return raises `TypeError: cannot unpack non-iterable NoneType`. Both are fixed by `_UvResult`. Comment/test wording updated to match; no behavior change. --- hermes_cli/managed_uv.py | 51 +++++++++++++++++++++++++---- tests/hermes_cli/test_managed_uv.py | 47 ++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 95cde0e7bc86..9be7f7cbb43c 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -51,15 +51,54 @@ def resolve_uv() -> Optional[str]: return None -def ensure_uv() -> Optional[str]: +class _UvResult(str): + """``ensure_uv()`` return value that survives an update boundary. + + ``ensure_uv()``'s arity has flipped between a single path string and a + ``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs + the call site from the *old*, already-imported ``hermes_cli.main`` against + this *freshly pulled* module, so the two can disagree on how many values + ``ensure_uv()`` returns. An install parked on a 2-tuple release runs + ``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module + and crashes the first update: the returned path is a plain ``str``, which is + itself iterable, so the 2-target unpack walks its characters and raises + ``ValueError: too many values to unpack (expected 2)`` (and on the failure + path the ``None`` return raises ``TypeError: cannot unpack non-iterable + NoneType``). This wrapper answers to both conventions: + + uv_bin = ensure_uv() # behaves as the path str ("" when absent) + uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap) + + Missing uv is the empty string (falsy) instead of ``None`` so legacy + 2-target call sites can still unpack a failure without raising, while + ``if not uv_bin`` keeps working for single-value callers. + """ + + fresh_bootstrap: bool + + def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult": + self = super().__new__(cls, path or "") + self.fresh_bootstrap = fresh + return self + + def __iter__(self): + # Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites. + # First element mirrors the historical contract: the path string, or + # ``None`` when uv is unavailable. + return iter(((str(self) or None), self.fresh_bootstrap)) + + +def ensure_uv() -> "_UvResult": """Return the managed uv path, installing it first if necessary. - On failure returns ``None`` (never raises) so callers can fall - back to pip gracefully. + Returns a :class:`_UvResult` (a ``str`` subclass) that is both usable + directly as the path and unpackable as ``(path, fresh_bootstrap)`` for + older call sites. On failure the result is falsy (``""``) — never raises — + so callers can fall back to pip gracefully. """ existing = resolve_uv() if existing: - return existing + return _UvResult(existing) target = managed_uv_path() target.parent.mkdir(parents=True, exist_ok=True) @@ -71,7 +110,7 @@ def ensure_uv() -> Optional[str]: except Exception as exc: logger.warning("Managed uv install failed: %s", exc) print(f" ✗ Failed to install managed uv: {exc}") - return None + return _UvResult(None) # Verify result = resolve_uv() @@ -85,7 +124,7 @@ def ensure_uv() -> Optional[str]: print(f" ✓ Managed uv installed ({version})") else: print(" ✗ Managed uv install appeared to succeed but binary not found") - return result + return _UvResult(result) def update_managed_uv() -> Optional[str]: diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index a9e9b92a67a9..8f55f1751df7 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -92,12 +92,55 @@ class TestEnsureUv: assert path == str(tmp_path / "bin" / "uv") mock_install.assert_called_once() - def test_install_failure_returns_none(self, tmp_path): + def test_install_failure_returns_falsy(self, tmp_path): with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): from hermes_cli.managed_uv import ensure_uv path = ensure_uv() - assert path is None + # Failure is a falsy sentinel (not None) so legacy 2-target call + # sites can still unpack it without raising — see + # TestEnsureUvUpdateBoundary for why. + assert not path + + +class TestEnsureUvUpdateBoundary: + """``ensure_uv()`` must answer to both the single-value and the legacy + ``(path, fresh_bootstrap)`` call conventions. + + ``hermes update`` runs the call site from the old, already-imported + ``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release + parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()`` + against the single-value module; the path is an iterable ``str`` so the + 2-target unpack walked its characters and raised + ``ValueError: too many values to unpack (expected 2)`` (root cause behind + PR #39763), or ``TypeError`` on the ``None`` failure path. The result must + therefore be usable as a bare path *and* unpackable as a 2-tuple, in both + the success and failure cases. + """ + + def test_success_usable_as_single_value(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import ensure_uv + uv_bin = ensure_uv() + assert uv_bin == str(tmp_path / "bin" / "uv") + assert bool(uv_bin) is True + + def test_success_unpacks_as_legacy_two_tuple(self, tmp_path): + _make_executable(tmp_path / "bin" / "uv") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + from hermes_cli.managed_uv import ensure_uv + uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv() + assert uv_bin == str(tmp_path / "bin" / "uv") + assert fresh is False + + def test_failure_unpacks_without_raising(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): + from hermes_cli.managed_uv import ensure_uv + uv_bin, fresh = ensure_uv() + assert uv_bin is None + assert fresh is False # --------------------------------------------------------------------------- From 1a3e608524a3dfae7aca9c058b1cbb1b08e19489 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 07:14:18 -0500 Subject: [PATCH 048/541] feat(desktop): per-profile remote gateway hosts (#39778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): per-profile remote gateway hosts Profile switching silently failed whenever the desktop was connected to a remote backend: the rail routed non-active profiles to a local pool backend, but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a remote Hermes backend", and the renderer swallowed the error into an infinite reconnect backoff while still marking the profile active. Remote was also a single app-global setting, so there was no way to give a profile its own host. Add per-profile remote hosts so each profile can point at its own backend: - connection.json gains a validated `profiles` map; profileRemoteOverride() (pure, unit-tested) selects an explicit per-profile remote. - resolveRemoteBackend(profile) precedence: per-profile override → env override → global remote → local spawn. spawnPoolBackend now connects to a profile's remote (no local child) instead of throwing; startHermes resolves the primary profile's remote. - coerce/sanitize connection config are scope-aware (global vs named profile) and preserve each other's entries; IPC get/save/apply/test thread an optional profile. Per-profile apply drops only that profile's pool backend. - Settings → Gateway adds an "Applies to" scope selector reusing the existing URL/token/OAuth/test UX per profile. Tests: connection-config pure suite (+6) and desktop platform suite pass; tsc/eslint/vitest clean. * refactor(desktop): DRY per-profile remote helpers Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the main.cjs copy), collapse the scope/auth ternaries, route the env remote through buildRemoteConnection, and fold the duplicated remote-block validation into buildRemoteBlock. No behavior change; pure suite + live E2E still green. --- apps/desktop/electron/connection-config.cjs | 38 +++ .../electron/connection-config.test.cjs | 63 ++++ apps/desktop/electron/main.cjs | 292 ++++++++++++------ apps/desktop/electron/preload.cjs | 2 +- .../src/app/settings/gateway-settings.tsx | 77 ++++- .../components/boot-failure-reauth.test.ts | 1 + apps/desktop/src/global.d.ts | 8 +- 7 files changed, 378 insertions(+), 103 deletions(-) diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 44ffb3d7a637..4595ca043c9c 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -131,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) { return buildGatewayWsUrl(baseUrl, token) } +// Normalize a profile name to a connection scope key, or null for the global +// (default) connection. Shared by the resolver and the IPC layer. +function connectionScopeKey(profile) { + return String(profile ?? '').trim() || null +} + +// Coerce a remote auth mode to one of the two supported values ('token' default). +function normAuthMode(mode) { + return mode === 'oauth' ? 'oauth' : 'token' +} + +/** + * Select a profile's explicit remote override from a connection config, or null + * when it has none (so the caller falls back to env → global remote → local). + * + * The config may carry a `profiles` map keyed by name; an entry counts as an + * override only with `mode === 'remote'` and a non-empty `url`. Pure: `token` + * is the raw stored secret; main.cjs decrypts it. Returns + * `{ url, authMode, token } | null`. + */ +function profileRemoteOverride(config, profile) { + const key = connectionScopeKey(profile) + const entry = key ? config?.profiles?.[key] : null + if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') { + return null + } + + const url = String(entry.url || '').trim() + if (!url) { + return null + } + + return { url, authMode: normAuthMode(entry.authMode), token: entry.token } +} + function tokenPreview(value) { const raw = String(value || '') @@ -207,9 +242,12 @@ module.exports = { authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, + connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + normAuthMode, normalizeRemoteBaseUrl, + profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, tokenPreview diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 6819e6407e12..7e7332ca33f4 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -19,14 +19,77 @@ const { authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, + connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + normAuthMode, normalizeRemoteBaseUrl, + profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, tokenPreview } = require('./connection-config.cjs') +// --- connectionScopeKey / normAuthMode --- + +test('connectionScopeKey trims to a name or null for the global scope', () => { + assert.equal(connectionScopeKey(' coder '), 'coder') + assert.equal(connectionScopeKey(''), null) + assert.equal(connectionScopeKey(null), null) + assert.equal(connectionScopeKey(undefined), null) +}) + +test('normAuthMode coerces to token unless explicitly oauth', () => { + assert.equal(normAuthMode('oauth'), 'oauth') + assert.equal(normAuthMode('token'), 'token') + assert.equal(normAuthMode(undefined), 'token') + assert.equal(normAuthMode('weird'), 'token') +}) + +// --- profileRemoteOverride --- + +test('profileRemoteOverride returns null when no profile is given', () => { + const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } } + assert.equal(profileRemoteOverride(config, ''), null) + assert.equal(profileRemoteOverride(config, null), null) + assert.equal(profileRemoteOverride(config, undefined), null) +}) + +test('profileRemoteOverride returns null when the profile has no entry', () => { + const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } } + assert.equal(profileRemoteOverride(config, 'writer'), null) +}) + +test('profileRemoteOverride ignores local or url-less profile entries', () => { + assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null) + assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null) + assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null) +}) + +test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => { + const config = { + profiles: { + coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } } + } + } + assert.deepEqual(profileRemoteOverride(config, 'coder'), { + url: 'https://coder.example.com/hermes', + authMode: 'token', + token: { value: 'sek' } + }) +}) + +test('profileRemoteOverride preserves an explicit oauth auth mode', () => { + const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } } + assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth') +}) + +test('profileRemoteOverride tolerates a missing/!object profiles map', () => { + assert.equal(profileRemoteOverride({}, 'coder'), null) + assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null) + assert.equal(profileRemoteOverride(null, 'coder'), null) +}) + // --- normalizeRemoteBaseUrl --- test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index a78260b81d5d..583a316dcbe7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -32,9 +32,12 @@ const { authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, + connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + normAuthMode, normalizeRemoteBaseUrl, + profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, tokenPreview @@ -3481,6 +3484,38 @@ function decryptDesktopSecret(secret) { return value } +// Validate + normalize the per-profile remote overrides map read from disk. +// Drops malformed names/entries and keeps only the recognized fields so a +// hand-edited or stale connection.json can't inject junk into resolution. +function sanitizeConnectionProfiles(raw) { + if (!raw || typeof raw !== 'object') { + return {} + } + + const out = {} + for (const [name, entry] of Object.entries(raw)) { + if (!entry || typeof entry !== 'object') { + continue + } + if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { + continue + } + + const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const url = String(entry.url || '').trim() + if (url) { + cleaned.url = url + } + cleaned.authMode = normAuthMode(entry.authMode) + if (entry.token && typeof entry.token === 'object') { + cleaned.token = entry.token + } + out[name] = cleaned + } + + return out +} + function readDesktopConnectionConfig() { // Check if file changed on disk since last read (e.g. modified by another // process or an external tool). Our own writes update the cache inline @@ -3496,7 +3531,7 @@ function readDesktopConnectionConfig() { return connectionConfigCache } - let config = { mode: 'local', remote: {} } + let config = { mode: 'local', remote: {}, profiles: {} } try { const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8') @@ -3510,7 +3545,11 @@ function readDesktopConnectionConfig() { remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { mode: parsed.mode === 'remote' ? 'remote' : 'local', - remote + remote, + // Per-profile remote overrides: each profile may point at its own + // backend (local spawn or its own remote URL). Preserved verbatim so + // profileRemoteOverride() can resolve them; normalized lazily on save. + profiles: sanitizeConnectionProfiles(parsed.profiles) } } } catch { @@ -3562,10 +3601,19 @@ function writeActiveDesktopProfile(name) { return value || null } -async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { - const remoteToken = decryptDesktopSecret(config.remote?.token) - const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' - const remoteUrl = String(config.remote?.url || '') +// Sanitize a connection config into the renderer-facing shape. With no +// `profile` this describes the global/default connection (the existing +// behavior); with a `profile` it describes that profile's per-profile remote +// override (or an empty "local/inherit" view when the profile has none). +async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) { + const key = connectionScopeKey(profile) + const scoped = key ? config.profiles?.[key] || null : null + const block = key ? scoped || {} : config.remote || {} + + const remoteToken = decryptDesktopSecret(block.token) + const authMode = normAuthMode(block.authMode) + const remoteUrl = String(block.url || '') + const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' let remoteOauthConnected = false if (authMode === 'oauth' && remoteUrl) { @@ -3581,82 +3629,75 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon } return { - mode: config.mode === 'remote' ? 'remote' : 'local', + mode, + // Echo the scope back so the UI knows which profile (if any) this reflects. + profile: key, remoteAuthMode: authMode, remoteOauthConnected, remoteUrl, remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), - envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + // The env override only forces the global/primary connection; a per-profile + // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. + envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) } } +// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways +// authenticate via the login-window session cookie (verified at connect time in +// resolveRemoteBackend), so only token-auth remotes require a saved token. +function buildRemoteBlock(remoteUrl, authMode, token) { + if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { + throw new Error('Remote gateway session token is required.') + } + return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } +} + function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { const persistToken = options.persistToken !== false + const key = connectionScopeKey(input.profile) const mode = input.mode === 'remote' ? 'remote' : 'local' - const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim() + + // The block being edited: a per-profile entry or the global remote block. + const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. - const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode) + const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' - const existingToken = existing.remote?.token - const nextRemote = { - url: remoteUrl, - authMode, - token: incomingToken - ? persistToken - ? encryptDesktopSecret(incomingToken) - : { encoding: 'plain', value: incomingToken } - : existingToken - } + const nextToken = incomingToken + ? persistToken + ? encryptDesktopSecret(incomingToken) + : { encoding: 'plain', value: incomingToken } + : existingBlock.token - if (mode === 'remote') { - nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) - - // OAuth gateways authenticate via the session cookie established by the - // login window, NOT a static token — so no token is required here. The - // cookie presence is verified at connect time (resolveRemoteBackend). - if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) { - throw new Error('Remote gateway session token is required.') + if (key) { + // Per-profile scope: a remote entry pins this profile to its own backend; a + // local entry clears the override so the profile inherits the default. + const profiles = { ...(existing.profiles || {}) } + if (mode === 'remote') { + profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } + } else { + delete profiles[key] } - } else if (remoteUrl) { - nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) + return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } } - return { mode, remote: nextRemote } + const nextRemote = + mode === 'remote' + ? buildRemoteBlock(remoteUrl, authMode, nextToken) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + + // Preserve per-profile overrides when saving the global connection. + return { mode, remote: nextRemote, profiles: existing.profiles || {} } } -async function resolveRemoteBackend() { - const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL - const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN - - if (rawEnvUrl) { - if (!rawEnvToken) { - throw new Error( - 'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' + - 'Both must be provided to connect to a remote Hermes backend.' - ) - } - - const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl) - - return { - baseUrl, - mode: 'remote', - source: 'env', - authMode: 'token', - token: rawEnvToken, - wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken) - } - } - - const config = readDesktopConnectionConfig() - - if (config.mode !== 'remote') { - return null - } - - const baseUrl = normalizeRemoteBaseUrl(config.remote?.url) - const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' +// Build a remote backend connection descriptor from an already-resolved remote +// config. Handles both auth models (OAuth ws-ticket vs static session token) +// and is shared by the per-profile, env, and global resolution paths. `token` +// is the DECRYPTED static token (or null in OAuth mode). `source` is a label +// for diagnostics ('profile' | 'env' | 'settings'). +async function buildRemoteConnection(rawUrl, authMode, token, source) { + const baseUrl = normalizeRemoteBaseUrl(rawUrl) if (authMode === 'oauth') { // OAuth gateway: auth comes from the session cookies in the OAuth @@ -3664,16 +3705,9 @@ async function resolveRemoteBackend() { // Portal issues a 24h rotating refresh token (hermes #37247), and the // gateway middleware transparently rotates a fresh ~15-min access token // from it on the next authenticated request. So a session with an expired - // AT cookie but a live RT cookie is still perfectly connectable. - // - // We therefore: - // 1. cheap early-out ONLY when the jar holds NEITHER an AT nor an RT - // cookie — a genuinely signed-out user — to avoid a pointless network - // round-trip and give a clear "sign in" message. - // 2. otherwise probe liveness by actually minting a ws-ticket. That POST - // carries the cookie jar (incl. the RT cookie); the gateway refreshes - // the AT server-side and returns a ticket. A real 401 here means the - // RT is also dead/revoked → genuine re-login needed. + // AT cookie but a live RT cookie is still perfectly connectable. We + // early-out only when neither cookie is present, then mint a ws-ticket as + // the authoritative liveness check. if (!(await hasLiveOauthSession(baseUrl))) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + @@ -3685,10 +3719,6 @@ async function resolveRemoteBackend() { let ticket try { - // This mint is the authoritative liveness check. If only the RT cookie - // is alive, the gateway rotates a fresh AT cookie back onto the partition - // via Set-Cookie (Electron's persistent session absorbs it), so the very - // next request is already re-authed — no user-visible re-login. ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( @@ -3702,7 +3732,7 @@ async function resolveRemoteBackend() { return { baseUrl, mode: 'remote', - source: 'settings', + source, authMode: 'oauth', // No static token in OAuth mode; REST is cookie-authed via the partition. token: null, @@ -3710,8 +3740,6 @@ async function resolveRemoteBackend() { } } - const token = decryptDesktopSecret(config.remote?.token) - if (!token) { throw new Error( 'Remote Hermes gateway is selected, but no session token is saved. ' + @@ -3722,13 +3750,54 @@ async function resolveRemoteBackend() { return { baseUrl, mode: 'remote', - source: 'settings', + source, authMode: 'token', token, wsUrl: buildGatewayWsUrl(baseUrl, token) } } +// Resolve the remote backend for a given profile, or null when that profile +// should run a LOCAL backend. Precedence: +// 1. explicit per-profile remote override (connection.json `profiles[name]`) +// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide +// 3. global remote (connection.json `mode: 'remote'`) +// A null/empty profile resolves the env/global remote, so legacy callers and +// the connection test (which pass no profile) are unchanged. +async function resolveRemoteBackend(profile) { + const config = readDesktopConnectionConfig() + + // 1. Per-profile override — "a profile with its own remote host". Wins even + // over the env override so an explicitly-configured profile always + // reaches its intended backend. + const override = profileRemoteOverride(config, profile) + if (override) { + const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) + return buildRemoteConnection(override.url, override.authMode, token, 'profile') + } + + // 2. Env override (global, token-auth only). + const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL + const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (rawEnvUrl) { + if (!rawEnvToken) { + throw new Error( + 'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' + + 'Both must be provided to connect to a remote Hermes backend.' + ) + } + return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') + } + + // 3. Global remote. + if (config.mode !== 'remote') { + return null + } + const authMode = normAuthMode(config.remote?.authMode) + const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) + return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') +} + async function probeRemoteAuthMode(rawUrl) { // Determine how a remote gateway expects callers to authenticate, WITHOUT // sending any credentials. ``/api/status`` is public on every Hermes @@ -3796,6 +3865,11 @@ async function probeRemoteAuthMode(rawUrl) { async function testDesktopConnectionConfig(input = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) + const key = connectionScopeKey(input.profile) + // The block under test: a per-profile entry or the global remote. Coerce has + // already normalized the URL and resolved token inheritance for the scope. + const block = key ? config.profiles?.[key] || null : config.remote + const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -3803,17 +3877,17 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null let authMode = 'token' - if (config.mode === 'remote') { - baseUrl = normalizeRemoteBaseUrl(config.remote.url) - authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token' + if (wantRemote && block?.url) { + baseUrl = normalizeRemoteBaseUrl(block.url) + authMode = normAuthMode(block.authMode) if (authMode !== 'oauth') { - token = decryptDesktopSecret(config.remote.token) + token = decryptDesktopSecret(block.token) } } else { - const remote = (await resolveRemoteBackend()) || (await startHermes()) + const remote = (await resolveRemoteBackend(key)) || (await startHermes()) baseUrl = remote.baseUrl token = remote.token - authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' + authMode = normAuthMode(remote.authMode) } const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) @@ -3985,10 +4059,21 @@ function startPoolIdleReaper() { // local-spawn portion of startHermes() but without the boot-progress UI, // bootstrap, or remote handling (those belong to the primary backend only). async function spawnPoolBackend(profile, entry) { - // Remote deployments are single-tenant; profiles only apply to local backends. - const remote = await resolveRemoteBackend() + // A profile may point at its OWN remote backend (connection.json + // `profiles[name]`), or inherit the app-wide remote (env / global settings). + // In either case there is no local child to spawn — we just verify the + // remote is reachable and hand back its connection descriptor. The pool + // entry keeps `entry.process === null`, which stopPoolBackend/evict already + // tolerate. + const remote = await resolveRemoteBackend(profile) if (remote) { - throw new Error('Profiles are unavailable when connected to a remote Hermes backend.') + await waitForHermes(remote.baseUrl, remote.token) + return { + ...remote, + profile, + logs: hermesLog.slice(-80), + ...getWindowState() + } } const port = await pickPort() @@ -4089,7 +4174,9 @@ async function startHermes() { connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) - const remote = await resolveRemoteBackend() + // Resolve for the desktop's primary profile so a per-profile remote + // override on the active profile is honored (falls back to env / global). + const remote = await resolveRemoteBackend(primaryProfileKey()) if (remote) { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) @@ -4426,7 +4513,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => { }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) -ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig()) +ipcMain.handle('hermes:connection-config:get', async (_event, profile) => + sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile) +) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl)) ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => { @@ -4450,16 +4539,27 @@ ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) - return sanitizeDesktopConnectionConfig(config) + return sanitizeDesktopConnectionConfig(config, payload?.profile) }) ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) - await teardownPrimaryBackendAndWait() + const key = connectionScopeKey(payload?.profile) - mainWindow?.reload() - return sanitizeDesktopConnectionConfig(config) + if (key && key !== primaryProfileKey()) { + // Editing a NON-primary profile's connection: don't disturb the window's + // primary backend. Drop the profile's pooled backend so the next switch + // re-resolves against the new remote/local target. + stopPoolBackend(key) + } else { + // Global connection, or the primary profile's connection: re-home the + // window backend by tearing it down and reloading the renderer. + await teardownPrimaryBackendAndWait() + mainWindow?.reload() + } + + return sanitizeDesktopConnectionConfig(config, payload?.profile) }) ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() })) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 2fcf96e4bfc5..e1479b1ba99b 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), - getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'), + getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload), testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload), diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 9abb1237f6fd..7c6368e97fdf 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' @@ -6,6 +7,7 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' +import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' @@ -74,6 +76,23 @@ function ModeCard({ ) } +function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) { + return ( + + ) +} + export function GatewaySettings() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -83,6 +102,16 @@ export function GatewaySettings() { const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(null) + // Connection scope: null = the global/default connection (the original + // behavior); a profile name = that profile's per-profile remote override, so + // each profile can point at its own backend. + const [scope, setScope] = useState(null) + const profiles = useStore($profiles) + + useEffect(() => { + void refreshActiveProfile() + }, []) + // Auth-mode probe: as the user types a remote URL we ask the gateway (via // its public /api/status) whether it gates with OAuth or a static session // token, so we can show the right control (login button vs token box). @@ -100,8 +129,14 @@ export function GatewaySettings() { return () => void (cancelled = true) } + setLoading(true) + // Clear scope-local entry state so a token from one scope can't leak into + // the next when switching profiles. + setRemoteToken('') + setLastTest(null) + desktop - .getConnectionConfig() + .getConnectionConfig(scope) .then(config => { if (cancelled) { return @@ -117,7 +152,7 @@ export function GatewaySettings() { }) return () => void (cancelled = true) - }, []) + }, [scope]) // Debounced probe of the entered remote URL. Only runs in remote mode with a // syntactically plausible URL. The probe result drives whether we render the @@ -223,6 +258,10 @@ export function GatewaySettings() { return providers.length > 0 && providers.every(p => p.supportsPassword) }, [probe]) + // The 'default' profile uses the global ("All profiles") connection, so the + // per-profile scopes are the named, non-default profiles. + const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles]) + const oauthConnected = state.remoteOauthConnected const canUseRemote = useMemo(() => { @@ -239,6 +278,7 @@ export function GatewaySettings() { const payload = () => ({ mode: state.mode, + profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, remoteUrl: trimmedUrl @@ -296,6 +336,7 @@ export function GatewaySettings() { // oauth mode is persisted, without yet flipping the live connection. const saved = await window.hermesDesktop.saveConnectionConfig({ mode: state.mode, + profile: scope ?? undefined, remoteAuthMode: 'oauth', remoteUrl: trimmedUrl }) @@ -305,7 +346,7 @@ export function GatewaySettings() { const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) if (result.connected) { - const refreshed = await window.hermesDesktop.getConnectionConfig() + const refreshed = await window.hermesDesktop.getConnectionConfig(scope) setState(refreshed) notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` }) } else { @@ -327,7 +368,7 @@ export function GatewaySettings() { try { await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) - const refreshed = await window.hermesDesktop.getConnectionConfig() + const refreshed = await window.hermesDesktop.getConnectionConfig(scope) setState(refreshed) notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' }) } catch (err) { @@ -357,6 +398,7 @@ export function GatewaySettings() { try { const result = await window.hermesDesktop.testConnectionConfig({ mode: 'remote', + profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, remoteUrl: trimmedUrl @@ -395,10 +437,35 @@ export function GatewaySettings() {

Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control - an already-running Hermes backend on another machine or behind a trusted proxy. + an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it + its own remote host.

+ {namedProfiles.length > 0 ? ( +
+
+ Applies to +
+
+ setScope(null)} /> + {namedProfiles.map(profile => ( + setScope(profile.name)} + /> + ))} +
+

+ {scope === null + ? 'Default connection for every profile that has no override of its own.' + : `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`} +

+
+ ) : null} + {state.envOverride ? (
diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 21d7f8229721..613b43f65353 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -8,6 +8,7 @@ function config(overrides: Partial = {}): DesktopConnec return { envOverride: false, mode: 'remote', + profile: null, remoteAuthMode: 'oauth', remoteOauthConnected: false, remoteTokenPreview: null, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 6cbb359946e9..43718037a1d1 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -12,7 +12,7 @@ declare global { touchBackend: (profile?: string | null) => Promise<{ ok: boolean }> getGatewayWsUrl: (profile?: null | string) => Promise getBootProgress: () => Promise - getConnectionConfig: () => Promise + getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise @@ -190,6 +190,9 @@ export interface DesktopActiveProfile { export interface DesktopConnectionConfig { envOverride: boolean mode: 'local' | 'remote' + // The profile this config describes, or null for the global/default + // connection. Per-profile entries let a profile point at its own backend. + profile: null | string remoteAuthMode: 'oauth' | 'token' remoteOauthConnected: boolean remoteTokenPreview: string | null @@ -199,6 +202,9 @@ export interface DesktopConnectionConfig { export interface DesktopConnectionConfigInput { mode: 'local' | 'remote' + // When set, the save/apply/test targets this profile's per-profile remote + // override instead of the global connection. + profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string remoteUrl?: string From ca8c78e588b700e865227de2d2cc5ba1d437f40c Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 07:52:44 -0500 Subject: [PATCH 049/541] fix(desktop): heal stale runtime-id cache + model on profile switch (#39819) Two switch-time regressions from the multi-profile rail work: - "Session not found" (4007): pruneSecondaryGateways idle-reaps a non-active profile's backend; switching back respawns a *fresh* backend that mints new runtime ids, but runtimeIdByStoredSessionId is never pruned. resumeSession's cache fast-path then makes a dead runtime id active and returns, so session.usage + the next prompt 404. Probe the cached id; on rejection drop the stale mapping and fall through to a full resume that rebinds a live id. - "Forgets the LLM setting": $currentModel is a nanostore set only by refreshCurrentModel (gatewayState->open, etc). A swap fires invalidateQueries() (react-query only) and keeps the socket 'open', so the model/pill kept showing the previous profile. Re-pull both when $activeGatewayProfile changes. --- apps/desktop/src/app/desktop-controller.tsx | 21 +++++++++++- .../app/session/hooks/use-session-actions.ts | 32 ++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 00c1d8fa59a4..e5c193ba6308 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -29,7 +29,7 @@ import { unpinSession } from '../store/layout' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' -import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile' +import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile' import { $activeSessionId, $currentCwd, @@ -506,6 +506,25 @@ export function DesktopController() { startFreshSessionDraft() }, [freshSessionRequest, startFreshSessionDraft]) + // Swapping the live gateway to another profile must re-pull that profile's + // global model + active-profile pill. Both are nanostores, so the blanket + // invalidateQueries() the profile store fires on swap doesn't touch them — + // without this the statusbar keeps showing the previous profile's model + // (the "forgets the LLM setting" report). gatewayState stays 'open' across a + // swap (background sockets persist), so the open→open effect won't re-run. + const activeGatewayProfile = useStore($activeGatewayProfile) + const lastGatewayProfileRef = useRef(activeGatewayProfile) + + useEffect(() => { + if (activeGatewayProfile === lastGatewayProfileRef.current) { + return + } + + lastGatewayProfileRef.current = activeGatewayProfile + void refreshCurrentModel() + void refreshActiveProfile() + }, [activeGatewayProfile, refreshCurrentModel]) + const composer = useComposerActions({ activeSessionId, currentCwd, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 3a6251b75f9c..bdd9dd15b874 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -453,15 +453,31 @@ export function useSessionActions({ clearComposerDraft() clearComposerAttachments() - void requestGateway('session.usage', { session_id: cachedRuntimeId }) - .then(usage => { - if (isCurrentResume() && usage) { - setCurrentUsage(current => ({ ...current, ...usage })) - } - }) - .catch(() => undefined) + try { + const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId }) - return + if (!isCurrentResume()) { + return + } + + if (usage) { + setCurrentUsage(current => ({ ...current, ...usage })) + } + + return + } catch { + // The cached runtime id was minted by a prior backend instance. A + // pooled profile backend that gets idle-reaped (pruneSecondaryGateways) + // and respawned across a profile swap mints fresh ids, so this mapping + // now 404s ("session not found"). Drop it and fall through to a full + // resume that rebinds a live runtime id. + if (!isCurrentResume()) { + return + } + + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) + } } setFreshDraftReady(false) From d880b5be098893e7d766a9179da3501181c6fdb8 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 07:54:08 -0500 Subject: [PATCH 050/541] fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #39780 made ensure_uv() return a _UvResult — a str subclass whose __iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()` call sites survive the update boundary. That trick is unsafe on Windows. The dependency installer passes uv straight into the command list (`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv via subprocess.list2cmdline, which iterates every entry *as a string* (`for c in arg`). Because _UvResult overrides __iter__, that iteration yields (path, fresh_bootstrap) instead of characters, injecting the bool into the command line and crashing the first update with: TypeError: sequence item 1: expected str instance, bool found This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on its first update after #39780: the freshly pulled _UvResult flows into the old in-memory call site and into the argv. Reported in the field on a ~10-commits-behind Windows install. A single return value cannot satisfy both legacy 2-target unpacking and Windows char-iteration — both use the iterator protocol with contradictory results. So gate the wrapper to POSIX: Windows returns a plain str/None (the historical, subprocess-safe contract). POSIX keeps _UvResult and the #39780 update-boundary fix. Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows returns-plain-str and POSIX dual-contract coverage. --- hermes_cli/managed_uv.py | 48 ++++++++++++++++----- tests/hermes_cli/test_managed_uv.py | 65 ++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 9be7f7cbb43c..78c8f469003a 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -72,6 +72,9 @@ class _UvResult(str): Missing uv is the empty string (falsy) instead of ``None`` so legacy 2-target call sites can still unpack a failure without raising, while ``if not uv_bin`` keeps working for single-value callers. + + POSIX only. This wrapper is **never** returned on Windows — see + ``ensure_uv()`` for why the ``__iter__`` override is unsafe there. """ fresh_bootstrap: bool @@ -88,17 +91,11 @@ class _UvResult(str): return iter(((str(self) or None), self.fresh_bootstrap)) -def ensure_uv() -> "_UvResult": - """Return the managed uv path, installing it first if necessary. - - Returns a :class:`_UvResult` (a ``str`` subclass) that is both usable - directly as the path and unpackable as ``(path, fresh_bootstrap)`` for - older call sites. On failure the result is falsy (``""``) — never raises — - so callers can fall back to pip gracefully. - """ +def _ensure_uv_path() -> Optional[str]: + """Resolve the managed uv path, installing it if necessary (plain ``str``/``None``).""" existing = resolve_uv() if existing: - return _UvResult(existing) + return existing target = managed_uv_path() target.parent.mkdir(parents=True, exist_ok=True) @@ -110,7 +107,7 @@ def ensure_uv() -> "_UvResult": except Exception as exc: logger.warning("Managed uv install failed: %s", exc) print(f" ✗ Failed to install managed uv: {exc}") - return _UvResult(None) + return None # Verify result = resolve_uv() @@ -124,6 +121,37 @@ def ensure_uv() -> "_UvResult": print(f" ✓ Managed uv installed ({version})") else: print(" ✗ Managed uv install appeared to succeed but binary not found") + return result + + +def ensure_uv(): + """Return the managed uv path, installing it first if necessary. + + On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is + both usable directly as the path *and* unpackable as + ``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple + release — see :class:`_UvResult` for the update-boundary rationale. + + On **Windows** we deliberately return a plain ``str``/``None`` instead. + ``subprocess`` there serializes the argv via ``subprocess.list2cmdline``, + which iterates every entry *as a string* (``for c in arg``). The dependency + installer passes uv straight into the command list (``[uv_bin, "pip", ...]``), + so a ``_UvResult`` — whose ``__iter__`` yields ``(path, fresh_bootstrap)`` + rather than characters — would inject the bool into the command line and + crash the install with ``TypeError: sequence item 1: expected str instance, + bool found``. A plain ``str`` matches the historical Windows contract and is + subprocess-safe. (A single value cannot satisfy both 2-target unpacking and + Windows char-iteration: both use the iterator protocol, with contradictory + results.) + + On failure the result is falsy — never raises — so callers can fall back to + pip gracefully. + """ + result = _ensure_uv_path() + if platform.system() == "Windows": + # See docstring: a str subclass with an overridden __iter__ is unsafe as + # a Windows subprocess argument. Hand back the plain path (or None). + return result return _UvResult(result) diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 8f55f1751df7..e0a331079176 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -105,7 +105,7 @@ class TestEnsureUv: class TestEnsureUvUpdateBoundary: """``ensure_uv()`` must answer to both the single-value and the legacy - ``(path, fresh_bootstrap)`` call conventions. + ``(path, fresh_bootstrap)`` call conventions — **on POSIX**. ``hermes update`` runs the call site from the old, already-imported ``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release @@ -113,14 +113,19 @@ class TestEnsureUvUpdateBoundary: against the single-value module; the path is an iterable ``str`` so the 2-target unpack walked its characters and raised ``ValueError: too many values to unpack (expected 2)`` (root cause behind - PR #39763), or ``TypeError`` on the ``None`` failure path. The result must - therefore be usable as a bare path *and* unpackable as a 2-tuple, in both - the success and failure cases. + PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the + result must therefore be usable as a bare path *and* unpackable as a + 2-tuple, in both the success and failure cases. + + The dual contract is intentionally **not** offered on Windows — see + ``TestEnsureUvWindowsSafe`` for why — so these tests pin ``platform.system`` + to a POSIX value. """ def test_success_usable_as_single_value(self, tmp_path): _make_executable(tmp_path / "bin" / "uv") - with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"): from hermes_cli.managed_uv import ensure_uv uv_bin = ensure_uv() assert uv_bin == str(tmp_path / "bin" / "uv") @@ -128,7 +133,8 @@ class TestEnsureUvUpdateBoundary: def test_success_unpacks_as_legacy_two_tuple(self, tmp_path): _make_executable(tmp_path / "bin" / "uv") - with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"): from hermes_cli.managed_uv import ensure_uv uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv() assert uv_bin == str(tmp_path / "bin" / "uv") @@ -136,6 +142,7 @@ class TestEnsureUvUpdateBoundary: def test_failure_unpacks_without_raising(self, tmp_path): with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): from hermes_cli.managed_uv import ensure_uv uv_bin, fresh = ensure_uv() @@ -143,6 +150,52 @@ class TestEnsureUvUpdateBoundary: assert fresh is False +class TestEnsureUvWindowsSafe: + """On Windows ``ensure_uv()`` must return a plain ``str``/``None``. + + ``subprocess`` on Windows serializes argv through + ``subprocess.list2cmdline``, which iterates every entry *as a string* + (``for c in arg``). The dependency installer feeds uv straight into the + command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass + whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters + therefore injects the bool into the command line and crashes the install + with ``TypeError: sequence item 1: expected str instance, bool found`` + (a real field report on a 10-commits-behind Windows install). A single + return value cannot serve both the legacy 2-tuple unpack and Windows + char-iteration — both use the iterator protocol — so Windows opts out of + the wrapper entirely. + """ + + def test_uvresult_would_break_windows_list2cmdline(self): + # Canary: this is *why* the wrapper is gated off Windows. If a future + # change makes _UvResult char-iterable (and thus list2cmdline-safe), + # the gate may be revisited. + import subprocess + from hermes_cli.managed_uv import _UvResult + with pytest.raises(TypeError): + subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"]) + + def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path): + import subprocess + # On (mocked) Windows the managed binary is uv.exe. + _make_executable(tmp_path / "bin" / "uv.exe") + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Windows"): + from hermes_cli.managed_uv import _UvResult, ensure_uv + uv_bin = ensure_uv() + assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult) + # The exact operation that crashed in the field must now succeed. + cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."]) + assert "pip" in cmdline and "install" in cmdline + + def test_windows_failure_returns_none(self, tmp_path): + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \ + patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")): + from hermes_cli.managed_uv import ensure_uv + assert ensure_uv() is None + + # --------------------------------------------------------------------------- # update_managed_uv # --------------------------------------------------------------------------- From 98528c78c1434a093be44760b31d4a81b433cc3e Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 08:33:53 -0500 Subject: [PATCH 051/541] fix(desktop/windows): stop racing our own backend during in-app update (#39828) * fix(desktop/windows): stop racing our own backend during in-app update The Windows in-app update (Update button -> hermes-setup.exe --update handoff) bricked because it raced a still-locked hermes.exe: the desktop quit fire-and-forget without reaping its backend child + grandchildren, so when the updater ran `hermes update`, the venv shim was still open. The quarantine rename then failed, uv's `pip install -e .` hit "Access is denied", the git path bailed to a full ZIP re-download, and the deps still couldn't write the locked shim -- leaving a half-applied install. macOS is fine because it never blocks REPLACE on a running executable. Three coordinated fixes restore Mac-style parity (click Update -> progress -> relaunch, no terminal): A. Desktop (main.cjs): before spawning the updater, releaseBackendLockForUpdate() tree-kills the primary + pool backends (taskkill /T /F on Windows, to catch REPL/pty/gateway grandchildren that SIGTERM misses) and polls the venv shim until it is actually writable (bounded 15s) -- so the lock is gone before we hand off. Also fixes resolveHermesCliBinary to use venv\Scripts\hermes.exe on Windows. B. Updater (update.rs): wait_for_venv_free no longer "proceeds anyway" on timeout -- it force-kills any lingering hermes.exe (excluding itself) and re-checks, so a straggler can't doom the install. C. Updater (update.rs): pass --force to `hermes update`. By contract the desktop has exited + waited, and the wait force-kills stragglers, so the running-exe guard would only produce a false "Hermes is still running" dead-end. Verified: node --check on main.cjs, cargo check on the updater (clean), and the Windows-gated taskkill body type-checks standalone. Field repro: ryanc's update.log (manual + handoff both hit the same lock cascade). * review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill --- .../src-tauri/src/update.rs | 68 ++++++++++- apps/desktop/electron/main.cjs | 111 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index c84f18965bbe..bd0595af2f80 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -171,6 +171,13 @@ async fn run_update(app: AppHandle) -> Result<()> { let child_env = update_child_env(&install_root); let mut update_args: Vec = vec!["update".into(), "--yes".into(), "--gateway".into()]; + // --force skips `hermes update`'s Windows running-exe guard (which would + // `sys.exit(2)` and dead-end the handoff). By contract the desktop has + // already exited and waited for the venv shim to unlock before launching + // us, and wait_for_venv_free below force-kills any straggler — so by the + // time `hermes update` runs there is no legitimate hermes.exe to protect, + // and the guard would only produce a false "Hermes is still running" stop. + update_args.push("--force".into()); update_args.push("--branch".into()); update_args.push(update_branch); @@ -366,18 +373,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) { return; } if Instant::now() >= deadline { + // Last resort: a backend hermes.exe (or a grandchild it spawned) + // is still holding the shim. The desktop should have reaped its + // tree before handing off, but SIGTERM races / detached + // grandchildren / AV handles can leave a straggler. Rather than + // "proceed anyway" straight into uv's "Access is denied", force-kill + // every hermes.exe except ourselves, then give the OS a beat to + // unload the image. emit_log( app, Some("update"), LogStream::Stdout, - "[update] timed out waiting for Hermes to exit; proceeding anyway", + "[update] Hermes still holding the venv shim; force-killing stragglers…", ); + force_kill_other_hermes(); + tokio::time::sleep(Duration::from_millis(800)).await; + if !is_locked(&shim) { + emit_log( + app, + Some("update"), + LogStream::Stdout, + "[update] venv shim freed after force-kill", + ); + } else { + emit_log( + app, + Some("update"), + LogStream::Stdout, + "[update] venv shim still locked; proceeding (--force + quarantine will handle it)", + ); + } return; } tokio::time::sleep(DESKTOP_EXIT_POLL).await; } } +/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op +/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively +/// target "the backend" by PID here — the desktop already exited and we never +/// knew its children — so we kill the whole `hermes.exe` image tree via +/// taskkill, excluding our own PID. +/// +/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`, +/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this +/// point no update-driven hermes.exe exists yet, so the only hermes.exe images +/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID +/// ne ` also spares this Tauri process, though it isn't named +/// hermes.exe.) +fn force_kill_other_hermes() { + if !cfg!(target_os = "windows") { + return; + } + #[cfg(target_os = "windows")] + { + let my_pid = std::process::id(); + // /FI excludes our own PID; /T kills the tree; /F forces. + let _ = std::process::Command::new("taskkill") + .args([ + "/F", + "/T", + "/IM", + "hermes.exe", + "/FI", + &format!("PID ne {my_pid}"), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + /// Best-effort lock probe: try to open the file for read+write. On Windows an /// exclusively-held running .exe refuses the open with a sharing violation. /// On Unix this almost always succeeds (no mandatory locking), which is fine — diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 583a316dcbe7..0234dea340ba 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1313,6 +1313,111 @@ function resolveUpdaterBinary() { return fileExists(candidate) ? candidate : null } +// Path to the venv shim whose lock decides whether `hermes update` can write +// fresh entry points. On Windows this is the file the running backend +// `hermes.exe` holds open; on POSIX it's never mandatory-locked. +function venvHermesShimPath(updateRoot) { + return IS_WINDOWS + ? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe') + : path.join(updateRoot, 'venv', 'bin', 'hermes') +} + +// Best-effort lock probe mirroring the Rust updater's is_locked(): a running +// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX +// this practically always succeeds (no mandatory locking), so it returns false +// — correct, since the shim-contention brick is Windows-only. +function isShimLocked(shimPath) { + if (!IS_WINDOWS) return false + let fd + try { + fd = fs.openSync(shimPath, 'r+') + return false + } catch (err) { + // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ + // EACCES) on Windows means a live handle holds it. + return err && err.code !== 'ENOENT' + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd) + } catch { + void 0 + } + } + } +} + +// Force-kill the entire process TREE rooted at each PID. Node's child.kill() +// only signals the direct child, so on Windows a backend `hermes.exe` that +// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the +// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps +// the whole tree synchronously. Windows-only: this is called solely from the +// Windows shim-unlock path, and the backend is NOT spawned detached (so it's +// not a process-group leader — a POSIX negative-pgid kill would be meaningless +// here anyway). POSIX teardown stays with the existing before-quit SIGTERM. +function forceKillProcessTree(pid) { + if (!IS_WINDOWS) return + if (!Number.isInteger(pid) || pid <= 0) return + try { + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } catch { + // Already gone, or no permission — best effort; the unlock wait below is + // the real gate. + } +} + +// Before handing off the update on Windows, the desktop MUST stop every backend +// it spawned and WAIT for the venv shim to actually unlock. The old code did +// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on +// Windows doesn't reap the backend's grandchildren, and quit didn't wait for +// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine +// rename failed, uv's `pip install` hit "Access is denied", and the git path +// bailed into a full ZIP re-download that ALSO couldn't write the locked shim — +// a half-applied install (ryanc's update.log). Here we tree-kill the primary + +// pool backends and poll the shim until it's writable (or a bounded timeout), +// so by the time we spawn the updater the lock is genuinely gone. +// +// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On +// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit +// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and +// aggressively SIGKILL-ing the backend here would be an untested behavior change +// for no benefit. So we no-op off Windows and leave that path exactly as it was. +async function releaseBackendLockForUpdate(updateRoot) { + if (!IS_WINDOWS) return { unlocked: true } + + // Collect every backend PID the desktop owns: primary window backend + pool. + const pids = [] + if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid) + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid) + } + + // Graceful first (lets Python flush), then tree-kill to catch grandchildren. + if (hermesProcess && !hermesProcess.killed) { + try { + hermesProcess.kill('SIGTERM') + } catch { + void 0 + } + } + stopAllPoolBackends() + for (const pid of pids) forceKillProcessTree(pid) + + const shim = venvHermesShimPath(updateRoot) + const deadlineMs = Date.now() + 15000 + while (Date.now() < deadlineMs) { + if (!isShimLocked(shim)) { + rememberLog('[updates] venv shim unlocked; safe to hand off the update') + return { unlocked: true } + } + await new Promise(r => setTimeout(r, 300)) + } + // Timed out: the updater's own wait_for_venv_free + force-kill is the second + // line of defense, and we pass --force so the guard won't dead-end. Log it. + rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)') + return { unlocked: false } +} + // applyUpdates — hand off to the installer's --update flow, then exit. // // The desktop is a pure consumer: it does NOT git pull / pip install / rebuild @@ -1379,6 +1484,12 @@ async function applyUpdates(opts = {}) { } const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') + // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we + // spawn the updater. Without this the updater races a still-locked + // hermes.exe (held by the backend child / its grandchildren) and the update + // bricks. See releaseBackendLockForUpdate for the full failure analysis. + await releaseBackendLockForUpdate(updateRoot) + // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). const child = spawn(updater, updaterArgs, { From 14fee4f11265728c427b5afb1942179ba6aba7f7 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 08:37:16 -0500 Subject: [PATCH 052/541] fix(update/windows): retry handoff `hermes update` once on first-run crash (#39831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily imports the freshly-pulled modules — but the dependency-install step runs the already-in-memory PRE-pull code for one invocation. When a release changes an updater-path contract across that boundary, the FIRST update on the parked population crashes even though the fix is already on disk. Concretely this is #39780's `_UvResult`: its `__iter__` yields (path, bool), so Windows `subprocess.list2cmdline([uv_bin, "pip", ...])` injects the bool and dies with `TypeError: sequence item 1: expected str instance, bool found` (fixed in #39820). A parked Windows user clicking Update pulls #39820 to disk, then still crashes on the in-memory pre-merge module; only the SECOND click runs clean. Field repro: ryanc's bootstrap.log (2026-06-05 12:41:41). Fix: when the first `hermes update` exits non-zero (and it isn't the concurrent-instance guard, exit 2, which a retry can't fix), retry once automatically. The retry loads the now-current module from the start and succeeds — so the parked user gets a working one-click update instead of a scary crash + manual second attempt. Verified: cargo check clean. --- .../src-tauri/src/update.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index bd0595af2f80..658bff6c5401 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -183,7 +183,7 @@ async fn run_update(app: AppHandle) -> Result<()> { emit_stage(&app, "update", StageState::Running, None, None); let started = Instant::now(); - let update = run_streamed( + let mut update = run_streamed( &app, &hermes, &update_args, @@ -192,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> { Some("update"), ) .await?; + + // Retry-once for the update-boundary crash. `hermes update` lazily imports + // the FRESHLY PULLED modules, but the dependency-install step still runs the + // already-in-memory pre-pull code for one invocation. A release that changed + // an updater-path contract across that boundary (e.g. #39780's `_UvResult`, + // whose `__iter__` injected a bool into the argv and crashed Windows + // `list2cmdline` with `TypeError: sequence item 1: expected str instance, + // bool found`, fixed in #39820) therefore kills the FIRST update on the + // parked population — even though the fix is already on disk by then. A + // second `hermes update` runs clean because the now-current module is loaded + // from the start. Rather than make the parked user click Update twice (and + // stare at a scary crash first), retry once automatically. Skip the retry + // for the concurrent-instance guard (exit 2) — that's a "close Hermes" state + // a retry can't fix. + if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) { + emit_log( + &app, + Some("update"), + LogStream::Stdout, + "[update] first update attempt failed; retrying once (the fix it just \ + pulled loads on the second run)…", + ); + update = run_streamed( + &app, + &hermes, + &update_args, + &install_root, + &child_env, + Some("update"), + ) + .await?; + } let update_ms = started.elapsed().as_millis() as u64; match update.exit_code { From 7583aedacd53442d33d95acbed40c884ff90523d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:43:51 -0700 Subject: [PATCH 053/541] fix(completion): remove /model autocomplete from CLI/TUI (#39727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(completion): remove /model autocomplete from CLI/TUI The TUI frontend already suppressed /model argument completion in favor of the two-step ModelPicker (useCompletion.ts), but the CLI prompt_toolkit completer and the gateway-backed complete.slash RPC (TUI + desktop) still emitted model aliases and probed LM Studio on every keystroke. Drops the /model branch in SlashCommandCompleter.get_completions, the _model_completions method, and the LM Studio probe/cache helper that only fed it. Command-name completion (/mod -> model) and sibling arg completers (/skin, /personality) are untouched. Removes the now-dead TestModelTabCompletion tests. --- hermes_cli/commands.py | 86 +--------------------- tests/hermes_cli/test_ollama_cloud_auth.py | 66 ----------------- 2 files changed, 1 insertion(+), 151 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 34e3af4014b6..f022ef32b07e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1148,41 +1148,6 @@ def slack_subcommand_map() -> dict[str, str]: # --------------------------------------------------------------------------- -# Per-process cache for /model LM Studio autocomplete. Probing on -# every keystroke would block the UI; a short TTL keeps it live without -# hammering the server. -_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None - - -def _lmstudio_completion_models() -> list[str]: - """Locally-loaded LM Studio models for /model autocomplete (cached, gated).""" - global _LMSTUDIO_COMPLETION_CACHE - # Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio. - if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")): - try: - from hermes_cli.auth import _load_auth_store - store = _load_auth_store() or {} - if "lmstudio" not in (store.get("providers") or {}) \ - and "lmstudio" not in (store.get("credential_pool") or {}): - return [] - except Exception: - return [] - now = time.time() - if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0: - return _LMSTUDIO_COMPLETION_CACHE[1] - try: - from hermes_cli.models import fetch_lmstudio_models - models = fetch_lmstudio_models( - api_key=os.environ.get("LM_API_KEY", ""), - base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1", - timeout=0.8, - ) - except Exception: - models = [] - _LMSTUDIO_COMPLETION_CACHE = (now, models) - return models - - class SlashCommandCompleter(Completer): """Autocomplete for built-in slash commands, subcommands, and skill commands.""" @@ -1599,52 +1564,6 @@ class SlashCommandCompleter(Completer): except Exception: pass - def _model_completions(self, sub_text: str, sub_lower: str): - """Yield completions for /model from config aliases + built-in aliases.""" - seen = set() - # Config-based direct aliases (preferred — include provider info) - try: - from hermes_cli.model_switch import ( - _ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES, - ) - _ensure_direct_aliases() - for name, da in DIRECT_ALIASES.items(): - if name.startswith(sub_lower) and name != sub_lower: - seen.add(name) - yield Completion( - name, - start_position=-len(sub_text), - display=name, - display_meta=f"{da.model} ({da.provider})", - ) - # Built-in catalog aliases not already covered - for name in sorted(MODEL_ALIASES.keys()): - if name in seen: - continue - if name.startswith(sub_lower) and name != sub_lower: - identity = MODEL_ALIASES[name] - yield Completion( - name, - start_position=-len(sub_text), - display=name, - display_meta=f"{identity.vendor}/{identity.family}", - ) - except Exception: - pass - # LM Studio: surface locally-loaded models. Gated on the user actually - # having LM Studio configured (env var or auth-store entry) so we - # don't probe 127.0.0.1 on every keystroke for users who don't use it. - for name in _lmstudio_completion_models(): - if name in seen: - continue - if name.startswith(sub_lower) and name != sub_lower: - yield Completion( - name, - start_position=-len(sub_text), - display=name, - display_meta="LM Studio", - ) - def get_completions(self, document, complete_event): text = document.text_before_cursor if not text.startswith("/"): @@ -1668,9 +1587,6 @@ class SlashCommandCompleter(Completer): # Dynamic completions for commands with runtime lists if " " not in sub_text: - if base_cmd == "/model": - yield from self._model_completions(sub_text, sub_lower) - return if base_cmd == "/skin": yield from self._skin_completions(sub_text, sub_lower) return @@ -1788,7 +1704,7 @@ class SlashCommandAutoSuggest(AutoSuggest): return Suggestion(cmd_name[len(word):]) return None - # Command is complete — suggest subcommands or model names + # Command is complete — suggest subcommands sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() diff --git a/tests/hermes_cli/test_ollama_cloud_auth.py b/tests/hermes_cli/test_ollama_cloud_auth.py index 7e2dc5ff0786..77b19c9bb1ae 100644 --- a/tests/hermes_cli/test_ollama_cloud_auth.py +++ b/tests/hermes_cli/test_ollama_cloud_auth.py @@ -178,72 +178,6 @@ class TestModelSwitchPersistence: assert result.base_url == "https://api.anthropic.com" -# --------------------------------------------------------------------------- -# /model tab completion -# --------------------------------------------------------------------------- - -class TestModelTabCompletion: - """SlashCommandCompleter provides model alias completions for /model.""" - - def test_model_completions_yields_direct_aliases(self, monkeypatch): - """_model_completions yields direct aliases with model and provider info.""" - from hermes_cli.commands import SlashCommandCompleter - from hermes_cli.model_switch import DirectAlias - import hermes_cli.model_switch as ms - - test_aliases = { - "opus": DirectAlias("claude-opus-4-6", "anthropic", ""), - "qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"), - } - monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) - - completer = SlashCommandCompleter() - completions = list(completer._model_completions("", "")) - - names = [c.text for c in completions] - assert "opus" in names - assert "qwen" in names - - def test_model_completions_filters_by_prefix(self, monkeypatch): - """Completions filter by typed prefix.""" - from hermes_cli.commands import SlashCommandCompleter - from hermes_cli.model_switch import DirectAlias - import hermes_cli.model_switch as ms - - test_aliases = { - "opus": DirectAlias("claude-opus-4-6", "anthropic", ""), - "qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"), - } - monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) - - completer = SlashCommandCompleter() - completions = list(completer._model_completions("o", "o")) - - names = [c.text for c in completions] - assert "opus" in names - assert "qwen" not in names - - def test_model_completions_shows_metadata(self, monkeypatch): - """Completions include model name and provider in display_meta.""" - from hermes_cli.commands import SlashCommandCompleter - from hermes_cli.model_switch import DirectAlias - import hermes_cli.model_switch as ms - - test_aliases = { - "glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"), - } - monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) - - completer = SlashCommandCompleter() - completions = list(completer._model_completions("g", "g")) - - assert len(completions) >= 1 - glm_comp = [c for c in completions if c.text == "glm"][0] - meta_str = str(glm_comp.display_meta) - assert "glm-4.7" in meta_str - assert "custom" in meta_str - - # --------------------------------------------------------------------------- # Fallback base_url passthrough # --------------------------------------------------------------------------- From ca1fb32c26190626e6f163ad03ac9d5f9fd69746 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:53:58 -0700 Subject: [PATCH 054/541] docs: remove --include-desktop install instructions (#39762) * docs: remove --include-desktop install instructions Drop the --include-desktop curl one-liner from the desktop app docs. The flag remains in scripts/install.sh; these docs now point to the desktop installer / website and the 'hermes desktop' path instead. * docs: remove --include-desktop from install docs Drop the redundant 'Hermes Desktop installer on Linux' block (which used --include-desktop) from quickstart, installation, and index docs. The website installer covers macOS/Windows desktop; the CLI-only path covers Linux. Removes the flag from all user-facing docs. --- apps/desktop/README.md | 6 ------ website/docs/getting-started/installation.md | 5 ----- website/docs/getting-started/quickstart.md | 4 ---- website/docs/index.mdx | 6 ------ 4 files changed, 21 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 525e9ab77a5c..f3084a9b6478 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -24,12 +24,6 @@ ### Install with Hermes (recommended) -Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go: - -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop -``` - Already have the Hermes CLI? Just run: ```bash diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index c47f70d9ea61..09884fa831e3 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -12,11 +12,6 @@ Get Hermes Agent up and running in under two minutes! ### With the Hermes Desktop installer on macOS or Windows (recommended) To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -### With the Hermes Desktop installer on Linux: -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash -``` - ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 6a40593cae2f..04a63226648e 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -50,10 +50,6 @@ Pick the row that matches your goal: ### With the Hermes Desktop installer on macOS or Windows (recommended) To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -### With the Hermes Desktop installer on Linux: -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash -``` ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/index.mdx b/website/docs/index.mdx index 2d5bc4443179..ce7effcbf757 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -67,12 +67,6 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com). To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -### Linux - -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash -``` - ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: From 9ca11b35d5d09561366bb039d1d89b647cd3a682 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:55:09 -0700 Subject: [PATCH 055/541] perf(/model): prewarm picker provider-models cache in background (#39847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * perf(/model): prewarm picker provider-models cache in background The no-args /model picker calls list_authenticated_providers(), which fetches each authenticated provider's live /v1/models list serially. On a cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path the first time /model is opened in a session. Warm that exact path off-thread during the idle window right after the CLI banner is shown: a once-per-process daemon thread runs list_authenticated_providers() to populate provider_models_cache.json for every authed provider. By the time the user types /model, the picker hits the warm disk cache (~136ms vs ~1500ms). Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done) ensures at most one thread per process; fully exception-isolated so an offline/no-creds provider can never affect the session. --- cli.py | 10 +++++ hermes_cli/model_switch.py | 56 +++++++++++++++++++++++ tests/hermes_cli/test_picker_prewarm.py | 60 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 tests/hermes_cli/test_picker_prewarm.py diff --git a/cli.py b/cli.py index 0e97c480d577..8910e2d8c5de 100644 --- a/cli.py +++ b/cli.py @@ -13094,6 +13094,16 @@ class HermesCLI: _welcome_color = "#FFF8DC" self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") + # Warm the /model picker's provider-models cache off-thread during this + # idle window (banner shown, user about to type). The no-args picker + # otherwise blocks ~1-2s on serial /v1/models fetches the first time + # it's opened in a session. Fire-and-forget, guarded once-per-process. + try: + from hermes_cli.model_switch import prewarm_picker_cache_async + prewarm_picker_cache_async() + except Exception: + pass + # Redaction opt-out warning (#17691): ON by default, loud when off. # The redactor snapshots its state at import time so any toggle now # won't affect the running process — we just want the operator to diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 0d1f6fa44d61..c4e76b0d5bc9 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1117,6 +1117,62 @@ def switch_model( # Authenticated providers listing (for /model no-args display) # --------------------------------------------------------------------------- +# Process-level guard so the picker prewarm thread is spawned at most once per +# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a +# long-lived process (or repeated triggers) would leak one OS thread per call. +import threading as _threading # noqa: E402 + +_picker_prewarm_done = _threading.Event() + + +def prewarm_picker_cache_async() -> Optional["_threading.Thread"]: + """Warm the provider-models disk cache in a background daemon thread. + + The no-args ``/model`` picker calls ``list_authenticated_providers()``, + which fetches each authenticated provider's live ``/v1/models`` list on a + cold/stale cache. Those fetches are independent HTTP round-trips but run + serially, so the first ``/model`` open in a session (or any open after the + 1h cache TTL expires) blocks ~1-2s on the user's critical path. + + This pre-warms that exact path off-thread during idle session time: it + runs ``list_authenticated_providers()`` once, which populates + ``provider_models_cache.json`` for every authed provider. By the time the + user types ``/model``, the picker hits the warm disk cache and renders in + ~100ms. + + Fire-and-forget. Process-level Event guard ensures it runs at most once. + Fully exception-isolated — a slow or offline provider can never affect the + session. Returns the spawned thread (for tests) or None if already warmed. + """ + if _picker_prewarm_done.is_set(): + return None + _picker_prewarm_done.set() + + def _warm() -> None: + try: + from hermes_cli.inventory import load_picker_context + + ctx = load_picker_context() + # Calling this is what populates cached_provider_model_ids() -> + # provider_models_cache.json for each authed provider. We discard + # the result; the side effect (warm disk cache) is the point. + list_authenticated_providers( + current_provider=ctx.current_provider, + current_base_url=ctx.current_base_url, + current_model=ctx.current_model, + user_providers=ctx.user_providers, + custom_providers=ctx.custom_providers, + max_models=50, + ) + except Exception: + # Best-effort warmup — never surface errors into the session. + logger.debug("picker cache prewarm failed", exc_info=True) + + t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm") + t.start() + return t + + def list_authenticated_providers( current_provider: str = "", current_base_url: str = "", diff --git a/tests/hermes_cli/test_picker_prewarm.py b/tests/hermes_cli/test_picker_prewarm.py new file mode 100644 index 000000000000..3ddc873f70e7 --- /dev/null +++ b/tests/hermes_cli/test_picker_prewarm.py @@ -0,0 +1,60 @@ +"""Tests for the /model picker background cache prewarm. + +``prewarm_picker_cache_async()`` warms the provider-models disk cache off the +user's critical path so the first ``/model`` open in a session is fast instead +of blocking ~1-2s on serial /v1/models fetches. These pin the two contracts +that matter: it runs the warm path exactly once per process (no thread leak), +and it delegates to ``list_authenticated_providers`` to do the warming. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import hermes_cli.model_switch as ms + + +def _reset_guard(): + ms._picker_prewarm_done.clear() + + +def test_prewarm_runs_list_authenticated_providers_once(): + """First call spawns a thread that calls list_authenticated_providers; + the warm side effect is delegated there (which disk-caches per provider).""" + _reset_guard() + with patch.object(ms, "list_authenticated_providers", return_value=[]) as mock_list: + t = ms.prewarm_picker_cache_async() + assert t is not None, "first call must spawn a prewarm thread" + t.join(timeout=10) + assert not t.is_alive(), "prewarm thread should finish promptly" + mock_list.assert_called_once() + _reset_guard() + + +def test_prewarm_guard_is_once_per_process(): + """The process-level Event guard must make repeat calls no-ops so a + long-lived process never leaks one OS thread per call.""" + _reset_guard() + with patch.object(ms, "list_authenticated_providers", return_value=[]): + t1 = ms.prewarm_picker_cache_async() + assert t1 is not None + t1.join(timeout=10) + # Subsequent calls return None (guard set) — no new thread. + assert ms.prewarm_picker_cache_async() is None + assert ms.prewarm_picker_cache_async() is None + _reset_guard() + + +def test_prewarm_never_raises_on_failure(): + """A failing/offline provider path must be fully swallowed — the prewarm + is best-effort and must never surface errors into the session.""" + _reset_guard() + with patch.object( + ms, "list_authenticated_providers", side_effect=RuntimeError("boom") + ): + t = ms.prewarm_picker_cache_async() + assert t is not None + # join must not raise; the worker swallows the exception internally. + t.join(timeout=10) + assert not t.is_alive() + _reset_guard() From af8b917dabc07c34c3edaf32ade678bbb7843b4d Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:24:58 +0100 Subject: [PATCH 056/541] fix(termux): scope frontend npm installs --- hermes_cli/main.py | 75 ++++++++++++++++--- tests/hermes_cli/test_tui_npm_install.py | 91 ++++++++++++++++++++++++ tests/hermes_cli/test_web_ui_build.py | 44 ++++++++++++ 3 files changed, 201 insertions(+), 9 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7527b4d48af1..391d85f1b52f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1264,6 +1264,32 @@ def _workspace_root(dir: Path) -> Path: return dir +def _termux_workspace_install_context( + dir: Path, *, include_child_workspaces: bool = False +) -> tuple[Path, tuple[str, ...]]: + """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" + ws_root = _workspace_root(dir) + if ws_root == dir: + return dir, () + + try: + workspace = dir.relative_to(ws_root).as_posix() + except ValueError: + return ws_root, () + + workspace_args: list[str] = ["--workspace", workspace] + if include_child_workspaces: + packages_dir = dir / "packages" + if packages_dir.is_dir(): + for child in sorted(packages_dir.iterdir()): + if child.is_dir() and (child / "package.json").is_file(): + workspace_args.extend( + ["--workspace", child.relative_to(ws_root).as_posix()] + ) + workspace_args.append("--include-workspace-root=false") + return ws_root, tuple(workspace_args) + + def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. @@ -1524,16 +1550,43 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # 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. - # npm install runs from the workspace root (where package-lock.json lives); - # npm workspaces resolves ui-tui deps automatically. + # Existing desktop behaviour runs npm from the workspace root. Termux + # scopes the install to ui-tui so launch does not pull desktop/web + # dependencies into the hot path. did_install = False - if _tui_need_npm_install(tui_dir): + termux_startup = _is_termux_startup_environment() + termux_need_rebuild = False + if termux_startup and not tui_dev: + termux_need_rebuild = _tui_need_rebuild(tui_dir) + + skip_install_for_fresh_termux_bundle = ( + termux_startup and not tui_dev and not termux_need_rebuild + ) + if ( + not skip_install_for_fresh_termux_bundle + and _tui_need_npm_install(tui_dir) + ): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") + npm_cwd = _workspace_root(tui_dir) + npm_workspace_args: tuple[str, ...] = () + if termux_startup: + npm_cwd, npm_workspace_args = _termux_workspace_install_context( + tui_dir, + include_child_workspaces=True, + ) result = subprocess.run( - [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], - cwd=str(_workspace_root(tui_dir)), + [ + npm, + "install", + *npm_workspace_args, + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ], + cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -1579,8 +1632,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # Termux cold starts use the freshness check because esbuild startup is # expensive on old mobile CPUs. should_build = True - if _is_termux_startup_environment(): - should_build = did_install or _tui_need_rebuild(tui_dir) + if termux_startup: + should_build = did_install or termux_need_rebuild if should_build: npm = _node_bin("npm") @@ -7004,10 +7057,14 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if text: _say(text) + npm_cwd = _workspace_root(web_dir) + npm_workspace_args: tuple[str, ...] = () + if _is_termux_startup_environment(): + npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir) r1 = _run_npm_install_deterministic( npm, - _workspace_root(web_dir), - extra_args=("--silent",), + npm_cwd, + extra_args=(*npm_workspace_args, "--silent"), ) if r1.returncode != 0: _say( diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 1ab5edcb06ca..790ace09d2de 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -172,6 +172,97 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh( assert cwd == tmp_path +def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh( + tmp_path: Path, main_mod, monkeypatch +) -> None: + _touch_tui_entry(tmp_path) + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + + def fail_run(*_args, **_kwargs): + raise AssertionError("fresh Termux TUI launch must not run npm") + + monkeypatch.setattr(main_mod.subprocess, "run", fail_run) + + argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False) + + assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")] + assert cwd == tmp_path + + +def test_make_tui_argv_scopes_npm_install_on_termux_workspace( + tmp_path: Path, main_mod, monkeypatch +) -> None: + tui_dir = tmp_path / "ui-tui" + tui_dir.mkdir() + (tui_dir / "package.json").write_text("{}") + ink_dir = tui_dir / "packages" / "hermes-ink" + ink_dir.mkdir(parents=True) + (ink_dir / "package.json").write_text("{}") + (tmp_path / "package-lock.json").write_text("{}") + + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + main_mod._make_tui_argv(tui_dir, tui_dev=False) + + install_cmd = calls[0][0][0] + assert install_cmd[:7] == [ + "/bin/npm", + "install", + "--workspace", + "ui-tui", + "--workspace", + "ui-tui/packages/hermes-ink", + "--include-workspace-root=false", + ] + assert calls[0][1]["cwd"] == str(tmp_path) + + +def test_make_tui_argv_keeps_desktop_workspace_install_behaviour( + tmp_path: Path, main_mod, monkeypatch +) -> None: + tui_dir = tmp_path / "ui-tui" + tui_dir.mkdir() + (tui_dir / "package.json").write_text("{}") + (tmp_path / "package-lock.json").write_text("{}") + + monkeypatch.delenv("TERMUX_VERSION", raising=False) + monkeypatch.setenv("PREFIX", "/usr") + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + main_mod._make_tui_argv(tui_dir, tui_dev=False) + + assert calls[0][0][0] == [ + "/bin/npm", + "install", + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ] + assert calls[0][1]["cwd"] == str(tmp_path) + + def test_make_tui_argv_keeps_desktop_always_build_behaviour( tmp_path: Path, main_mod, monkeypatch ) -> None: diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index 666420a37ab2..1f76e2a7cb5e 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -164,6 +164,50 @@ class TestBuildWebUISkipsWhenFresh: assert args[0] == ["/usr/bin/npm", "run", "build"] assert kwargs["cwd"] == web_dir + def test_termux_web_install_is_workspace_scoped(self, tmp_path, monkeypatch): + web_dir, _ = _make_web_dir(tmp_path) + (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setenv("TERMUX_VERSION", "1") + + install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ + patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + result = _build_web_ui(web_dir) + + assert result is True + args, kwargs = mock_run.call_args + assert args[0] == [ + "/usr/bin/npm", + "ci", + "--workspace", + "web", + "--include-workspace-root=false", + "--silent", + ] + assert kwargs["cwd"] == tmp_path + + def test_desktop_web_install_uses_existing_workspace_root( + self, tmp_path, monkeypatch + ): + web_dir, _ = _make_web_dir(tmp_path) + (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.delenv("TERMUX_VERSION", raising=False) + monkeypatch.setenv("PREFIX", "/usr") + + install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ + patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + result = _build_web_ui(web_dir) + + assert result is True + args, kwargs = mock_run.call_args + assert args[0] == ["/usr/bin/npm", "ci", "--silent"] + assert kwargs["cwd"] == tmp_path + class TestBuildWebUIRetryAndStaleFallback: """Coverage for the retry + stale-dist fallback added in #23824 / issue #23817.""" From 83c13862f107577f70e547a46dca99cc68dcd500 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 5 Jun 2026 09:52:52 -0500 Subject: [PATCH 057/541] fix(desktop): route remote-profile session reads to the owning remote backend Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's remote backend, but session list + transcript reads still assumed every profile's state.db is a local file the primary can open. For a remote profile the local file is absent or stale, so the IDs the sidebar shows 404 the moment resume runs against the remote -- the "session not found -> new session" bug. Intercept the three session-read GETs in the hermes:api handler and route them to the owning remote backend (which serves its own state.db natively): GET /api/profiles/sessions -> splice each remote profile's real rows in GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles No remote profiles configured -> untouched local fast path. A dead remote contributes nothing rather than breaking the sidebar. Verified end-to-end against a live remote backend: a remote-profile session resumes from remote history and continues on the remote across turns (history grows in place, no new session spawned). --- apps/desktop/electron/main.cjs | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 0234dea340ba..fcd6c3759f58 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3909,6 +3909,28 @@ async function resolveRemoteBackend(profile) { return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') } +// A remote profile's sessions live on its remote host's state.db, not on a local +// file the primary can open — so reads for it must route to the remote backend, +// not the local-disk fast path. These three helpers drive that (see +// interceptSessionReadForRemote). +function profileHasRemoteOverride(profile) { + return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile)) +} + +function configuredRemoteProfileNames() { + const config = readDesktopConnectionConfig() + return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) +} + +// GET a profile's resolved backend (remote pool or local primary), parsed JSON. +async function fetchJsonForProfile(profile, path) { + const conn = await ensureBackend(profile) + const url = `${conn.baseUrl}${path}` + return conn.authMode === 'oauth' + ? fetchJsonViaOauthSession(url, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) + : fetchJson(url, conn.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) +} + async function probeRemoteAuthMode(rawUrl) { // Determine how a remote gateway expects callers to authenticate, WITHOUT // sending any credentials. ``/api/status`` is public on every Hermes @@ -4698,7 +4720,92 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { return systemPreferences.askForMediaAccess('microphone') }) +// Re-route remote-profile session reads to the owning remote backend. Returns +// `undefined` when the request isn't an interceptable session GET (caller takes +// the normal local path), else the response. Mutations (DELETE/PATCH) carry +// their own profile semantics and are out of scope. +// GET /api/profiles/sessions → splice each remote profile's real rows in +// GET /api/sessions/{id}[/messages] → for a remote profile, read from remote +async function interceptSessionReadForRemote(request) { + if ((request?.method || 'GET').toUpperCase() !== 'GET' || typeof request?.path !== 'string') { + return undefined + } + + let parsed + try { + parsed = new URL(request.path, 'http://x') + } catch { + return undefined + } + const { pathname, searchParams } = parsed + + if (pathname === '/api/profiles/sessions') { + const remoteProfiles = configuredRemoteProfileNames() + if (remoteProfiles.length === 0) { + return undefined // no remote profiles → local fast path + } + const requested = (searchParams.get('profile') || 'all').trim() || 'all' + if (requested !== 'all') { + return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined + } + return mergeRemoteProfileSessions(searchParams, remoteProfiles) + } + + // Per-session detail/messages. The renderer tags the owner as ?profile=; + // for a remote profile, drop it and let the remote serve its own state.db. + if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { + const profile = (searchParams.get('profile') || '').trim() + return profile && profileHasRemoteOverride(profile) ? fetchJsonForProfile(profile, pathname) : undefined + } + + return undefined +} + +const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : []) + +// A remote profile's session list, read from its remote host and tagged with the +// desktop-facing profile name (the remote's /api/sessions doesn't know it). +async function remoteSessionList(profile, searchParams) { + const qs = new URLSearchParams(searchParams) + qs.delete('profile') // remote serves its own db; no cross-profile read there + const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`) + for (const s of rowsOf(data)) { + s.profile = profile + s.is_default_profile = false + } + return { ...data, sessions: rowsOf(data) } +} + +// Unified list: primary's local aggregate, with each remote profile's stale local +// rows swapped for the remote's real ones, re-sorted by recency. A dead remote +// contributes nothing rather than breaking the sidebar. +async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { + const primary = await ensureBackend(null) + const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { + method: 'GET', + timeoutMs: DEFAULT_FETCH_TIMEOUT_MS + }).catch(() => ({ sessions: [], total: 0 })) + + const remoteSet = new Set(remoteProfiles) + const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile)) + const remoteRows = await Promise.all(remoteProfiles.map(name => remoteSessionList(name, searchParams).then(rowsOf, () => []))) + for (const rows of remoteRows) merged.push(...rows) + + const recency = s => s?.last_active ?? s?.started_at ?? 0 + merged.sort((a, b) => recency(b) - recency(a)) + return { ...base, sessions: merged } +} + ipcMain.handle('hermes:api', async (_event, request) => { + // Remote-profile session reads would otherwise hit the local primary off each + // profile's on-disk state.db — fine for local profiles, but a remote profile's + // sessions live on its remote host, so the UI's IDs 404 the moment resume runs + // there (the "session not found → new session" bug). Route them to the remote. + const rerouted = await interceptSessionReadForRemote(request) + if (rerouted !== undefined) { + return rerouted + } + const connection = await ensureBackend(request?.profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const url = `${connection.baseUrl}${request.path}` From 3045d54547f7151d5297fe2f2e0378713c7c27c2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 5 Jun 2026 10:01:32 -0500 Subject: [PATCH 058/541] fix(desktop): route remote-profile session mutations + fix unified-list pagination Follow-up to the read-routing fix: make remote-profile sessions fully first-class, not just resumable. Mutations (rename/archive/delete) went through the same hermes:api handler but never carried the owning profile, so they hit the local primary's state.db -- which has no row for a remote session. Deleting/archiving/renaming a remote session silently no-op'd or 404'd, and the row reappeared on next refresh. - hermes.ts: setSessionArchived/deleteSession/renameSession take the owning profile and pass it as request.profile so Electron routes to that profile's backend (matching the read path). Callers now forward session.profile. - main.cjs: generalize the intercept (read -> request) to also reroute DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile param (the remote serves its own state.db; no cross-profile semantics there). - web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with GET/PATCH (local cross-profile delete). Also fix the unified-list merge: it concatenated each remote's page onto the primary's without re-windowing, so a limit=N request could return up to N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches limit+offset from each remote (from offset 0), re-sorts by recency, re-windows to the page, and recomputes total/profile_totals from the remote counts. Verified live against a remote backend: rename/archive/delete mutate the remote db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no overlap with page 1. tsc -b clean; connection-config tests pass. --- apps/desktop/electron/main.cjs | 97 +++++++++++++------ .../app/session/hooks/use-session-actions.ts | 4 +- .../src/app/settings/sessions-settings.tsx | 4 +- apps/desktop/src/hermes.ts | 17 +++- hermes_cli/web_server.py | 8 +- 5 files changed, 92 insertions(+), 38 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index fcd6c3759f58..3273b2551419 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3924,11 +3924,17 @@ function configuredRemoteProfileNames() { // GET a profile's resolved backend (remote pool or local primary), parsed JSON. async function fetchJsonForProfile(profile, path) { + return requestJsonForProfile(profile, path, 'GET') +} + +// Issue an arbitrary method against a profile's resolved backend, parsed JSON. +async function requestJsonForProfile(profile, path, method, body) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` + const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } return conn.authMode === 'oauth' - ? fetchJsonViaOauthSession(url, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) - : fetchJson(url, conn.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) + ? fetchJsonViaOauthSession(url, opts) + : fetchJson(url, conn.token, opts) } async function probeRemoteAuthMode(rawUrl) { @@ -4720,16 +4726,20 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { return systemPreferences.askForMediaAccess('microphone') }) -// Re-route remote-profile session reads to the owning remote backend. Returns -// `undefined` when the request isn't an interceptable session GET (caller takes -// the normal local path), else the response. Mutations (DELETE/PATCH) carry -// their own profile semantics and are out of scope. -// GET /api/profiles/sessions → splice each remote profile's real rows in -// GET /api/sessions/{id}[/messages] → for a remote profile, read from remote -async function interceptSessionReadForRemote(request) { - if ((request?.method || 'GET').toUpperCase() !== 'GET' || typeof request?.path !== 'string') { +// Re-route remote-profile session requests to the owning remote backend. Returns +// `undefined` when not interceptable (caller takes the normal local path), else +// the response. Reads tag the profile as ?profile=; mutations carry it in +// request.profile. Either way, a remote profile's session lives only on its +// remote host, so the request must go there (where it serves its own state.db). +// GET /api/profiles/sessions → splice each remote profile's rows in +// GET /api/sessions/{id}[/messages] → read from remote +// DELETE /api/sessions/{id} → delete on remote +// PATCH /api/sessions/{id} → rename/archive on remote +async function interceptSessionRequestForRemote(request) { + if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed try { @@ -4739,7 +4749,7 @@ async function interceptSessionReadForRemote(request) { } const { pathname, searchParams } = parsed - if (pathname === '/api/profiles/sessions') { + if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path @@ -4751,11 +4761,20 @@ async function interceptSessionReadForRemote(request) { return mergeRemoteProfileSessions(searchParams, remoteProfiles) } - // Per-session detail/messages. The renderer tags the owner as ?profile=; - // for a remote profile, drop it and let the remote serve its own state.db. + // Per-session read/mutation. Owner is in ?profile= (reads) or request.profile + // (mutations); route to the remote sans profile param — it serves its own + // state.db, with no cross-profile semantics. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { - const profile = (searchParams.get('profile') || '').trim() - return profile && profileHasRemoteOverride(profile) ? fetchJsonForProfile(profile, pathname) : undefined + const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile || !profileHasRemoteOverride(profile)) { + return undefined + } + if (method === 'GET') { + return fetchJsonForProfile(profile, pathname) + } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body + if (body) delete body.profile + return requestJsonForProfile(profile, pathname, method, body) } return undefined @@ -4777,31 +4796,55 @@ async function remoteSessionList(profile, searchParams) { } // Unified list: primary's local aggregate, with each remote profile's stale local -// rows swapped for the remote's real ones, re-sorted by recency. A dead remote -// contributes nothing rather than breaking the sidebar. +// rows/totals swapped for the remote's real ones, re-sorted by recency and +// re-windowed to the requested page. A dead remote contributes nothing rather +// than breaking the sidebar. async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { + const limit = Math.max(1, Number(searchParams.get('limit')) || 20) + const offset = Math.max(0, Number(searchParams.get('offset')) || 0) + const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' + const primary = await ensureBackend(null) const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0 })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + + // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window + // is correct for this page — mirrors the primary's per-profile over-fetch. + const remoteParams = new URLSearchParams(searchParams) + remoteParams.set('limit', String(limit + offset)) + remoteParams.set('offset', '0') const remoteSet = new Set(remoteProfiles) const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile)) - const remoteRows = await Promise.all(remoteProfiles.map(name => remoteSessionList(name, searchParams).then(rowsOf, () => []))) - for (const rows of remoteRows) merged.push(...rows) + const profileTotals = { ...(base.profile_totals || {}) } + let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0) - const recency = s => s?.last_active ?? s?.started_at ?? 0 + // Swap each remote profile's stale local rows/total for the remote's real ones. + await Promise.all(remoteProfiles.map(async name => { + const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { + delete profileTotals[name] // dead remote → drop its stale local total too + return + } + const rows = rowsOf(list) + merged.push(...rows) + profileTotals[name] = Number(list.total) || rows.length + total += profileTotals[name] + })) + + const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged } + return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { - // Remote-profile session reads would otherwise hit the local primary off each - // profile's on-disk state.db — fine for local profiles, but a remote profile's - // sessions live on its remote host, so the UI's IDs 404 the moment resume runs - // there (the "session not found → new session" bug). Route them to the remote. - const rerouted = await interceptSessionReadForRemote(request) + // Remote-profile session requests would otherwise hit the local primary off + // each profile's on-disk state.db — fine for local profiles, but a remote + // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations + // no-op) the moment they run there. Route reads + mutations to the remote. + const rerouted = await interceptSessionRequestForRemote(request) if (rerouted !== undefined) { return rerouted } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index bdd9dd15b874..4d63dda27901 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -763,7 +763,7 @@ export function useSessionActions({ await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined) } - await deleteSession(storedSessionId) + await deleteSession(storedSessionId, removed?.profile) clearQueuedPrompts(storedSessionId) if (closingRuntimeId) { @@ -839,7 +839,7 @@ export function useSessionActions({ } try { - await setSessionArchived(storedSessionId, true) + await setSessionArchived(storedSessionId, true, archived?.profile) notify({ durationMs: 2_000, kind: 'success', message: 'Archived' }) } catch (err) { if (archived) { diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index af3861d098d1..060088ba1602 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -57,7 +57,7 @@ export function SessionsSettings() { setBusyId(session.id) try { - await setSessionArchived(session.id, false) + await setSessionArchived(session.id, false, session.profile) setLocalSessions(prev => prev.filter(s => s.id !== session.id)) // Surface it again in the sidebar without waiting for a full refresh. setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)]) @@ -78,7 +78,7 @@ export function SessionsSettings() { setBusyId(session.id) try { - await deleteSession(session.id) + await deleteSession(session.id, session.profile) setLocalSessions(prev => prev.filter(s => s.id !== session.id)) triggerHaptic('warning') } catch (err) { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index d1f78efaf282..216d96a87385 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -166,8 +166,13 @@ export async function listAllProfileSessions( } } -export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> { +// Mutations take the owning `profile` so Electron routes them to that profile's +// backend (remote pool or local primary) via request.profile — matching the +// read path. A remote session's row lives only on its remote host, so a mutation +// that hit the local primary would no-op or 404. Omit for the current/default. +export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { archived } @@ -180,8 +185,10 @@ export function searchSessions(query: string): Promise { }) } -// `profile` reads another profile's transcript straight off its state.db via the -// primary backend (no spawn). Omit for the current/default profile. +// Reads another profile's transcript. For a remote profile Electron reroutes +// this GET to the remote backend (which serves its own state.db); for a local +// profile the primary opens that profile's state.db via ?profile=. Omit for +// the current/default profile. export function getSessionMessages(id: string, profile?: string | null): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' @@ -190,8 +197,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise }) } -export function deleteSession(id: string): Promise<{ ok: boolean }> { +export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' }) @@ -203,6 +211,7 @@ export function renameSession( profile?: string | null ): Promise<{ ok: boolean; title: string }> { return window.hermesDesktop.api<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { title, ...(profile ? { profile } : {}) } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 98107dcbe959..c45a21961bf9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5257,9 +5257,11 @@ async def get_session_messages(session_id: str, profile: Optional[str] = None): @app.delete("/api/sessions/{session_id}") -async def delete_session_endpoint(session_id: str): - from hermes_state import SessionDB - db = SessionDB() +async def delete_session_endpoint(session_id: str, profile: Optional[str] = None): + # ``profile`` deletes a session belonging to another (local) profile by + # opening its state.db directly. Remote profiles never reach here — the + # desktop routes their DELETE to the remote backend. Omit for current/default. + db = _open_session_db_for_profile(profile) try: if not db.delete_session(session_id): raise HTTPException(status_code=404, detail="Session not found") From cf786593cd83f8cffd76d9faed561f7d92e24454 Mon Sep 17 00:00:00 2001 From: ViewWay <834740219@qq.com> Date: Wed, 6 May 2026 23:41:56 +0800 Subject: [PATCH 059/541] fix(gateway): propagate max_tokens from config.yaml to AIAgent max_tokens set under model: in config.yaml was silently ignored. The value was never read from config, never passed through _resolve_runtime_agent_kwargs(), _resolve_turn_agent_config(), or the session override path. Added it to all three code paths so custom/Ollama endpoints receive the correct output cap. Closes #20741 --- gateway/run.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 45d8f6a7a8c4..6444c857a799 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1179,6 +1179,7 @@ def _resolve_runtime_agent_kwargs() -> dict: from hermes_cli.runtime_provider import ( resolve_runtime_provider, format_runtime_provider_error, + _get_model_config, ) from hermes_cli.auth import AuthError, is_rate_limited_auth_error @@ -1200,6 +1201,13 @@ def _resolve_runtime_agent_kwargs() -> dict: except Exception as exc: raise RuntimeError(format_runtime_provider_error(exc)) from exc + model_cfg = _get_model_config() + max_tokens = None + if isinstance(model_cfg, dict): + mt = model_cfg.get("max_tokens") + if isinstance(mt, int): + max_tokens = mt + return { "api_key": runtime.get("api_key"), "base_url": runtime.get("base_url"), @@ -1208,6 +1216,7 @@ def _resolve_runtime_agent_kwargs() -> dict: "command": runtime.get("command"), "args": list(runtime.get("args") or []), "credential_pool": runtime.get("credential_pool"), + "max_tokens": max_tokens, } @@ -2596,6 +2605,7 @@ class GatewayRunner: "api_key": override.get("api_key"), "base_url": override.get("base_url"), "api_mode": override.get("api_mode"), + "max_tokens": override.get("max_tokens"), } if override_runtime.get("api_key"): logger.debug( @@ -2693,6 +2703,7 @@ class GatewayRunner: "command": runtime_kwargs.get("command"), "args": list(runtime_kwargs.get("args") or []), "credential_pool": runtime_kwargs.get("credential_pool"), + "max_tokens": runtime_kwargs.get("max_tokens"), } route = { "model": model, From 1c909e75e1a0ba6e5fde07da804066a1e42450e9 Mon Sep 17 00:00:00 2001 From: ViewWay <834740219@qq.com> Date: Thu, 7 May 2026 00:04:12 +0800 Subject: [PATCH 060/541] =?UTF-8?q?fix(cli,gateway):=20complete=20max=5Fto?= =?UTF-8?q?kens=20propagation=20=E2=80=94=20CLI=20path=20+=20env=20var=20o?= =?UTF-8?q?verride?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit only covered the gateway runtime path. This adds: - CLI __init__: read max_tokens from model config with HERMES_MAX_TOKENS env override - CLI AIAgent() calls (interactive + background): pass max_tokens - Gateway _resolve_runtime_agent_kwargs: add HERMES_MAX_TOKENS env override All three code paths (CLI, gateway runtime, session override) now consistently propagate max_tokens to AIAgent. --- cli.py | 14 ++++++++++++++ gateway/run.py | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 8910e2d8c5de..177a1e44e97a 100644 --- a/cli.py +++ b/cli.py @@ -3194,6 +3194,18 @@ class HermesCLI: _config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") _DEFAULT_CONFIG_MODEL = "" self.model = model or _config_model or _DEFAULT_CONFIG_MODEL + # Read max_tokens from config (env var override: HERMES_MAX_TOKENS) + _env_mt = os.environ.get("HERMES_MAX_TOKENS") + if _env_mt: + try: + self.max_tokens = int(_env_mt) + except (ValueError, TypeError): + self.max_tokens = None + elif isinstance(_model_config, dict): + _mt = _model_config.get("max_tokens") + self.max_tokens = _mt if isinstance(_mt, int) else None + else: + self.max_tokens = None # Auto-detect model from local server if still on default if self.model == _DEFAULT_CONFIG_MODEL: _base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else "" @@ -5168,6 +5180,7 @@ class HermesCLI: acp_command=runtime.get("command"), acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"), + max_tokens=self.max_tokens, max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets, @@ -9284,6 +9297,7 @@ class HermesCLI: api_mode=turn_route["runtime"].get("api_mode"), acp_command=turn_route["runtime"].get("command"), acp_args=turn_route["runtime"].get("args"), + max_tokens=turn_route["runtime"].get("max_tokens"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, quiet_mode=True, diff --git a/gateway/run.py b/gateway/run.py index 6444c857a799..ef3fd3be5ed4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1203,7 +1203,13 @@ def _resolve_runtime_agent_kwargs() -> dict: model_cfg = _get_model_config() max_tokens = None - if isinstance(model_cfg, dict): + _env_mt = os.environ.get("HERMES_MAX_TOKENS") + if _env_mt: + try: + max_tokens = int(_env_mt) + except (ValueError, TypeError): + max_tokens = None + elif isinstance(model_cfg, dict): mt = model_cfg.get("max_tokens") if isinstance(mt, int): max_tokens = mt From 14275d7baa1a48844e58671f63ab49c4823c7cc0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:00:14 -0700 Subject: [PATCH 061/541] fix(gateway): honor per-provider max_output_tokens in max_tokens chain Widens ViewWay's #20741 fix to the sibling config surface: a custom_providers entry can pin its own output cap via max_output_tokens (or max_tokens). _get_named_custom_provider now lifts it onto the resolved runtime at all three return sites, and the gateway uses it as a fallback only when the documented global model.max_tokens isn't set, so the global key always wins. Precedence: HERMES_MAX_TOKENS > model.max_tokens > provider max_output_tokens > None. Closes the same #20741 truncation for users who configure the cap per-provider rather than globally. Picks up the intent of #19782 (alexcam1901), reimplemented to feed ViewWay's max_tokens pipeline. --- gateway/run.py | 7 +++++++ hermes_cli/runtime_provider.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index ef3fd3be5ed4..40721eada6bd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1213,6 +1213,13 @@ def _resolve_runtime_agent_kwargs() -> dict: mt = model_cfg.get("max_tokens") if isinstance(mt, int): max_tokens = mt + # Fall back to a per-provider output cap (custom_providers max_output_tokens) + # only when the documented global model.max_tokens isn't set, so the global + # key always wins. + if max_tokens is None: + _runtime_mot = runtime.get("max_output_tokens") + if isinstance(_runtime_mot, int) and _runtime_mot > 0: + max_tokens = _runtime_mot return { "api_key": runtime.get("api_key"), diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1edb8e99e478..cca80e988ceb 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -474,6 +474,21 @@ def _try_resolve_from_custom_pool( return None +def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None: + """Propagate a per-provider output cap onto the resolved runtime dict. + + Accepts ``max_output_tokens`` or ``max_tokens`` on a ``custom_providers`` + entry so a provider block can pin its own output limit. Gateway and CLI + map this onto ``AIAgent.max_tokens`` only when the top-level + ``model.max_tokens`` isn't set, so the documented global key still wins. + """ + for _k in ("max_output_tokens", "max_tokens"): + _v = entry.get(_k) + if isinstance(_v, int) and _v > 0: + result["max_output_tokens"] = _v + return + + def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]: requested_norm = _normalize_custom_provider_name(requested_provider or "") if not requested_norm or requested_norm == "custom": @@ -541,6 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode + _lift_max_output_tokens(entry, result) return result # Also check the 'name' field if present display_name = entry.get("name", "") @@ -562,6 +578,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode + _lift_max_output_tokens(entry, result) return result # Fall back to custom_providers: list (legacy format) @@ -611,6 +628,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An model_name = str(entry.get("model", "") or "").strip() if model_name: result["model"] = model_name + _lift_max_output_tokens(entry, result) return result return None @@ -699,6 +717,8 @@ def _resolve_named_custom_runtime( model_name = custom_provider.get("model") if model_name: pool_result["model"] = model_name + if isinstance(custom_provider.get("max_output_tokens"), int): + pool_result["max_output_tokens"] = custom_provider["max_output_tokens"] request_overrides = _custom_provider_request_overrides(custom_provider) if request_overrides: pool_result["request_overrides"] = { @@ -736,6 +756,8 @@ def _resolve_named_custom_runtime( # provider name differs from the actual model string the API expects. if custom_provider.get("model"): result["model"] = custom_provider["model"] + if isinstance(custom_provider.get("max_output_tokens"), int): + result["max_output_tokens"] = custom_provider["max_output_tokens"] request_overrides = _custom_provider_request_overrides(custom_provider) if request_overrides: result["request_overrides"] = request_overrides From 2dda393f9f44e5030ca36cf2c8ab8065a9ac982f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:00:14 -0700 Subject: [PATCH 062/541] test(gateway): regression tests for max_tokens propagation chain (#20741) --- tests/gateway/test_max_tokens_propagation.py | 178 +++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/gateway/test_max_tokens_propagation.py diff --git a/tests/gateway/test_max_tokens_propagation.py b/tests/gateway/test_max_tokens_propagation.py new file mode 100644 index 000000000000..a12b4ec79291 --- /dev/null +++ b/tests/gateway/test_max_tokens_propagation.py @@ -0,0 +1,178 @@ +"""Regression tests for max_tokens propagation from config.yaml to AIAgent. + +Covers #20741: `model.max_tokens` was silently dropped before reaching the +gateway-spawned agent, so providers without a hardcoded default (OpenRouter +free models, Ollama Cloud, custom OpenAI-compatible endpoints) truncated long +generations with `finish_reason="length"`. + +Precedence verified here: + HERMES_MAX_TOKENS env > model.max_tokens > per-provider + max_output_tokens > None +""" + +import importlib +import os +import sys +import textwrap + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with a writable config.yaml and a clean module cache. + + These tests deliberately re-import ``hermes_cli`` / ``gateway`` so each + config write is read fresh. To avoid leaking that purge into sibling test + files in the same worker (which breaks their import-time mocks), we snapshot + the affected modules and restore them on teardown. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_MAX_TOKENS", raising=False) + + _saved = { + k: v + for k, v in sys.modules.items() + if k.startswith(("hermes_cli", "gateway")) + } + + def write_cfg(body: str) -> None: + (hermes_home / "config.yaml").write_text(textwrap.dedent(body)) + + def fresh_gateway(): + for mod in list(sys.modules.keys()): + if mod.startswith(("hermes_cli", "gateway")): + del sys.modules[mod] + return importlib.import_module("gateway.run") + + try: + yield write_cfg, fresh_gateway + finally: + # Drop anything we (re)imported, then restore the pre-test snapshot so + # the next test file sees the module objects it was loaded with. + for k in list(sys.modules.keys()): + if k.startswith(("hermes_cli", "gateway")): + del sys.modules[k] + sys.modules.update(_saved) + + +def test_top_level_max_tokens_propagates(isolated_home): + """model.max_tokens is read into the gateway runtime kwargs (#20741).""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: openrouter + max_tokens: 16384 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 16384 + + +def test_per_provider_max_output_tokens_fallback(isolated_home): + """A custom provider's max_output_tokens fills in when no global is set.""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: sk-test + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 12000 + + +def test_global_max_tokens_beats_per_provider(isolated_home): + """The documented global model.max_tokens wins over a provider cap.""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + max_tokens: 16384 + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: sk-test + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 16384 + + +def test_env_override_beats_everything(isolated_home, monkeypatch): + """HERMES_MAX_TOKENS is the internal override mechanism (highest priority).""" + write_cfg, fresh_gateway = isolated_home + monkeypatch.setenv("HERMES_MAX_TOKENS", "2048") + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + max_tokens: 16384 + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: sk-test + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 2048 + + +def test_no_config_leaves_max_tokens_none(isolated_home): + """No cap configured anywhere -> max_tokens is None (no spurious limit).""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: openrouter + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] is None + + +def test_lift_helper_accepts_alias_and_rejects_garbage(isolated_home): + """_lift_max_output_tokens accepts both keys, ignores non-positive/non-int.""" + write_cfg, _ = isolated_home + write_cfg("model:\n provider: openrouter\n") + for mod in list(sys.modules.keys()): + if mod.startswith("hermes_cli"): + del sys.modules[mod] + rp = importlib.import_module("hermes_cli.runtime_provider") + + out: dict = {} + rp._lift_max_output_tokens({"max_output_tokens": 8192}, out) + assert out["max_output_tokens"] == 8192 + + out = {} + rp._lift_max_output_tokens({"max_tokens": 4096}, out) + assert out["max_output_tokens"] == 4096 + + for bad in ({"max_output_tokens": 0}, {"max_output_tokens": "x"}, {}): + out = {} + rp._lift_max_output_tokens(bad, out) + assert "max_output_tokens" not in out From e837856ecdb5498d78242584ea9b16cafb43e712 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:00:14 -0700 Subject: [PATCH 063/541] chore(release): map ViewWay author email for AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 1fee7cd0260a..35f9215aec17 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "834740219@qq.com": "ViewWay", "harjoth.khara@gmail.com": "harjothkhara", "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "dirtyren@users.noreply.github.com": "dirtyren", From 02d6bf1c39dfefd5abb4983732f81e070ec30d99 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 12:22:55 -0500 Subject: [PATCH 064/541] fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): cross-profile session history in app-global remote mode #39894 made remote-profile sessions first-class for PER-PROFILE remote overrides. But the common setup — Settings → Gateway → "All profiles" → Remote — writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty profiles map), which the intercept didn't recognize. Switching to a non-launch profile then 404'd every session read, so no history showed for it. In global remote mode a SINGLE backend serves every profile via ?profile= (it reads each profile's state.db off the remote host's own disk — verified: one dashboard returns /api/profiles and /api/profiles/sessions?profile=all across all profiles). The fix: when no per-profile override matches but global remote mode is active, route per-session reads/mutations to that one backend and KEEP the ?profile= param so it opens the right state.db (instead of bailing to the local path and dropping the profile scope). - new globalRemoteActive() — true for connection.json mode:'remote' or the HERMES_DESKTOP_REMOTE_URL env override. - per-session branch: per-profile override → route sans profile (own db); global mode → route to the single backend WITH ?profile= preserved. - unified list is unchanged in global mode: it already passes through to the one backend, which aggregates all profiles natively. Verified live against a one-dashboard / multi-profile remote (Austin's topology): cross-profile transcript reads load (was 404), rename/delete route to the right profile, unified list spans both profiles. Known limitation (architectural, not fixed here): LIVE chat as a non-launch profile still needs a per-profile dashboard on the remote — the dashboard binds HERMES_HOME once at process start, so one global backend can't run an agent turn as another profile. Session history/read/mutate now work regardless. * fix(gateway): resume + chat any profile over one global-remote dashboard The REST half of this branch made cross-profile session history visible in app-global remote mode, but resume + chat still went over the WebSocket gateway, which was hard-bound to the dashboard's launch profile. Resuming a non-launch profile's session 404'd ("session not found") and sending spawned a new session — because session.resume/prompt.submit had no profile concept and the live agent + state.db were process-global to the launch profile's HERMES_HOME. Make the WS gateway per-session profile-aware so ONE dashboard can serve every local profile on its host (the app-global remote topology): - session.resume accepts an optional `profile`. _profile_home() resolves that profile's home on this host; resume opens THAT profile's state.db, binds its HERMES_HOME (ContextVar override) while building the agent so config/skills/ model resolve to it, and passes the profile db to the agent so turns persist to the right state.db. The owning profile_home is stored on the session. - prompt.submit re-binds the stored profile_home for the turn thread (mid-turn home reads — memory, skills — resolve to the resumed profile), reset in finally. - _make_agent gains an optional session_db param (defaults to _get_db()). - _load_cfg honors the home override (falls back to _hermes_home) so a resumed profile loads its own config; cache keyed on resolved path. - desktop: session.resume now sends the owning profile. Omitted/launch profile → unchanged (single-profile and per-profile-remote setups are byte-for-byte the same path). Verified live against a one-dashboard / multi-profile remote: resuming a non-launch profile's session loads its history, runs a real turn against THAT profile's home/env, and persists to its state.db. tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param. --- apps/desktop/electron/main.cjs | 42 ++++++++-- .../app/session/hooks/use-session-actions.ts | 6 +- tests/tui_gateway/test_protocol.py | 10 +-- tui_gateway/server.py | 81 +++++++++++++++++-- 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3273b2551419..b42913093d7c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3922,6 +3922,16 @@ function configuredRemoteProfileNames() { return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) } +// True when the app is in app-global remote mode (Settings → "All profiles" → +// Remote, or the env override): a SINGLE remote backend serves every profile via +// ?profile=. Distinct from per-profile overrides — here there's one host for all. +function globalRemoteActive() { + if (process.env.HERMES_DESKTOP_REMOTE_URL) { + return true + } + return readDesktopConnectionConfig().mode === 'remote' +} + // GET a profile's resolved backend (remote pool or local primary), parsed JSON. async function fetchJsonForProfile(profile, path) { return requestJsonForProfile(profile, path, 'GET') @@ -4762,19 +4772,35 @@ async function interceptSessionRequestForRemote(request) { } // Per-session read/mutation. Owner is in ?profile= (reads) or request.profile - // (mutations); route to the remote sans profile param — it serves its own - // state.db, with no cross-profile semantics. + // (mutations). Two remote shapes: + // - per-profile override: route to that profile's own remote, sans profile + // param (it serves its own state.db natively). + // - global remote mode: ONE backend serves every profile via ?profile=, so + // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { const profile = (searchParams.get('profile') || request.profile || '').trim() - if (!profile || !profileHasRemoteOverride(profile)) { + if (!profile) { return undefined } - if (method === 'GET') { - return fetchJsonForProfile(profile, pathname) + if (profileHasRemoteOverride(profile)) { + if (method === 'GET') { + return fetchJsonForProfile(profile, pathname) + } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body + if (body) delete body.profile + return requestJsonForProfile(profile, pathname, method, body) } - const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) delete body.profile - return requestJsonForProfile(profile, pathname, method, body) + if (globalRemoteActive()) { + // Single global backend: keep ?profile= so it opens the right state.db. + const sep = pathname.includes('?') ? '&' : '?' + const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}` + if (method === 'GET') { + return fetchJsonForProfile(null, path) + } + const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile } + return requestJsonForProfile(null, path, method, body) + } + return undefined } return undefined diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 4d63dda27901..e54fd88c0735 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -529,7 +529,11 @@ export function useSessionActions({ const resumed = await requestGateway('session.resume', { session_id: storedSessionId, - cols: 96 + cols: 96, + // Owning profile: in app-global remote mode one backend serves every + // profile, so the gateway opens this profile's state.db + home to + // resume + persist the right session (no-op for single/launch profile). + ...(sessionProfile ? { profile: sessionProfile } : {}) }) if (!isCurrentResume()) { diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 9a6b7d30bd12..2fec6617d62d 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -315,7 +315,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): ] monkeypatch.setattr(server, "_get_db", lambda: _DB()) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object()) monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) @@ -366,7 +366,7 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): return [multimodal_user, text_only_assistant] monkeypatch.setattr(server, "_get_db", lambda: _DB()) - monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object()) monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"}) @@ -432,7 +432,7 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): def close(self): closed_sids.append(self.sid) - def make_agent(sid, key, session_id=None): + def make_agent(sid, key, session_id=None, session_db=None): created_sids.append(sid) first_agent_started.set() assert agent_can_finish.wait(timeout=1) @@ -547,7 +547,7 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch.setattr( server, "_make_agent", - lambda _sid, key, session_id=None: types.SimpleNamespace( + lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace( model="test/model", session_id=session_id or key ), ) @@ -647,7 +647,7 @@ def test_session_branch_persists_branched_from_marker(server, monkeypatch): monkeypatch.setattr( server, "_make_agent", - lambda _sid, key, session_id=None: types.SimpleNamespace( + lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace( model="test/model", session_id=session_id or key ), ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4dc30e06a994..7e9fd45302fc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16,7 +16,12 @@ from datetime import datetime from pathlib import Path from typing import Any, Optional -from hermes_constants import get_hermes_home +from hermes_constants import ( + get_hermes_home, + get_hermes_home_override, + reset_hermes_home_override, + set_hermes_home_override, +) from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value from tui_gateway.transport import ( @@ -458,6 +463,31 @@ def _db_unavailable_error(rid, *, code: int): return _err(rid, code, f"state.db unavailable: {detail}") +# ── per-session profile scoping (global remote mode) ─────────────────────────── +# One dashboard normally serves its launch profile. But the desktop's app-global +# remote mode points every profile at this single backend, so resume/prompt must +# be able to act on ANOTHER local profile's state.db + home. The desktop passes +# ``profile`` on those calls; we open that profile's db and bind its HERMES_HOME +# (a ContextVar override) for the duration of the call so config/skills/model and +# message persistence all resolve to the right profile. Omitted/own profile → the +# launch profile (unchanged for single-profile and per-profile-remote setups). +def _profile_home(profile: str | None) -> Path | None: + """Resolve a named profile's home on THIS host, or None for the launch profile.""" + name = (profile or "").strip() + if not name: + return None + try: + from hermes_cli import profiles as profiles_mod + + home = Path(profiles_mod.get_profile_dir(name)) + except Exception: + return None + # Already the launch profile? No override needed. + if home.resolve() == Path(_hermes_home).resolve(): + return None + return home if (home / "state.db").exists() or home.exists() else None + + def write_json(obj: dict) -> bool: """Emit one JSON frame. Routes via the most-specific transport available. @@ -873,7 +903,13 @@ def _load_cfg() -> dict: try: import yaml - p = _hermes_home / "config.yaml" + # Honor a per-session profile override (see session.resume) so a resumed + # remote profile loads ITS config (model, skills, prompt); otherwise the + # launch profile's _hermes_home. Cache is keyed on the resolved path, so + # profiles don't clobber each other. + override = get_hermes_home_override() + home = override if isinstance(override, str) and override else _hermes_home + p = Path(home) / "config.yaml" mtime = p.stat().st_mtime if p.exists() else None with _cfg_lock: if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p: @@ -2434,7 +2470,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict: return info -def _make_agent(sid: str, key: str, session_id: str | None = None): +def _make_agent(sid: str, key: str, session_id: str | None = None, session_db=None): from run_agent import AIAgent from hermes_cli.runtime_provider import resolve_runtime_provider @@ -2494,7 +2530,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): enabled_toolsets=_load_enabled_toolsets(), platform="tui", session_id=session_id or key, - session_db=_get_db(), + session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None, checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")), pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")), @@ -3094,9 +3130,22 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) except (TypeError, ValueError): cols = 80 - db = _get_db() + # ``profile`` (app-global remote mode): resume a session that lives in another + # local profile's state.db. None/own profile → the launch profile (unchanged). + profile = (params.get("profile") or "").strip() or None + profile_home = _profile_home(profile) + + # In a profile scope, the agent OWNS a long-lived db handle bound to that + # profile (do NOT auto-close it here). Otherwise reuse the shared launch db. + if profile_home is not None: + from hermes_state import SessionDB + + db = SessionDB(db_path=profile_home / "state.db") + else: + db = _get_db() if db is None: return _db_unavailable_error(rid, code=5000) + found = db.get_session(target) if not found: found = db.get_session_by_title(target) @@ -3125,6 +3174,9 @@ def _(rid, params: dict) -> dict: # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. sid = uuid.uuid4().hex[:8] _enable_gateway_prompts() + home_token = ( + set_hermes_home_override(str(profile_home)) if profile_home is not None else None + ) try: db.reopen_session(target) history = db.get_messages_as_conversation(target) @@ -3137,11 +3189,17 @@ def _(rid, params: dict) -> dict: messages = _history_to_messages(display_history) tokens = _set_session_context(target) try: - agent = _make_agent(sid, target, session_id=target) + # Pass the profile's db so the agent persists turns to the right + # state.db; home override is active here so config/skills/model + # resolve to the profile too. + agent = _make_agent(sid, target, session_id=target, session_db=db) finally: _clear_session_context(tokens) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") + finally: + if home_token is not None: + reset_hermes_home_override(home_token) # Double-checked locking: another concurrent resume may have created the # live session while we were building. Re-check under the lock; if it won, @@ -3168,6 +3226,11 @@ def _(rid, params: dict) -> dict: _init_session(sid, target, agent, history, cols=cols) if sid in _sessions: _sessions[sid]["display_history_prefix"] = display_history_prefix + # Remember the profile home so each turn re-binds HERMES_HOME (the + # agent persists to its own db, but mid-turn home reads — memory, + # skills — must resolve to the resumed profile too). + if profile_home is not None: + _sessions[sid]["profile_home"] = str(profile_home) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") session = _sessions.get(sid) or {} @@ -4381,6 +4444,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: def run(): approval_token = None session_tokens = [] + home_token = None # per-turn HERMES_HOME override for a resumed remote profile goal_followup = None # set by the post-turn goal hook below try: from tools.approval import ( @@ -4390,6 +4454,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: approval_token = set_current_session_key(session["session_key"]) session_tokens = _set_session_context(session["session_key"]) + _profile_home_str = session.get("profile_home") + if _profile_home_str: + home_token = set_hermes_home_override(_profile_home_str) # The sudo password callback is thread-local (tools.terminal_tool # _callback_tls), so wiring it on the build thread doesn't reach this # turn thread — terminal sudo prompts would fall through to /dev/tty @@ -4718,6 +4785,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: reset_current_session_key(approval_token) except Exception: pass + if home_token is not None: + reset_hermes_home_override(home_token) _clear_session_context(session_tokens) with session["history_lock"]: session["running"] = False From 4a1907bd10c3da9266d36ece6337afeb20365fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Wed, 3 Jun 2026 08:48:01 -0500 Subject: [PATCH 065/541] feat(desktop): add i18n with Simplified Chinese (zh-Hans) support Introduce a lightweight React context-based i18n layer for the desktop app and translate the UI into Simplified Chinese. - New apps/desktop/src/i18n module: typed Translations interface, en + zh locale tables, I18nProvider/useI18n, localStorage-persisted locale (defaults to English), and language endonym metadata for the picker. - Wire I18nProvider at the app root in main.tsx. - Refactor 24 desktop screens/components to read strings from the `t` object instead of hard-coded English. - Add a unit test for the i18n context. --- apps/desktop/src/app/agents/index.tsx | 86 +- apps/desktop/src/app/artifacts/index.tsx | 94 +- .../src/app/chat/composer/attachments.tsx | 11 +- .../src/app/chat/composer/context-menu.tsx | 105 +-- .../src/app/chat/composer/controls.tsx | 42 +- .../src/app/chat/composer/help-hint.tsx | 40 +- apps/desktop/src/app/chat/composer/index.tsx | 39 +- .../src/app/chat/composer/queue-panel.tsx | 27 +- .../app/chat/composer/skin-slash-popover.tsx | 11 +- .../src/app/chat/composer/url-dialog.tsx | 16 +- .../src/app/chat/composer/voice-activity.tsx | 12 +- apps/desktop/src/app/chat/sidebar/index.tsx | 66 +- .../app/chat/sidebar/session-actions-menu.tsx | 39 +- .../src/app/chat/sidebar/session-row.tsx | 34 +- apps/desktop/src/app/command-center/index.tsx | 871 +++++++++++------- .../src/app/cron/cron-job-actions-menu.tsx | 20 +- apps/desktop/src/app/cron/index.tsx | 357 ++++--- apps/desktop/src/app/desktop-controller.tsx | 1 + apps/desktop/src/app/messaging/index.tsx | 171 ++-- .../src/app/overlays/overlay-search-input.tsx | 37 + apps/desktop/src/app/page-search-shell.tsx | 3 + apps/desktop/src/app/profiles/index.tsx | 844 ++++++++++------- .../src/app/settings/about-settings.tsx | 69 +- .../src/app/settings/appearance-settings.tsx | 241 +++-- .../src/app/settings/config-settings.tsx | 16 +- apps/desktop/src/app/settings/index.tsx | 28 +- .../src/app/shell/titlebar-controls.tsx | 16 +- apps/desktop/src/app/skills/index.test.tsx | 2 +- apps/desktop/src/app/skills/index.tsx | 134 +-- apps/desktop/src/i18n/context.test.tsx | 57 ++ apps/desktop/src/i18n/context.tsx | 76 ++ apps/desktop/src/i18n/en.ts | 669 ++++++++++++++ apps/desktop/src/i18n/index.ts | 2 + apps/desktop/src/i18n/types.ts | 539 +++++++++++ apps/desktop/src/i18n/zh.ts | 802 ++++++++++++++++ apps/desktop/src/main.tsx | 17 +- 36 files changed, 4221 insertions(+), 1373 deletions(-) create mode 100644 apps/desktop/src/app/overlays/overlay-search-input.tsx create mode 100644 apps/desktop/src/i18n/context.test.tsx create mode 100644 apps/desktop/src/i18n/context.tsx create mode 100644 apps/desktop/src/i18n/en.ts create mode 100644 apps/desktop/src/i18n/index.ts create mode 100644 apps/desktop/src/i18n/types.ts create mode 100644 apps/desktop/src/i18n/zh.ts diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 4bcf76e46e6f..ff0aa8fb654c 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { BrailleSpinner } from '@/components/ui/braille-spinner' import { FadeText } from '@/components/ui/fade-text' +import { type Translations, useI18n } from '@/i18n' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view' // Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the // same visual vocabulary as the chat tool blocks. -function statusGlyph(status: SubagentStatus): ReactNode { +function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode { if (status === 'running' || status === 'queued') { return ( @@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode { } if (status === 'failed' || status === 'interrupted') { - return + return } - return + return } const STREAM_TONE: Record = { @@ -75,6 +76,7 @@ interface AgentsViewProps { } export function AgentsView({ onClose }: AgentsViewProps) { + const { t } = useI18n() const activeSessionId = useStore($activeSessionId) const subagentsBySession = useStore($subagentsBySession) @@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) { return (
-

Spawn tree

-

Live subagent activity for the current turn.

+

{t.agents.title}

+

{t.agents.subtitle}

) } -const fmtDuration = (seconds?: number) => { +const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => { if (!seconds || seconds <= 0) { return '' } if (seconds < 60) { - return `${seconds.toFixed(1)}s` + return a.durationSeconds(seconds.toFixed(1)) } const m = Math.floor(seconds / 60) const s = Math.round(seconds % 60) - return `${m}m ${s}s` + return a.durationMinutes(m, s) } -const fmtTokens = (value?: number) => { +const fmtTokens = (value: number | undefined, a: Translations['agents']) => { if (!value) { return '' } - return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` + return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value) } -const fmtAge = (updatedAt: number, nowMs: number) => { +const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => { const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) if (s < 2) { - return 'now' + return a.ageNow } if (s < 60) { - return `${s}s ago` + return a.ageSeconds(s) } const m = Math.floor(s / 60) if (m < 60) { - return `${m}m ago` + return a.ageMinutes(m) } - return `${Math.floor(m / 60)}h ago` + return a.ageHours(Math.floor(m / 60)) } const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => @@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => interface RootGroup { id: string - label: string + delegationIndex: number nodes: SubagentNode[] taskCount: number } @@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { if (node.taskCount > 1) { n += 1 - groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount }) continue } - groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount }) + groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount }) } return groups } function SubagentTree({ tree }: { tree: SubagentNode[] }) { + const { t } = useI18n() const flat = useMemo(() => flatten(tree), [tree]) const groups = useMemo(() => groupDelegations(tree), [tree]) const [nowMs, setNowMs] = useState(() => Date.now()) @@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { return (
-

No live subagents

-

- When a turn delegates work, child agents stream their progress here. -

+

{t.agents.emptyTitle}

+

{t.agents.emptyDesc}

) } const summary = [ - `${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, - active > 0 ? `${active} active` : '', - failed > 0 ? `${failed} failed` : '', - tools > 0 ? `${tools} tools` : '', - files > 0 ? `${files} files` : '', - tokens > 0 ? fmtTokens(tokens) : '', + t.agents.agentsCount(flat.length), + active > 0 ? t.agents.activeCount(active) : '', + failed > 0 ? t.agents.failedCount(failed) : '', + tools > 0 ? t.agents.toolsCount(tools) : '', + files > 0 ? t.agents.filesCount(files) : '', + tokens > 0 ? fmtTokens(tokens, t.agents) : '', cost > 0 ? `$${cost.toFixed(2)}` : '' ].filter(Boolean) @@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { } function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { + const { t } = useI18n() + if (group.nodes.length === 1 && group.taskCount <= 1) { return } @@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) return (

- {group.label} · {group.nodes.length} workers - {activeWorkers > 0 ? · {activeWorkers} active : null} + {group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '} + · {t.agents.workers(group.nodes.length)} + {activeWorkers > 0 ? · {t.agents.workersActive(activeWorkers)} : null}

{group.nodes.map(node => ( @@ -275,6 +279,7 @@ function StreamLine({ parentRunning: boolean rowKey: string }) { + const { t } = useI18n() const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) const isMono = entry.kind === 'tool' const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] @@ -286,7 +291,7 @@ function StreamLine({ {entry.text} {active ? ( @@ -297,6 +302,7 @@ function StreamLine({ } function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { + const { t } = useI18n() const running = node.status === 'running' || node.status === 'queued' const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) @@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n const subtitle = [ node.model, - fmtDuration(durationSeconds), - node.toolCount ? `${node.toolCount} tools` : '', - fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), - `updated ${fmtAge(node.updatedAt, nowMs)}` + fmtDuration(durationSeconds, t.agents), + node.toolCount ? t.agents.toolsCount(node.toolCount) : '', + fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents), + t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents)) ].filter(Boolean) return ( @@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n onClick={() => setOpen(v => !v)} type="button" > - {statusGlyph(node.status)} + {statusGlyph(node.status, t.agents)} 0 ? (
-

Files

+

{t.agents.files}

{fileLines.slice(0, 8).map(line => (

{line} @@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n ))} {fileLines.length > 8 ? (

- +{fileLines.length - 8} more files + {t.agents.moreFiles(fileLines.length - 8)}

) : null}
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 109ffba07943..fd1569d7caf4 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom' import { ZoomableImage } from '@/components/chat/zoomable-image' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { Pagination, @@ -18,6 +19,7 @@ import { import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listSessions } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' @@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string { return ARTIFACT_TIME_FMT.format(new Date(timestamp)) } -function pageRangeLabel(total: number, page: number, pageSize: number): string { +function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string { if (total === 0) { - return '0' + return a.zero } const start = (page - 1) * pageSize + 1 const end = Math.min(total, page * pageSize) - return `${start}-${end} of ${total}` + return a.rangeOf(start, end, total) } function paginationItems(page: number, pageCount: number): Array { @@ -356,21 +358,25 @@ type CellCtx = { interface ArtifactColumn { Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement bodyClassName: string - header: (filter: ArtifactFilter) => string + header: (filter: ArtifactFilter, a: Translations['artifacts']) => string id: 'location' | 'primary' | 'session' width: (filter: ArtifactFilter) => string } -const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items') +const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) => + f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric interface ArtifactsViewProps extends React.ComponentProps<'section'> { setStatusbarItemGroup?: SetStatusbarItemGroup } export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) { + const { t } = useI18n() + const a = t.artifacts const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all') @@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const [filePage, setFilePage] = useState(1) const refreshArtifacts = useCallback(async () => { + setRefreshing(true) + try { const sessions = (await listSessions(30, 1)).sessions const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) @@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages)) }) - setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp)) + setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp)) } catch (err) { - notifyError(err, 'Artifacts failed to load') + notifyError(err, a.failedLoad) setArtifacts([]) + } finally { + setRefreshing(false) } - }, []) + }, [a]) useRefreshHotkey(refreshArtifacts) @@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . window.open(href, '_blank', 'noopener,noreferrer') } } catch (err) { - notifyError(err, 'Open failed') + notifyError(err, a.openFailed) } - }, []) + }, [a]) const markImageFailed = useCallback((id: string) => { setFailedImageIds(current => { @@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . {...props} onSearchChange={setQuery} searchHidden={counts.all === 0} - searchPlaceholder="Search artifacts..." + searchPlaceholder={a.search} + searchTrailingAction={ + + } searchValue={query} tabs={ <> setKindFilter('all')}> - All ({counts.all}) + {a.tabAll} ({counts.all}) setKindFilter('image')}> - Images ({counts.image}) + {a.tabImages} ({counts.image}) setKindFilter('file')}> - Files ({counts.file}) + {a.tabFiles} ({counts.file}) setKindFilter('link')}> - Links ({counts.link}) + {a.tabLinks} ({counts.link}) } > {!artifacts ? ( - + ) : visibleArtifacts.length === 0 ? (
-
No artifacts found
-
- Generated images and file outputs will appear here as sessions produce them. -
+
{a.noArtifactsTitle}
+
{a.noArtifactsDesc}
) : ( @@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . >
- {pageRangeLabel(total, page, pageSize)} {itemLabel} + {pageRangeLabel(total, page, pageSize, a)} {itemLabel}
{pageCount > 1 && ( @@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz ) : ( onPageChange(item)} > @@ -657,6 +681,10 @@ interface ArtifactImageCardProps { } function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) { + const { t } = useI18n() + const a = t.artifacts + const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink + return (
- {artifact.kind} + {kindLabel}
{artifact.label} @@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
@@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx } function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const { t } = useI18n() const isLink = artifact.kind === 'link' const value = isLink ? hostPathLabel(artifact.value) : artifact.value - const copyLabel = isLink ? 'Copy URL' : 'Copy path' + const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath return (
@@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [ { Cell: PrimaryCell, bodyClassName: 'p-0', - header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'), + header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault), id: 'primary', width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]') }, { Cell: LocationCell, bodyClassName: 'px-2.5 py-1.5', - header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'), + header: (filter, a) => + filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault, id: 'location', width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]') }, { Cell: SessionCell, bodyClassName: 'p-0', - header: () => 'Session', + header: (_filter, a) => a.colSession, id: 'session', width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]') } @@ -843,13 +873,15 @@ function ArtifactTable({ ctx: CellCtx filter: ArtifactFilter }) { + const { t } = useI18n() + return ( {ARTIFACT_COLUMNS.map(col => ( ))} diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index a46112338279..0c154a8a4b15 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import type { ComposerAttachment } from '@/store/composer' @@ -26,6 +27,8 @@ export function AttachmentList({ } function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { + const { t } = useI18n() + const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] const cwd = useStore($currentCwd) const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' @@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined) if (!preview) { - throw new Error(`Could not preview ${attachment.label}`) + throw new Error(c.couldNotPreview(attachment.label)) } setCurrentSessionPreviewTarget(preview, 'manual', target) } catch (error) { - notifyError(error, 'Preview unavailable') + notifyError(error, c.previewUnavailable) } } @@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
{onRemove && ( - - ))} + + + ) + })} @@ -175,15 +165,8 @@ interface ContextMenuProps { state: ChatBarState } -interface PromptSnippet { - description: string - label: string - text: string -} - interface PromptSnippetsDialogProps { onInsertText: (text: string) => void onOpenChange: (open: boolean) => void open: boolean - snippets: readonly PromptSnippet[] } diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7e98456f9890..5e1e3df6fb04 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -55,6 +56,9 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void }) { + const { t } = useI18n() + const c = t.composer + if (conversation.active) { return } @@ -65,9 +69,9 @@ export function ComposerControls({
{showVoicePrimary ? ( - + ) : ( - + )} {label} @@ -220,10 +228,12 @@ function DictationButton({ status: VoiceStatus onToggle: () => void }) { + const { t } = useI18n() + const c = t.composer const active = state.active || status !== 'idle' const aria = - status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation' + status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation return ( diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx index c986f20f44c3..897c7f0e3a78 100644 --- a/apps/desktop/src/app/chat/composer/help-hint.tsx +++ b/apps/desktop/src/app/chat/composer/help-hint.tsx @@ -1,44 +1,32 @@ import type { ReactNode } from 'react' +import { useI18n } from '@/i18n' + import { COMPLETION_DRAWER_CLASS } from './completion-drawer' -const COMMON_COMMANDS: [string, string][] = [ - ['/help', 'full list of commands + hotkeys'], - ['/clear', 'start a new session'], - ['/resume', 'resume a prior session'], - ['/details', 'control transcript detail level'], - ['/copy', 'copy selection or last assistant message'], - ['/quit', 'exit hermes'] -] - -const HOTKEYS: [string, string][] = [ - ['@', 'reference files, folders, urls, git'], - ['/', 'slash command palette'], - ['?', 'this quick help (delete to dismiss)'], - ['Enter', 'send · Shift+Enter for newline'], - ['Cmd/Ctrl+K', 'send next queued turn'], - ['Cmd/Ctrl+L', 'redraw'], - ['Esc', 'close popover · cancel run'], - ['↑ / ↓', 'cycle popover / history'] -] +const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit'] +const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓'] export function HelpHint() { + const { t } = useI18n() + const c = t.composer + return (
-
- {COMMON_COMMANDS.map(([key, desc]) => ( - +
+ {COMMON_COMMAND_KEYS.map(key => ( + ))}
-
- {HOTKEYS.map(([key, desc]) => ( - +
+ {HOTKEY_KEYS.map(key => ( + ))}

- /help opens the full panel · backspace dismisses + /help {c.helpFooter}

) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 4997014e8107..2288a7b7f82b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' +import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' @@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36 const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' -// Resting composer placeholders. New sessions get open-ended starters; an -// existing chat gets phrasings that read as a continuation of the thread. -// One is picked at random per session (stable until the session changes). -const NEW_SESSION_PLACEHOLDERS = [ - 'What are we building?', - 'Give Hermes a task', - "What's on your mind?", - 'Describe what you need', - 'What should we tackle?', - 'Ask anything', - 'Start with a goal' -] - -const FOLLOW_UP_PLACEHOLDERS = [ - 'Send a follow-up', - 'Add more context', - 'Refine the request', - "What's next?", - 'Keep it going', - 'Push it further', - 'Adjust or continue' -] - const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] interface QueueEditState { @@ -190,7 +168,10 @@ export function ChatBar({ const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' + const { t } = useI18n() const gatewayState = useStore($gatewayState) + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a @@ -198,7 +179,7 @@ export function ChatBar({ // started session (null → id, on the first send) is treated as the same // conversation so the placeholder doesn't visibly flip mid-stream. const [restingPlaceholder, setRestingPlaceholder] = useState(() => - pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS) + pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) ) const prevSessionIdRef = useRef(sessionId) @@ -217,16 +198,16 @@ export function ChatBar({ return } - setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)) - }, [sessionId]) + setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) + }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) // When the bar is disabled it's because the gateway isn't open. Distinguish a // cold start ("Starting Hermes...") from a dropped connection we're trying to // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. const placeholder = disabled ? gatewayState === 'closed' || gatewayState === 'error' - ? 'Reconnecting to Hermes…' - : 'Starting Hermes...' + ? t.composer.placeholderReconnecting + : t.composer.placeholderStarting : restingPlaceholder const focusInput = useCallback(() => { @@ -1213,7 +1194,7 @@ export function ChatBar({ const input = (
void } -const entryPreview = (entry: QueuedPromptEntry) => - entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn') +const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => + entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { + const { t } = useI18n() + const c = t.composer const [collapsed, setCollapsed] = useState(false) if (entries.length === 0) { @@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" > - {entries.length} Queued + {c.queued(entries.length)} {!collapsed && ( @@ -57,17 +60,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
-

{entryPreview(entry)}

+

{entryPreview(entry, c)}

{(attachmentsCount > 0 || isEditing) && (
{attachmentsCount > 0 && ( - {attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'} + {c.attachments(attachmentsCount)} )} {isEditing && ( - Editing in composer + {c.editingInComposer} )}
@@ -81,9 +84,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100' )} > - + - + - + diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index b41e7aac816f..535d1422e45a 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { stopVoicePlayback } from '@/lib/voice-playback' @@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n } export function VoiceActivity({ state }: { state: VoiceActivityState }) { + const { t } = useI18n() + if (state.status === 'idle') { return null } const recording = state.status === 'recording' - const title = recording ? 'Dictating' : 'Transcribing' + const title = recording ? t.composer.dictating : t.composer.transcribing return (
() for (const session of sessions) { const path = session.cwd?.trim() || '' const id = path || '__no_workspace__' - const label = baseName(path) || path || 'No workspace' + const label = baseName(path) || path || noWorkspaceLabel const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] } group.sessions.push(session) @@ -233,6 +234,8 @@ export function ChatSidebar({ onArchiveSession, onNewSessionInWorkspace }: ChatSidebarProps) { + const { t } = useI18n() + const s = t.sidebar const sidebarOpen = useStore($sidebarOpen) const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) @@ -402,8 +405,8 @@ export function ChatSidebar({ ) const agentGroups = useMemo( - () => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds), - [agentSessions, workspaceOrderIds] + () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), + [agentSessions, s.noWorkspace, workspaceOrderIds] ) const loadMoreForProfileGroup = useCallback( @@ -589,13 +592,15 @@ export function ChatSidebar({ onNavigate(item) }} - tooltip={item.label} + tooltip={s.nav[item.id] ?? item.label} type="button" > {sidebarOpen && ( <> - {item.label} + + {s.nav[item.id] ?? item.label} + {isNewSession && (
@@ -629,10 +634,10 @@ export function ChatSidebar({ contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75" emptyState={
- No sessions match “{trimmedQuery}”. + {s.noMatch(trimmedQuery)}
} - label="Results" + label={s.results} labelMeta={String(searchResults.length)} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} @@ -653,7 +658,7 @@ export function ChatSidebar({ contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1" dndSensors={dndSensors} emptyState={} - label="Pinned" + label={s.pinned} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} onReorder={handlePinnedDragEnd} @@ -703,9 +708,9 @@ export function ChatSidebar({ // view (always grouped by profile), so hide the button (not the slot).
{!showAllProfiles && agentSessions.length > 0 ? ( - + {(onNewSession || isProfileGroup) && ( - + diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 4c79e85b00d5..0c2ed62d235b 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { dragHandleProps?: React.HTMLAttributes } -const AGE_TICKS: ReadonlyArray<[number, string]> = [ - [86_400_000, 'd'], - [3_600_000, 'h'], - [60_000, 'm'] +const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [ + [86_400_000, 'ageDay'], + [3_600_000, 'ageHour'], + [60_000, 'ageMin'] ] -function formatAge(seconds: number): string { +function formatAge(seconds: number, r: Translations['sidebar']['row']): string { const delta = Math.max(0, Date.now() - seconds * 1000) - for (const [ms, suffix] of AGE_TICKS) { + for (const [ms, key] of AGE_TICKS) { if (delta >= ms) { - return `${Math.floor(delta / ms)}${suffix}` + return `${Math.floor(delta / ms)}${r[key]}` } } - return 'now' + return r.ageNow } export function SidebarSessionRow({ @@ -61,8 +62,10 @@ export function SidebarSessionRow({ ref, ...rest }: SidebarSessionRowProps) { + const { t } = useI18n() + const r = t.sidebar.row const title = sessionTitle(session) - const age = formatAge(session.last_active || session.started_at) + const age = formatAge(session.last_active || session.started_at, r) const handleLabel = `Reorder ${title}` // Subscribe per-row (the leaf) instead of drilling a set through the list — // the atom is tiny and rarely non-empty. True when a clarify prompt in this @@ -196,10 +199,10 @@ export function SidebarSessionRow({ title={title} > - - ) -} - -function EmptyPanel({ action, description, title }: { action?: ReactNode; description: string; title: string }) { - return ( -
-
-
{title}
-
- {description} -
- {action &&
{action}
} -
-
- ) -} - -export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) { +export function CommandCenterView({ + initialSection, + onClose, + onDeleteSession, + onNavigateRoute, + onOpenSession +}: CommandCenterViewProps) { + const { t } = useI18n() + const cc = t.commandCenter const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions') const [query, setQuery] = useState('') + const [searchLoading, setSearchLoading] = useState(false) + const [searchGroups, setSearchGroups] = useState([]) const [status, setStatus] = useState(null) const [logs, setLogs] = useState([]) const [systemLoading, setSystemLoading] = useState(false) @@ -139,30 +172,78 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on const [usage, setUsage] = useState(null) const [usageLoading, setUsageLoading] = useState(false) const [usageError, setUsageError] = useState('') + const searchRequestRef = useRef(0) const usageRequestRef = useRef(0) const debouncedQuery = useDebouncedValue(query.trim(), 180) - const filteredSessions = useMemo(() => { - const sorted = [...sessions].sort((a, b) => { - const left = a.last_active || a.started_at || 0 - const right = b.last_active || b.started_at || 0 + const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) - return right - left - }) + const filteredSessions = useMemo( + () => + [...sessions].sort((a, b) => { + const left = a.last_active || a.started_at || 0 + const right = b.last_active || b.started_at || 0 - const needle = debouncedQuery.toLowerCase() + return right - left + }), + [sessions] + ) - if (!needle) { - return sorted - } + const searchProviders = useMemo( + () => [ + { + id: 'navigation', + label: cc.providerNavigate, + search: async searchQuery => { + const routeHits: RouteSearchHit[] = NAV_ROUTES.filter(entry => + matchesSearchQuery(searchQuery, cc.nav[entry.key].title, cc.nav[entry.key].detail, entry.route) + ).map(entry => ({ + detail: cc.nav[entry.key].detail, + kind: 'route', + route: entry.route, + title: cc.nav[entry.key].title + })) - return sorted.filter(session => { - const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase() + const sectionHits: SectionSearchHit[] = SECTIONS.filter(section => + matchesSearchQuery( + searchQuery, + cc.sectionEntries[section].title, + cc.sectionEntries[section].detail, + cc.sections[section] + ) + ).map(section => ({ + detail: cc.sectionEntries[section].detail, + kind: 'section', + section, + title: cc.sectionEntries[section].title + })) - return haystack.includes(needle) - }) - }, [debouncedQuery, sessions]) + return [...routeHits, ...sectionHits] + } + }, + { + id: 'sessions', + label: cc.providerSessions, + search: async searchQuery => { + const response = await searchSessions(searchQuery) + + return response.results.map(result => { + const { detail, title } = splitSessionSearchResult(result, sessionsById) + + return { + detail, + kind: 'session', + sessionId: result.session_id, + snippet: result.snippet || '', + title + } satisfies SessionSearchHit + }) + } + } + ], + [cc, sessionsById] + ) const refreshSystem = useCallback(async () => { setSystemLoading(true) @@ -180,7 +261,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on setStatus(nextStatus) setLogs(nextLogs.lines) } catch (error) { - setSystemError(errorText(error)) + setSystemError(error instanceof Error ? error.message : String(error)) } finally { setSystemLoading(false) } @@ -200,7 +281,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } } catch (error) { if (usageRequestRef.current === requestId) { - setUsageError(errorText(error)) + setUsageError(error instanceof Error ? error.message : String(error)) } } finally { if (usageRequestRef.current === requestId) { @@ -209,6 +290,42 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } }, []) + useEffect(() => { + if (!debouncedQuery) { + setSearchGroups([]) + setSearchLoading(false) + + return + } + + const requestId = searchRequestRef.current + 1 + searchRequestRef.current = requestId + setSearchLoading(true) + + void Promise.all( + searchProviders.map(async provider => ({ + id: provider.id, + label: provider.label, + results: await provider.search(debouncedQuery) + })) + ) + .then(groups => { + if (searchRequestRef.current === requestId) { + setSearchGroups(groups.filter(group => group.results.length > 0)) + } + }) + .catch(() => { + if (searchRequestRef.current === requestId) { + setSearchGroups([]) + } + }) + .finally(() => { + if (searchRequestRef.current === requestId) { + setSearchLoading(false) + } + }) + }, [debouncedQuery, searchProviders]) + useEffect(() => { if (section === 'system' && !status && !systemLoading) { void refreshSystem() @@ -229,6 +346,8 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } }) + const showGlobalSearchResults = debouncedQuery.length > 0 + const hasGlobalSearchResults = searchGroups.length > 0 const sessionListHasResults = filteredSessions.length > 0 const runSystemAction = useCallback( @@ -254,7 +373,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on if (!nextStatus) { const pendingStatus = { exit_code: null, - lines: ['Action started, waiting for status...'], + lines: [cc.actionStartedWaiting], name: started.name, pid: started.pid, running: true @@ -264,131 +383,234 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on upsertDesktopActionTask(pendingStatus) } } catch (error) { - setSystemError(errorText(error)) + setSystemError(error instanceof Error ? error.message : String(error)) } finally { void refreshSystem() } }, - [refreshSystem] + [cc, refreshSystem] + ) + + const handleSearchSelect = useCallback( + (result: CommandCenterSearchResult) => { + if (result.kind === 'route') { + onNavigateRoute(result.route) + + return + } + + if (result.kind === 'section') { + setSection(result.section) + setQuery('') + + return + } + + onOpenSession(result.sessionId) + }, + [onNavigateRoute, onOpenSession, setSection] ) return ( - + setQuery(next)} + placeholder={cc.searchPlaceholder} + value={query} + /> + } + onClose={onClose} + > {SECTIONS.map(value => ( setSection(value)} /> ))} -
-
-

- {SECTION_LABELS[section]} -

-

- {SECTION_DESCRIPTIONS[section]} -

-
-
- {section === 'sessions' && sessions.length > 0 && ( - setQuery(next)} - placeholder="Search sessions…" - value={query} - /> - )} - {section === 'usage' && ( - setUsagePeriod(Number(id) as UsagePeriod)} - options={USAGE_PERIODS.map(value => ({ id: String(value), label: `${value}d` }))} - value={String(usagePeriod)} - /> - )} +
+
+

{cc.sections[section]}

+

{cc.sectionDescriptions[section]}

+ {section === 'system' && ( + void refreshSystem()}> + + {systemLoading ? cc.refreshing : cc.refresh} + + )} + {section === 'usage' && ( + void refreshUsage(usagePeriod)}> + + {usageLoading ? cc.refreshing : cc.refresh} + + )}
- {section === 'sessions' ? ( + {showGlobalSearchResults ? ( +
+ {!hasGlobalSearchResults ? ( + {cc.noResults} + ) : ( +
+ {searchGroups.map(group => ( +
+

+ {group.label} +

+ {group.results.map(result => { + if (result.kind === 'session') { + const pinned = pinnedSessionIds.includes(result.sessionId) + + return ( + + +
+ { + event.preventDefault() + event.stopPropagation() + pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId) + }} + title={pinned ? cc.unpinSession : cc.pinSession} + > + {pinned ? ( + + ) : ( + + )} + + { + event.preventDefault() + event.stopPropagation() + void exportSession(result.sessionId, { title: result.title }) + }} + title={cc.exportSession} + > + + + { + event.preventDefault() + event.stopPropagation() + void onDeleteSession(result.sessionId) + }} + title={cc.deleteSession} + > + + +
+
+ ) + } + + return ( + + ) + })} +
+ ))} +
+ )} +
+ ) : section === 'sessions' ? (
{!sessionListHasResults ? ( - + {cc.noSessions} ) : ( -
    +
    {filteredSessions.map(session => { - const pinId = sessionPinId(session) - const pinned = pinnedSessionIds.includes(pinId) + const pinned = pinnedSessionIds.includes(session.id) return ( -
  • + -
    - (pinned ? unpinSession(pinId) : pinSession(pinId))} - title={pinned ? 'Unpin session' : 'Pin session'} - > - {pinned ? ( - - ) : ( - - )} - - void exportSession(session.id, { session, title: sessionTitle(session) })} - title="Export session" - > - - - void onDeleteSession(session.id)} - title="Delete session" - > - - -
    -
  • + (pinned ? unpinSession(session.id) : pinSession(session.id))} + title={pinned ? cc.unpinSession : cc.pinSession} + > + {pinned ? : } + + void exportSession(session.id, { session, title: sessionTitle(session) })} + title={cc.exportSession} + > + + + void onDeleteSession(session.id)} + title={cc.deleteSession} + > + + + ) })} -
+
)}
) : section === 'usage' ? ( void refreshUsage(usagePeriod)} period={usagePeriod} usage={usage} /> ) : ( -
-
+
+ {status ? (
@@ -400,51 +622,49 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500' )} /> - - {status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'} + + {status.gateway_running ? cc.gatewayRunning : cc.gatewayStopped}
-
- Hermes {status.version} · Active sessions {status.active_sessions} +
+ {cc.hermesActiveSessions(status.version, status.active_sessions)}
- - + void runSystemAction('restart')}> + {cc.restartMessaging} + + void runSystemAction('update')}> + {cc.updateHermes} +
{systemAction && ( -
+
{systemAction.name} ·{' '} - {systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'} + {systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
)}
) : ( - +
{cc.loadingStatus}
)} -
+ -
+
- - Recent logs - + {cc.recentLogs} {systemError && ( - + {systemError} )}
-
-                  {logs.length ? logs.join('\n') : 'No logs loaded yet.'}
+                
+                  {logs.length ? logs.join('\n') : cc.noLogs}
                 
-
+
)} @@ -488,12 +708,15 @@ function formatInteger(value: null | number | undefined): string { interface UsagePanelProps { error: string loading: boolean + onPeriodChange: (period: UsagePeriod) => void onRefresh: () => void period: UsagePeriod usage: AnalyticsResponse | null } -function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProps) { +function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { + const { t } = useI18n() + const cc = t.commandCenter const daily = useMemo(() => usage?.daily ?? [], [usage]) const totals = usage?.totals const byModel = usage?.by_model ?? [] @@ -507,82 +730,92 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) }, [daily]) - if (!totals) { - return ( -
- {loading ? ( - - ) : ( - - Retry - - } - description={`No token, cost, or skill activity recorded in the last ${period} days.`} - title="No usage yet" - /> - )} -
- ) - } - return ( -
- {error && ( - - - {error} - - )} - -
- - - - 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} - label="Est. cost" - value={formatCost(totals.total_estimated_cost)} - /> -
- -
-
- - Daily tokens - - - - input - - - output - - +
+ +
+ {USAGE_PERIODS.map(value => ( + + ))}
- {daily.length === 0 ? ( -
- No daily activity. -
- ) : ( - <> -
- {daily.map(entry => { - const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) - const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + {error && ( + + + {error} + + )} + - return ( - -
+ + {totals ? ( +
+ + + + 0 ? cc.actualCost(formatCost(totals.total_actual_cost)) : undefined} + label={cc.statCost} + value={formatCost(totals.total_estimated_cost)} + /> +
+ ) : loading ? ( +
{cc.loadingUsage}
+ ) : ( +
+ {cc.noUsage(period)}{' '} + +
+ )} +
+ +
+ +
+ {cc.dailyTokens} + + + {cc.input} + + + {cc.output} + + +
+ {daily.length === 0 ? ( +
{cc.noDailyActivity}
+ ) : ( + <> +
+ {daily.map(entry => { + const total = (entry.input_tokens || 0) + (entry.output_tokens || 0) + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + + return ( +
0 ? 1 : 0) }} />
0 ? 1 : 0) }} />
- - ) - })} -
-
- {daily[0]?.day} - {daily[daily.length - 1]?.day} -
- - )} -
+ ) + })} +
+
+ {daily[0]?.day} + {daily[daily.length - 1]?.day} +
+ + )} + -
- ({ - key: entry.model, - label: entry.model, - value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} · ${formatCost(entry.estimated_cost)}` - }))} - title="Top models" - /> - ({ - key: entry.skill, - label: entry.skill, - value: `${entry.total_count.toLocaleString()} actions` - }))} - title="Top skills" - /> + +
+
+
+ {cc.topModels} +
+ {byModel.length === 0 ? ( +
{cc.noModelUsage}
+ ) : ( +
    + {byModel.slice(0, 6).map(entry => ( +
  • + {entry.model} + + {formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '} + {formatCost(entry.estimated_cost)} + +
  • + ))} +
+ )} +
+ +
+
+ {cc.topSkills} +
+ {topSkills.length === 0 ? ( +
{cc.noSkillActivity}
+ ) : ( +
    + {topSkills.slice(0, 6).map(entry => ( +
  • + {entry.skill} + + {cc.actions(entry.total_count.toLocaleString())} + +
  • + ))} +
+ )} +
+
+
) } -function UsageList({ - emptyLabel, - rows, - title -}: { - emptyLabel: string - rows: Array<{ key: string; label: string; value: string }> - title: string -}) { - return ( -
-
- {title} -
- {rows.length === 0 ? ( -
- {emptyLabel} -
- ) : ( -
    - {rows.map(row => ( -
  • - {row.label} - {row.value} -
  • - ))} -
- )} -
- ) -} - function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) { return (
-
{label}
-
{value}
- {hint &&
{hint}
} +
{label}
+
{value}
+ {hint &&
{hint}
}
) } diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx index 9e576c9ea736..2993a1c74118 100644 --- a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx +++ b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx @@ -3,6 +3,7 @@ import type * as React from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' interface CronJobActions { @@ -32,12 +33,15 @@ export function CronJobActionsMenu({ sideOffset = 6, title }: CronJobActionsMenuProps) { + const { t } = useI18n() + const c = t.cron + return ( {children} @@ -49,7 +53,7 @@ export function CronJobActionsMenu({ }} > - {isPaused ? 'Resume' : 'Pause'} + {isPaused ? c.resumeTitle : c.pauseTitle} - Trigger now + {c.triggerNow} - Edit + {c.edit} - Delete + {t.common.delete} @@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 8202dd3d827b..dcf852e6aa5d 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -1,10 +1,9 @@ +import type * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { PageLoader } from '@/components/page-loader' -import { Badge, type BadgeProps } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, @@ -14,7 +13,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { SearchField } from '@/components/ui/search-field' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { @@ -27,78 +25,49 @@ import { triggerCronJob, updateCronJob } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { AlertTriangle, Clock } from '@/lib/icons' +import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayView } from '../overlays/overlay-view' +import { PageSearchShell } from '../page-search-shell' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' const DEFAULT_DELIVER = 'local' -const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ - { label: 'This desktop', value: 'local' }, - { label: 'Telegram', value: 'telegram' }, - { label: 'Discord', value: 'discord' }, - { label: 'Slack', value: 'slack' }, - { label: 'Email', value: 'email' } -] +const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email'] const SCHEDULE_OPTIONS: ReadonlyArray = [ - { - expr: '0 9 * * *', - hint: 'Every day at 9:00 AM', - label: 'Daily', - value: 'daily' - }, - { - expr: '0 9 * * 1-5', - hint: 'Monday through Friday at 9:00 AM', - label: 'Weekdays', - value: 'weekdays' - }, - { - expr: '0 9 * * 1', - hint: 'Every Monday at 9:00 AM', - label: 'Weekly', - value: 'weekly' - }, - { - expr: '0 9 1 * *', - hint: 'The first day of each month at 9:00 AM', - label: 'Monthly', - value: 'monthly' - }, - { - expr: '0 * * * *', - hint: 'At the top of every hour', - label: 'Hourly', - value: 'hourly' - }, - { - expr: '*/15 * * * *', - hint: 'Every 15 minutes', - label: 'Every 15 minutes', - value: 'every-15-minutes' - }, - { - hint: 'Cron syntax or natural language', - label: 'Custom', - value: 'custom' - } + { expr: '0 9 * * *', value: 'daily' }, + { expr: '0 9 * * 1-5', value: 'weekdays' }, + { expr: '0 9 * * 1', value: 'weekly' }, + { expr: '0 9 1 * *', value: 'monthly' }, + { expr: '0 * * * *', value: 'hourly' }, + { expr: '*/15 * * * *', value: 'every-15-minutes' }, + { value: 'custom' } ] -const STATE_VARIANT: Record = { - enabled: 'default', - scheduled: 'default', - running: 'default', +const STATE_TONE: Record = { + enabled: 'good', + scheduled: 'good', + running: 'good', paused: 'warn', disabled: 'muted', - error: 'destructive', + error: 'bad', completed: 'muted' } +const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + const asText = (value: unknown): string => (typeof value === 'string' ? value : '') const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) @@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] { return parts.length === 5 ? parts : null } -function dayName(value: string): string { - const names: Record = { - '0': 'Sunday', - '1': 'Monday', - '2': 'Tuesday', - '3': 'Wednesday', - '4': 'Thursday', - '5': 'Friday', - '6': 'Saturday', - '7': 'Sunday' - } - - return names[value] ?? `day ${value}` +function dayName(value: string, c: Translations['cron']): string { + return c.days[value] ?? c.dayFallback(value) } function formatCronTime(minute: string, hour: string): string { @@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption { return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] } -function scheduleSummary(option: ScheduleOption, expr: string): string { +function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string { const parts = cronParts(expr) if (!parts) { - return option.hint + return c.scheduleHints[option.value] ?? '' } const [minute, hour, dayOfMonth, , dayOfWeek] = parts if (option.value === 'daily') { - return `Every day at ${formatCronTime(minute, hour)}` + return c.everyDayAt(formatCronTime(minute, hour)) } if (option.value === 'weekdays') { - return `Weekdays at ${formatCronTime(minute, hour)}` + return c.weekdaysAt(formatCronTime(minute, hour)) } if (option.value === 'weekly') { - return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}` + return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour)) } if (option.value === 'monthly') { - return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}` + return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour)) } if (option.value === 'hourly') { - return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}` + return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0')) } - return option.hint + return c.scheduleHints[option.value] ?? '' } function formatTime(iso?: null | string): string { @@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean { ) } -interface CronViewProps { +interface CronViewProps extends React.ComponentProps<'section'> { onClose: () => void + setStatusbarItemGroup?: SetStatusbarItemGroup } -export function CronView({ onClose }: CronViewProps) { +export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) { + const { t } = useI18n() + const c = t.cron const [jobs, setJobs] = useState(null) const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) const [busyJobId, setBusyJobId] = useState(null) const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(null) + const [deleting, setDeleting] = useState(false) const refresh = useCallback(async () => { + setRefreshing(true) + try { const result = await getCronJobs() setJobs(result) } catch (err) { - notifyError(err, 'Failed to load cron jobs') + notifyError(err, c.failedLoad) + } finally { + setRefreshing(false) } - }, []) + }, [c]) useRefreshHotkey(refresh) @@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) { setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) notify({ kind: 'success', - title: isPaused ? 'Cron resumed' : 'Cron paused', + title: isPaused ? c.resumed : c.paused, message: truncate(jobTitle(job), 60) }) } catch (err) { - notifyError(err, 'Failed to update cron job') + notifyError(err, c.failedUpdate) } finally { setBusyJobId(null) } @@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) { try { const updated = await triggerCronJob(job.id) setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) - notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) }) + notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) }) } catch (err) { - notifyError(err, 'Failed to trigger cron job') + notifyError(err, c.failedTrigger) } finally { setBusyJobId(null) } } + async function handleConfirmDelete() { + if (!pendingDelete) { + return + } + + setDeleting(true) + + try { + await deleteCronJob(pendingDelete.id) + setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) + notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) }) + setPendingDelete(null) + } catch (err) { + notifyError(err, c.failedDelete) + } finally { + setDeleting(false) + } + } + async function handleEditorSave(values: EditorValues) { if (editor.mode === 'create') { const created = await createCronJob({ @@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) { }) setJobs(current => (current ? [...current, created] : [created])) - notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) }) + notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { const updated = await updateCronJob(editor.job.id, { prompt: values.prompt, @@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) { }) setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current)) - notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) }) + notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) }) } setEditor({ mode: 'closed' }) } return ( - -
- {totalCount > 0 && ( -
- -
- )} + + void refresh()} + size="icon-xs" + title={refreshing ? c.refreshing : c.refresh} + type="button" + variant="ghost" + > + + + } + searchValue={query} + > {!jobs ? ( - + ) : visibleJobs.length === 0 ? ( // Empty state owns the primary "create" CTA — we used to also have // one in the filters bar but it was redundant. Only show the button // when there are zero jobs total; the search-empty case ("No // matches") just asks the user to broaden their query. setEditor({ mode: 'create' }) : undefined} - title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'} + title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} /> ) : ( -
+
{/* Inline header replaces the old top-bar "New cron" button. We still need a single, always-visible affordance to add a job when the list is non-empty (rows themselves only expose edit/pause/trigger/delete). */}
- {enabledCount}/{totalCount} active + {c.active(enabledCount, totalCount)}
-
+
{visibleJobs.map(job => ( setPendingDelete(job)} @@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
)} -
- setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> - - This will remove{' '} - {truncate(jobTitle(pendingDelete), 60)} permanently. - It will stop firing immediately. - - ) : null - } - destructive - doneLabel="Deleted" - onClose={() => setPendingDelete(null)} - onConfirm={async () => { - if (!pendingDelete) { - return - } - - await deleteCronJob(pendingDelete.id) - setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) - notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' }) - }} - open={pendingDelete !== null} - title="Delete cron job?" - /> + !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> + + + {c.deleteTitle} + + {pendingDelete ? ( + <> + {c.deleteDescPrefix} + {truncate(jobTitle(pendingDelete), 60)} + {c.deleteDescSuffix} + + ) : null} + + + + + + + + + ) } function CronJobRow({ busy, + c, job, onDelete, onEdit, @@ -501,6 +491,7 @@ function CronJobRow({ onTrigger }: { busy: boolean + c: Translations['cron'] job: CronJob onDelete: () => void onEdit: () => void @@ -516,19 +507,15 @@ function CronJobRow({ return (
{job.last_error && (

@@ -569,6 +560,16 @@ function CronJobRow({ ) } +function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { + return ( + + {children} + + ) +} + function EmptyState({ actionLabel, description, @@ -605,6 +606,8 @@ function CronEditorDialog({ onClose: () => void onSave: (values: EditorValues) => Promise }) { + const { t } = useI18n() + const c = t.cron const open = editor.mode !== 'closed' const isEdit = editor.mode === 'edit' const initial = isEdit ? editor.job : null @@ -647,7 +650,7 @@ function CronEditorDialog({ } } - const scheduleHint = scheduleSummary(selectedScheduleOption, schedule) + const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c) async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -655,7 +658,7 @@ function CronEditorDialog({ const trimmedSchedule = schedule.trim() if (!trimmedPrompt || !trimmedSchedule) { - setError('Prompt and schedule are required.') + setError(c.promptScheduleRequired) return } @@ -671,7 +674,7 @@ function CronEditorDialog({ schedule: trimmedSchedule }) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save cron job') + setError(err instanceof Error ? err.message : c.failedSave) } finally { setSaving(false) } @@ -681,60 +684,56 @@ function CronEditorDialog({

!value && !saving && onClose()} open={open}> - {isEdit ? 'Edit cron job' : 'New cron job'} - - {isEdit - ? 'Update the schedule, prompt, or delivery target. Changes apply on next run.' - : 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'} - + {isEdit ? c.editTitle : c.createTitle} + {isEdit ? c.editDesc : c.createDesc}
- + setName(event.target.value)} - placeholder="Morning briefing" + placeholder={c.namePlaceholder} value={name} /> - +
- {col.header(filter)} + {col.header(filter, t.artifacts)}