From 2e0c9083db8425d6e087ba0af6024406aa513d05 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 3 Jun 2026 11:22:06 -0700 Subject: [PATCH 01/14] feat(middleware): add adaptive execution intercepts Signed-off-by: Bryan Bednarski --- agent/agent_runtime_helpers.py | 196 ++++++++----- 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, 2013 insertions(+), 149 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 09eccef5f3..2c22334505 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) + 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): - return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, function_args)) + 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": - 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: + 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 c52b9b72d7..b66a6615ae 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 fc3667edb5..f908aedb80 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 0000000000..b385b87eb2 --- /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 0000000000..938bffcf17 --- /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 fd449fc27a..d5cb7e8fe0 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 c3a9c98c60..9d04ada2d7 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 f1a2c3b7dc..b537669621 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 25078a21c6..cd1587fdab 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 d0d0293439..c0b3619896 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 baf7f92fcf..6bff7b6d87 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 7c18493fd3..c4970bf241 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 e9e7011dd1..aef73c665f 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 f1a5b510cb..91e7103aac 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 5abe45674dc7eaf72190d97785738ea2ea8b607b Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Sat, 6 Jun 2026 09:26:18 -0700 Subject: [PATCH 02/14] fix(middleware): preserve translated downstream failures Track successful next_call completion separately from invocation so execution middleware that catches and translates a downstream provider/tool failure does not accidentally convert that failure into a successful None result. Also avoid wrapping BaseException from downstream execution, and document the execution middleware error semantics. Tests cover: - pre-next_call middleware failures fail open to the remaining chain - post-next_call middleware failures preserve the downstream result - translated downstream failures propagate instead of returning None - downstream BaseException is not wrapped Signed-off-by: Bryan Bednarski --- docs/middleware/README.md | 9 +++++ hermes_cli/middleware.py | 10 +++-- tests/hermes_cli/test_plugins.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs/middleware/README.md b/docs/middleware/README.md index b385b87eb2..4a5c06f8cb 100644 --- a/docs/middleware/README.md +++ b/docs/middleware/README.md @@ -244,6 +244,15 @@ For NeMo Relay adaptive execution middleware, see patches. - Execution middleware should call `next_call(...)` exactly once unless it is intentionally short-circuiting execution. +- If execution middleware raises before calling `next_call(...)`, Hermes treats + that as middleware failure and continues with the remaining middleware chain + and base execution. +- If execution middleware calls `next_call(...)` successfully and then raises + during post-processing, Hermes preserves the downstream result and does not + run the provider or tool a second time. +- If downstream provider or tool execution fails, middleware may let that error + propagate or translate it deliberately. Hermes does not convert downstream + failure into a successful `None` result. - Tool request middleware runs before approvals. If it mutates file paths, commands, URLs, or arguments, the mutated values are what guardrails and approvals evaluate. diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index 938bffcf17..277368dffb 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -237,15 +237,17 @@ def _run_execution_chain( callback = callbacks[index] next_called = False + next_succeeded = False next_result: Any = None def next_call(next_payload: Any = None) -> Any: - nonlocal next_called, next_result + nonlocal next_called, next_succeeded, next_result next_called = True try: next_result = call_at(index + 1, payload if next_payload is None else next_payload) + next_succeeded = True return next_result - except BaseException as exc: + except Exception as exc: raise _DownstreamExecutionError(exc) from exc call_kwargs = middleware_payload(**kwargs) @@ -262,8 +264,10 @@ def _run_execution_chain( getattr(callback, "__name__", repr(callback)), exc, ) - if next_called: + if next_succeeded: return next_result + if next_called: + raise return call_at(index + 1, payload) return call_at(0, kwargs[payload_key]) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 6bff7b6d87..ddd1dab56e 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -207,6 +207,71 @@ class TestPluginDiscovery: assert result == "terminal-result" assert calls == [{"command": "printf ok"}] + def test_execution_middleware_pre_next_call_error_fails_open_to_remaining_chain(self, monkeypatch): + calls = [] + + def failing_middleware(**kwargs): + calls.append("failing") + raise RuntimeError("middleware setup failed") + + def downstream_middleware(**kwargs): + calls.append("downstream") + return kwargs["next_call"]({**kwargs["args"], "rewritten": True}) + + manager = types.SimpleNamespace(_middleware={"tool_execution": [failing_middleware, downstream_middleware]}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + def terminal(args): + calls.append(("terminal", args)) + return args + + result = run_tool_execution_middleware("terminal", {"command": "printf ok"}, terminal) + + assert result == {"command": "printf ok", "rewritten": True} + assert calls == ["failing", "downstream", ("terminal", {"command": "printf ok", "rewritten": True})] + + def test_execution_middleware_translated_downstream_failure_is_not_masked(self, monkeypatch): + calls = [] + + def middleware(**kwargs): + try: + return kwargs["next_call"](kwargs["args"]) + except Exception as exc: + raise RuntimeError(f"translated downstream failure: {exc}") from exc + + 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("terminal failed") + + with pytest.raises(RuntimeError, match="translated downstream failure: terminal failed"): + run_tool_execution_middleware("terminal", {"command": "false"}, terminal) + + assert calls == [{"command": "false"}] + + def test_execution_middleware_downstream_base_exception_is_not_wrapped(self, monkeypatch): + calls = [] + + def middleware(**kwargs): + try: + return kwargs["next_call"](kwargs["args"]) + except Exception as exc: + raise RuntimeError(f"middleware should not catch base exception: {exc}") from exc + + manager = types.SimpleNamespace(_middleware={"tool_execution": [middleware]}) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + def terminal(args): + calls.append(args) + raise KeyboardInterrupt() + + with pytest.raises(KeyboardInterrupt): + run_tool_execution_middleware("terminal", {"command": "interrupt"}, terminal) + + assert calls == [{"command": "interrupt"}] + def test_discover_project_plugins(self, tmp_path, monkeypatch): """Plugins in ./.hermes/plugins/ are discovered.""" project_dir = tmp_path / "project" From 8b23b2bc0130d3c74b5893b3f5cd21857c0a58d7 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:20:40 -0600 Subject: [PATCH 03/14] docs: align runtime footer field docs --- hermes_cli/tips.py | 3 +-- website/docs/reference/slash-commands.md | 4 ++-- website/docs/user-guide/configuration.md | 4 ++-- .../current/user-guide/configuration.md | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 3a17055e7b..610128c6fb 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -345,7 +345,7 @@ TIPS = [ '/copy [N] copies the last assistant response to your clipboard, or the Nth-from-last with a number.', '/redraw forces a full UI repaint, fixing terminal drift after tmux resize or mouse selection artifacts.', '/agents (alias /tasks) shows active agents and running background tasks across the current session.', - '/footer toggles the gateway footer on final replies showing model, tool counts, and turn timing.', + '/footer toggles the gateway footer on final replies showing model, context %, and cwd.', '/busy queue|steer|interrupt controls what pressing Enter does while Hermes is working.', '/topic in Telegram DMs enables user-managed multi-session topic mode — /topic restores past sessions inline.', '/approve session|always runs a pending dangerous command with your chosen trust scope; /deny rejects it.', @@ -484,4 +484,3 @@ def get_random_tip(exclude_recent: int = 0) -> str: deduplication across sessions. """ return random.choice(TIPS) - diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index d90e5227c5..737bc3a2a7 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -75,7 +75,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/statusbar` (alias: `/sb`) | Toggle the context/model status bar on or off | | `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | -| `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, tool counts, timing). | +| `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, context %, and cwd). | | `/busy [queue\|steer\|interrupt\|status]` | CLI-only: control what pressing Enter does while Hermes is working — queue the new message, steer mid-turn, or interrupt immediately. | | `/indicator [kaomoji\|emoji\|unicode\|ascii]` | CLI-only: pick the TUI busy-indicator style. | @@ -219,7 +219,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/queue ` (alias: `/q`) | Queue a prompt for the next turn without interrupting the current one. | | `/steer ` | Inject a message after the next tool call without interrupting — the model picks it up on its next iteration rather than as a new turn. | | `/goal ` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. A judge model checks after each turn; if not done, Hermes auto-continues until it is, you pause/clear it, or the turn budget (default 20) is hit. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Safe to run mid-agent for status/pause/clear; setting a new goal requires `/stop` first. See [Persistent Goals](/user-guide/features/goals). | -| `/footer [on\|off\|status]` | Toggle the runtime-metadata footer on final replies (shows model, tool counts, timing). | +| `/footer [on\|off\|status]` | Toggle the runtime-metadata footer on final replies (shows model, context %, and cwd). | | `/curator [status\|run\|pin\|archive]` | Background skill maintenance controls. | | `/kanban ` | Drive the multi-profile, multi-project collaboration board from chat — identical argument surface to the CLI. Bypasses the running-agent guard, so `/kanban unblock t_abc`, `/kanban comment t_abc "…"`, `/kanban list --mine`, `/kanban boards switch `, etc. work mid-turn. `/kanban create …` auto-subscribes the originating chat to the new task's terminal events. See [Kanban slash command](/user-guide/features/kanban#kanban-slash-command). | | `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 11ed264a8c..b5eccbbefd 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1255,13 +1255,13 @@ In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in mess ### Runtime-metadata footer (gateway only) -When `display.runtime_footer.enabled: true`, Hermes appends a small runtime-context footer to the **final** message of each gateway turn — same info the CLI shows in its status bar (model, context %, cwd, session duration, tokens, cost). Off by default; opt in per-gateway if your team wants every reply to include the provenance. +When `display.runtime_footer.enabled: true`, Hermes appends a small runtime-context footer to the **final** message of each gateway turn. The current footer can show the model, context-window percentage, and current working directory. Off by default; opt in per-gateway if your team wants every reply to include this provenance. ```yaml display: runtime_footer: enabled: true - fields: ["model", "context_pct", "cwd"] # any of: model, context_pct, cwd, duration, tokens, cost + fields: ["model", "context_pct", "cwd"] # supported fields: model, context_pct, cwd ``` The `/footer` slash command toggles this at runtime in any session. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index f8a0f87b40..4232161d94 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1176,13 +1176,13 @@ display: ### 运行时元数据页脚(仅限 gateway) -当 `display.runtime_footer.enabled: true` 时,Hermes 在每个 gateway 轮次的**最终**消息中附加一个小型运行时上下文页脚 —— 与 CLI 在其状态栏中显示的相同信息(模型、上下文 %、cwd、会话时长、token、成本)。默认关闭;如果您的团队希望每个回复都包含来源信息,请按 gateway 选择加入。 +当 `display.runtime_footer.enabled: true` 时,Hermes 在每个 gateway 轮次的**最终**消息中附加一个小型运行时上下文页脚。目前页脚可显示模型、上下文窗口百分比和当前工作目录。默认关闭;如果您的团队希望每个回复都包含这些来源信息,请按 gateway 选择加入。 ```yaml display: runtime_footer: enabled: true - fields: ["model", "context_pct", "cwd"] # 任意:model、context_pct、cwd、duration、tokens、cost + fields: ["model", "context_pct", "cwd"] # 支持字段:model、context_pct、cwd ``` `/footer` 斜杠命令在任何会话中运行时切换此功能。 @@ -1655,4 +1655,4 @@ Hermes 使用两种不同的上下文范围: # 在 ~/.hermes/.env 或 ~/.hermes/config.yaml 中: MESSAGING_CWD=/home/myuser/projects # Gateway 会话 TERMINAL_CWD=/workspace # 所有终端会话 -``` \ No newline at end of file +``` From c4c5548eb4800068ff3dd1ac8361d3b4ee23a06b Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:07:25 +0530 Subject: [PATCH 04/14] fix(middleware): single-use next_call guard + deepcopy-safe request copies Address the two non-blocking follow-ups from review: - next_call is now single-use per middleware frame. A second invocation raises instead of silently re-running the downstream provider/tool, so the terminal call cannot execute twice via the chain. The error surfaces through the existing handler, which preserves the first downstream result. - Request-middleware payload copies go through _safe_copy(), which falls back to a shallow dict copy when deepcopy() fails on a non-deepcopyable member (clients, callbacks, file handles) instead of aborting the pass. Adds regression coverage for both: double next_call() keeps the terminal single-run, and a non-deepcopyable (threading.Lock) request payload still runs middleware via the shallow fallback. --- hermes_cli/middleware.py | 41 +++++++++++++++++++++++---- tests/hermes_cli/test_plugins.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index 277368dffb..8795952a2b 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -55,6 +55,25 @@ def middleware_payload(**kwargs: Any) -> Dict[str, Any]: return kwargs +def _safe_copy(payload: Any) -> Any: + """Deep-copy a request payload, tolerating non-deepcopyable members. + + Request payloads are normally plain JSON-shaped dicts, but an LLM request + can occasionally carry non-deepcopyable objects (clients, callbacks, file + handles). A hard ``deepcopy`` failure there would otherwise abort the whole + request-middleware pass. Fall back to a shallow ``dict`` copy so middleware + still runs and the original nested objects are shared by reference rather + than corrupting the live payload. + """ + try: + return deepcopy(payload) + except Exception as exc: # pragma: no cover - exercised via fallback test + logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc) + if isinstance(payload, dict): + return dict(payload) + return payload + + def apply_llm_request_middleware( request: Dict[str, Any], **context: Any, @@ -72,8 +91,8 @@ def apply_llm_request_middleware( trace=[], ) - original_request = deepcopy(request) - current_request = deepcopy(original_request) + original_request = _safe_copy(request) + current_request = _safe_copy(original_request) trace: List[Dict[str, Any]] = [] for result in _invoke_middleware( @@ -87,7 +106,7 @@ def apply_llm_request_middleware( next_request = result.get("request") if not isinstance(next_request, dict): continue - current_request = deepcopy(next_request) + current_request = _safe_copy(next_request) trace.append(_trace_entry(result)) return RequestMiddlewareResult( @@ -116,8 +135,8 @@ def apply_tool_request_middleware( trace=[], ) - original_args = deepcopy(args) - current_args = deepcopy(original_args) + original_args = _safe_copy(args) + current_args = _safe_copy(original_args) trace: List[Dict[str, Any]] = [] for result in _invoke_middleware( @@ -132,7 +151,7 @@ def apply_tool_request_middleware( next_args = result.get("args") if not isinstance(next_args, dict): continue - current_args = deepcopy(next_args) + current_args = _safe_copy(next_args) trace.append(_trace_entry(result)) return RequestMiddlewareResult( @@ -242,6 +261,16 @@ def _run_execution_chain( def next_call(next_payload: Any = None) -> Any: nonlocal next_called, next_succeeded, next_result + # ``next_call`` is single-use per middleware frame. Calling it more + # than once would re-run the downstream provider/tool, so a second + # invocation is a contract violation rather than a retry. Surface it + # instead of silently executing the terminal call twice. + if next_called: + raise RuntimeError( + f"Middleware '{kind}' callback " + f"{getattr(callback, '__name__', repr(callback))} called " + "next_call() more than once; downstream execution is single-use" + ) next_called = True try: next_result = call_at(index + 1, payload if next_payload is None else next_payload) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index ddd1dab56e..bb889450d0 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -272,6 +272,54 @@ class TestPluginDiscovery: assert calls == [{"command": "interrupt"}] + def test_execution_middleware_double_next_call_does_not_run_terminal_twice(self, monkeypatch): + calls = [] + + def middleware(**kwargs): + first = kwargs["next_call"](kwargs["args"]) + # Deliberate misuse: a second next_call() must not re-run the + # downstream tool. The chain surfaces it as an error and preserves + # the first (successful) downstream result. + kwargs["next_call"](kwargs["args"]) + return first + + 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_request_middleware_tolerates_non_deepcopyable_payload(self, monkeypatch): + import threading + + recorded = {} + + def middleware(**kwargs): + recorded["args"] = kwargs["args"] + return None + + manager = types.SimpleNamespace( + _middleware={"tool_request": [middleware]}, + invoke_middleware=lambda kind, **kwargs: [middleware(**kwargs)], + ) + monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager) + + # threading.Lock is not deepcopyable; a hard deepcopy would raise. + args = {"command": "noop", "lock": threading.Lock()} + result = apply_tool_request_middleware("terminal", args) + + # Middleware ran (payload was copied via the shallow fallback) and the + # non-deepcopyable member is shared by reference rather than aborting. + assert recorded["args"]["command"] == "noop" + assert result.payload["command"] == "noop" + assert result.payload["lock"] is args["lock"] + def test_discover_project_plugins(self, tmp_path, monkeypatch): """Plugins in ./.hermes/plugins/ are discovered.""" project_dir = tmp_path / "project" From 2820d87ea56b9418b8289b419ff6e0a05e47c9cb Mon Sep 17 00:00:00 2001 From: The Garden Date: Sat, 6 Jun 2026 11:31:12 -0500 Subject: [PATCH 05/14] fix(cli): tolerate stale `dashboard --tui` from old desktop shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older Hermes desktop app shells (<= 0.15.x) spawn the backend as `hermes dashboard --no-open --tui --host ... --port ...`. The --tui flag was removed from the dashboard subcommand in cae6b5486 (embedded chat is always on now). When a user's CLI updates past that commit but their desktop app binary has not, argparse hard-errored with 'unrecognized arguments: --tui' and exit(2). The backend died before becoming ready and the desktop GUI showed only 'Hermes couldn't start' with no actionable cause — a confusing brick for anyone whose app and CLI versions drift apart across an update. Add a hidden, deprecated, accepted-and-ignored --tui flag to the dashboard subparser so an old app shell + new CLI degrades gracefully. Hidden from --help via argparse.SUPPRESS so we don't re-advertise a removed feature. Safe to delete once the floor app version is well past 0.16.0. Adds tests/hermes_cli/test_dashboard_tui_backcompat.py pinning: the flag parses without error, stays hidden from --help, and the modern (no --tui) invocation is unaffected. --- hermes_cli/main.py | 15 ++++ .../test_dashboard_tui_backcompat.py | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/hermes_cli/test_dashboard_tui_backcompat.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4c0c733449..5804322989 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -15738,6 +15738,21 @@ Examples: action="store_true", help="List running hermes dashboard processes and exit", ) + # Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the + # backend as `hermes dashboard --no-open --tui --host ... --port ...`. The + # `--tui` flag was removed from this subcommand in cae6b5486 (embedded chat is + # always on now). When a user's CLI updates past that commit but their desktop + # app binary has not, argparse used to hard-error with "unrecognized arguments: + # --tui" and exit(2) — the backend died before becoming ready and the GUI just + # showed "Hermes couldn't start" with no actionable cause. Accept and silently + # ignore the flag so an old app + new CLI degrades gracefully instead of + # bricking. Hidden from --help; safe to delete once the floor app version is + # well past 0.16.0. + dashboard_parser.add_argument( + "--tui", + action="store_true", + help=argparse.SUPPRESS, + ) dashboard_parser.set_defaults(func=cmd_dashboard) # `hermes dashboard register` — register a self-hosted dashboard OAuth diff --git a/tests/hermes_cli/test_dashboard_tui_backcompat.py b/tests/hermes_cli/test_dashboard_tui_backcompat.py new file mode 100644 index 0000000000..677b1f4c86 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_tui_backcompat.py @@ -0,0 +1,82 @@ +"""Regression test: `hermes dashboard --tui` must not hard-crash. + +Older Hermes desktop app shells (<= 0.15.x) spawn the backend as:: + + hermes dashboard --no-open --tui --host 127.0.0.1 --port + +The ``--tui`` flag was removed from the ``dashboard`` subcommand in cae6b5486 +(embedded chat is always on now). When a user's CLI updates past that commit +but their desktop app binary has not, argparse used to reject the unknown flag +with ``error: unrecognized arguments: --tui`` and ``exit(2)`` — the backend +died before it became ready and the desktop GUI showed only "Hermes couldn't +start" with no actionable cause. + +The fix adds a hidden, deprecated, accepted-and-ignored ``--tui`` flag to the +dashboard subparser so an old app shell + new CLI degrades gracefully instead +of bricking. These tests pin that contract. +""" + +import os +import subprocess +import sys + +import pytest + +REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) +) + + +def _run_cli(args, timeout=60): + """Invoke the real hermes_cli.main parser in a subprocess. + + Uses ``--status`` so the dashboard command exits immediately after parsing + (it scans the process table and returns) instead of starting a server. + Returns the CompletedProcess. + """ + env = dict(os.environ) + env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") + return subprocess.run( + [sys.executable, "-m", "hermes_cli.main", *args], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def test_dashboard_tui_flag_is_accepted_not_rejected(): + """The exact argv an old desktop app sends must parse without argparse error.""" + result = _run_cli( + ["dashboard", "--no-open", "--tui", "--host", "127.0.0.1", + "--port", "39997", "--status"] + ) + combined = (result.stdout or "") + (result.stderr or "") + # The pre-fix failure signature. + assert "unrecognized arguments" not in combined, combined + assert "--tui" not in (result.stderr or ""), result.stderr + # argparse usage errors exit 2; the parse itself must not be that error. + assert result.returncode != 2, combined + + +def test_dashboard_tui_flag_is_hidden_from_help(): + """The deprecated shim must not re-advertise a removed feature in --help.""" + result = _run_cli(["dashboard", "--help"]) + combined = (result.stdout or "") + (result.stderr or "") + assert result.returncode == 0, combined + assert "--tui" not in combined, ( + "dashboard --tui is a deprecated back-compat shim and must stay " + "hidden via argparse.SUPPRESS:\n" + combined + ) + + +def test_dashboard_without_tui_still_parses(): + """Sanity: the modern (no --tui) invocation is unaffected by the shim.""" + result = _run_cli( + ["dashboard", "--no-open", "--host", "127.0.0.1", + "--port", "39996", "--status"] + ) + combined = (result.stdout or "") + (result.stderr or "") + assert "unrecognized arguments" not in combined, combined + assert result.returncode != 2, combined From abbf050241317100ed6c106d7caf67874868e2af Mon Sep 17 00:00:00 2001 From: The Garden Date: Sat, 6 Jun 2026 12:14:54 -0500 Subject: [PATCH 06/14] fix(desktop): cap desktop.log size to prevent unbounded growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktop.log is an append-only forensic log written via appendFileSync / fs.promises.appendFile with no rotation. When the backend enters a boot loop — e.g. the version-skew crash where an old app shell spawns `dashboard --tui`, argparse exits(2) instantly, and the renderer keeps retrying — the full bootstrap transcript plus repeated stack traces are appended on every attempt. In the wild this drove a single desktop.log to ~326 GB, exhausting the disk and breaking `hermes update`/install (git index.lock, venv rebuild, and npm all need scratch space). Rotate to a single .1 sibling once the live file crosses a 10 MB cap, so total on-disk usage stays ~2x the cap while preserving the most recent transcript for diagnostics. The size check runs before each append in both the sync (shutdown) and async (steady-state) flush paths. All filesystem ops stay inside try/catch so logging can never block startup/shutdown or crash the shell — consistent with the existing append error handling. Paired with the CLI --tui back-compat guard in this PR: the guard stops the crash loop from starting, and this stops a crash loop (from any cause) from ever filling the disk. --- apps/desktop/electron/main.cjs | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3ea31b2720..2b906d5986 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -247,6 +247,16 @@ const DEFAULT_UPDATE_BRANCH = 'main' const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log') const DESKTOP_LOG_FLUSH_MS = 120 const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 +// Cap desktop.log on disk. It is an append-only forensic log with no other +// rotation, so a boot loop (e.g. a version-skew crash where the backend exits +// instantly and the renderer keeps hitting Retry) appends the full bootstrap +// transcript on every attempt and can grow without bound — we have seen this +// file reach hundreds of GB and exhaust the disk, which then breaks update and +// install (no room for git/venv/npm temp files). Rotate to a single .1 sibling +// when the live file crosses the cap, so total on-disk usage stays ~2x the cap +// while preserving the most recent transcript for diagnostics. +const DESKTOP_LOG_MAX_BYTES = 10 * 1024 * 1024 +const DESKTOP_LOG_ROTATED_PATH = `${DESKTOP_LOG_PATH}.1` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -534,6 +544,30 @@ let bootProgressState = { timestamp: Date.now() } +function rotateDesktopLogIfNeededSync() { + try { + const { size } = fs.statSync(DESKTOP_LOG_PATH) + if (size < DESKTOP_LOG_MAX_BYTES) return + fs.rmSync(DESKTOP_LOG_ROTATED_PATH, { force: true }) + fs.renameSync(DESKTOP_LOG_PATH, DESKTOP_LOG_ROTATED_PATH) + } catch { + // No file yet (ENOENT) or rotation failed — appending will (re)create it. + // Logging must never block app startup/shutdown. + } +} + +async function rotateDesktopLogIfNeededAsync() { + try { + const { size } = await fs.promises.stat(DESKTOP_LOG_PATH) + if (size < DESKTOP_LOG_MAX_BYTES) return + await fs.promises.rm(DESKTOP_LOG_ROTATED_PATH, { force: true }) + await fs.promises.rename(DESKTOP_LOG_PATH, DESKTOP_LOG_ROTATED_PATH) + } catch { + // No file yet (ENOENT) or rotation failed — appending will (re)create it. + // Logging must never crash the desktop shell. + } +} + function flushDesktopLogBufferSync() { if (!desktopLogBuffer) return const chunk = desktopLogBuffer @@ -541,6 +575,7 @@ function flushDesktopLogBufferSync() { try { fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + rotateDesktopLogIfNeededSync() fs.appendFileSync(DESKTOP_LOG_PATH, chunk) } catch { // Logging must never block app startup/shutdown. @@ -555,6 +590,7 @@ function flushDesktopLogBufferAsync() { desktopLogFlushPromise = desktopLogFlushPromise .then(async () => { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + await rotateDesktopLogIfNeededAsync() await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk) }) .catch(() => { From 146e77684b717e4c136fdf6de835d26c7b28c87b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:27:49 -0500 Subject: [PATCH 07/14] fix(desktop): bound desktop.log via cascade rotation + reclaim oversized logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the single-.1 rotation from the prior commit, which only bounded FUTURE growth: rotating a pre-existing oversized desktop.log just renamed the monster to .1 (no disk reclaimed) and left it stranded until a second rotation cycle that a now-healthy app may never reach. The ~326 GB file that motivated this PR would therefore persist as desktop.log.1 after the user updated. Two changes bring desktop.log in line with the Python-side logs (hermes_logging.py RotatingFileHandler, maxBytes x backupCount): 1. Cascade rotation: live -> .1 -> .2 -> .3, dropping the oldest. Steady-state usage is bounded at ~(backupCount + 1) x cap regardless of loop intensity, instead of the old ~2x with a single backup. 2. Pathological-size discard: a file past 4x the cap is a boot-loop artifact with no diagnostic value — delete it (and any equally poisoned backups) outright instead of relocating the disk-exhaustion problem into a sibling. This is what lets an updated app self-heal a disk a stale build filled, on the very next launch, rather than one rotation cycle later. Behavior verified against a real filesystem in a temp dir: under cap -> no rotation; normal overflow -> live becomes .1; repeated overflow keeps exactly backupCount backups (no .4) with total bounded; a pathological live file plus poisoned backups are all reclaimed. node --check passes. Co-authored-by: The Garden --- apps/desktop/electron/main.cjs | 80 +++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2b906d5986..054b4e2245 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -247,16 +247,25 @@ const DEFAULT_UPDATE_BRANCH = 'main' const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log') const DESKTOP_LOG_FLUSH_MS = 120 const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 -// Cap desktop.log on disk. It is an append-only forensic log with no other -// rotation, so a boot loop (e.g. a version-skew crash where the backend exits -// instantly and the renderer keeps hitting Retry) appends the full bootstrap -// transcript on every attempt and can grow without bound — we have seen this -// file reach hundreds of GB and exhaust the disk, which then breaks update and -// install (no room for git/venv/npm temp files). Rotate to a single .1 sibling -// when the live file crosses the cap, so total on-disk usage stays ~2x the cap -// while preserving the most recent transcript for diagnostics. +// Bound desktop.log on disk. It is an append-only forensic log, so a boot loop +// (version-skew crash -> backend exits instantly -> renderer keeps hitting +// Retry) appends the full bootstrap transcript every attempt and grows without +// bound — we have seen it reach ~326 GB and exhaust the disk, which then breaks +// update/install (no room for git/venv/npm temp files). +// +// Mirror the Python logs (hermes_logging.py RotatingFileHandler, maxBytes x +// backupCount): cascade live -> .1 -> .2 -> .3, drop the oldest. Steady-state +// stays bounded at ~(backupCount + 1) x cap however hard the app loops. +// +// Bounding alone never RECLAIMS an already-huge file: a plain rotation just +// renames the monster to .1 and strands it for a cycle a healthy app may never +// reach. A multi-GB boot-loop transcript has no diagnostic value, so anything +// past the discard ceiling is deleted outright — the updated app self-heals a +// disk a stale build filled, on the next launch. const DESKTOP_LOG_MAX_BYTES = 10 * 1024 * 1024 -const DESKTOP_LOG_ROTATED_PATH = `${DESKTOP_LOG_PATH}.1` +const DESKTOP_LOG_BACKUP_COUNT = 3 +const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 +const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -544,27 +553,56 @@ let bootProgressState = { timestamp: Date.now() } +// Pure planner: ordered fs ops to bound a live log of `size`. [] = nothing. +// Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a +// missing chain link never aborts the rest. +function planDesktopLogRotation(size) { + if (size < DESKTOP_LOG_MAX_BYTES) return [] + const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) + // Pathological boot-loop log: reclaim live + every backup outright. + if (size > DESKTOP_LOG_DISCARD_BYTES) { + return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) + } + // Cascade: drop oldest, shift each up, live -> .1. + const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] + for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { + ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) + } + ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) + return ops +} + function rotateDesktopLogIfNeededSync() { + let size try { - const { size } = fs.statSync(DESKTOP_LOG_PATH) - if (size < DESKTOP_LOG_MAX_BYTES) return - fs.rmSync(DESKTOP_LOG_ROTATED_PATH, { force: true }) - fs.renameSync(DESKTOP_LOG_PATH, DESKTOP_LOG_ROTATED_PATH) + size = fs.statSync(DESKTOP_LOG_PATH).size } catch { - // No file yet (ENOENT) or rotation failed — appending will (re)create it. - // Logging must never block app startup/shutdown. + return // No live file yet — the append (re)creates it. + } + for (const [op, src, dst] of planDesktopLogRotation(size)) { + try { + if (op === 'rm') fs.rmSync(src, { force: true }) + else fs.renameSync(src, dst) + } catch { + // Best-effort — logging must never block startup/shutdown. + } } } async function rotateDesktopLogIfNeededAsync() { + let size try { - const { size } = await fs.promises.stat(DESKTOP_LOG_PATH) - if (size < DESKTOP_LOG_MAX_BYTES) return - await fs.promises.rm(DESKTOP_LOG_ROTATED_PATH, { force: true }) - await fs.promises.rename(DESKTOP_LOG_PATH, DESKTOP_LOG_ROTATED_PATH) + size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { - // No file yet (ENOENT) or rotation failed — appending will (re)create it. - // Logging must never crash the desktop shell. + return // No live file yet — the append (re)creates it. + } + for (const [op, src, dst] of planDesktopLogRotation(size)) { + try { + if (op === 'rm') await fs.promises.rm(src, { force: true }) + else await fs.promises.rename(src, dst) + } catch { + // Best-effort — logging must never crash the shell. + } } } From 003110c107b0ba079100a32e0c06e3245cc2a155 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 6 Jun 2026 12:39:15 -0500 Subject: [PATCH 08/14] fix(ci): map @TheGardenGallery email + drop unused pytest import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-attribution: add chilltulpa@gmail.com -> TheGardenGallery to AUTHOR_MAP in scripts/release.py (new external contributor via the carried-over commits). - ty: the dashboard back-compat test imported pytest but never used it, tripping unresolved-import. Drop the dead import — tests are plain functions driving the parser via subprocess, no pytest API needed. --- scripts/release.py | 1 + tests/hermes_cli/test_dashboard_tui_backcompat.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 3b81a058b6..a75b0d4de1 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 = { + "chilltulpa@gmail.com": "TheGardenGallery", "al@randomsnowflake.me": "randomsnowflake", "834740219@qq.com": "ViewWay", "harjoth.khara@gmail.com": "harjothkhara", diff --git a/tests/hermes_cli/test_dashboard_tui_backcompat.py b/tests/hermes_cli/test_dashboard_tui_backcompat.py index 677b1f4c86..e3a55bf001 100644 --- a/tests/hermes_cli/test_dashboard_tui_backcompat.py +++ b/tests/hermes_cli/test_dashboard_tui_backcompat.py @@ -20,8 +20,6 @@ import os import subprocess import sys -import pytest - REPO_ROOT = os.path.abspath( os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) ) From ebed881d46c4d39a7723a0bdbb70b53429f65e26 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:50:58 -0700 Subject: [PATCH 09/14] fix(cli): quarantine running hermes.exe during update dep-verification repair on Windows (#40409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency-verification repair in _verify_core_dependencies_installed ran 'pip install --reinstall -e .' via _run_install_with_heartbeat directly, bypassing the Windows shim-quarantine that the primary install path performs. That reinstall rewrites the entry-point shims, and on Windows the live hermes.exe is the running process — pip can neither delete nor overwrite it. With no quarantine, the shim was left missing and 'hermes' dropped off PATH ('hermes' is not recognized... after update). Extract the rename-out-of-the-way / restore-on-failure logic into a reusable _run_quarantined_install helper and route both the primary editable installs and the --reinstall -e . repair through it. The per-package repair installs only third-party deps (never hermes-agent), so they don't touch the shims and are left untouched. Add a regression test (fails on old code, passes on new). --- hermes_cli/main.py | 57 +++++++++++++++---- .../test_verify_core_dependencies.py | 43 ++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5804322989..38271f64ef 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9079,6 +9079,40 @@ def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: pass +def _run_quarantined_install( + cmd: list[str], + *, + env: dict[str, str] | None = None, + scripts_dir: Path | None = None, +) -> None: + """Run an editable install, quarantining the running ``hermes.exe`` first. + + Any ``pip install -e .`` (or ``--reinstall``) rewrites the entry-point + shims, and on Windows the live ``hermes.exe`` is the running process — + pip can neither delete nor overwrite it, so without quarantine the shim + is left missing and ``hermes`` drops off PATH. This wraps + :func:`_run_install_with_heartbeat` with the same rename-out-of-the-way / + restore-on-failure dance that the primary install path uses, so EVERY + install that touches the shims is protected — including the + verification-repair reinstalls in + :func:`_verify_core_dependencies_installed`, which previously called + ``_run_install_with_heartbeat`` directly and bypassed quarantine. + + Off-Windows (``scripts_dir is None``) this is a thin pass-through. + """ + moved: list[tuple[Path, Path]] = [] + if scripts_dir is not None: + moved = _quarantine_running_hermes_exe(scripts_dir) + try: + _run_install_with_heartbeat(cmd, env=env) + except BaseException: + # Restore shims if pip/uv didn't write replacements (e.g. install + # failed before the entry-points step). Don't swallow the error. + if scripts_dir is not None: + _restore_quarantined_exes(moved) + raise + + def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: """Sweep ``hermes.exe.old.*`` left by prior updates. @@ -9189,17 +9223,9 @@ def _install_python_dependencies_with_optional_fallback( scripts_dir = _venv_scripts_dir() if _is_windows() else None def _install(args: list[str]) -> None: - moved: list[tuple[Path, Path]] = [] - if scripts_dir is not None: - moved = _quarantine_running_hermes_exe(scripts_dir) - try: - _run_install_with_heartbeat(install_cmd_prefix + args, env=env) - except BaseException: - # Restore shims if uv didn't write replacements (e.g. install - # failed before the entry-points step). Don't swallow the error. - if scripts_dir is not None: - _restore_quarantined_exes(moved) - raise + _run_quarantined_install( + install_cmd_prefix + args, env=env, scripts_dir=scripts_dir + ) try: _install(["install", "-e", f".[{group}]"]) @@ -9366,9 +9392,16 @@ def _verify_core_dependencies_installed( # purpose — the missing dep is in *base* deps; rerunning the full all- # extras install can cost minutes and trips on whatever optional extra # was already broken upstream. Base is fast and is what's actually wrong. + # + # Quarantine the running ``hermes.exe`` first: ``--reinstall -e .`` + # rewrites the entry-point shims, and on Windows pip can't overwrite the + # live launcher, which would leave ``hermes`` off PATH. + scripts_dir = _venv_scripts_dir() if _is_windows() else None repair_args = ["install", "--reinstall", "-e", "."] try: - _run_install_with_heartbeat(install_cmd_prefix + repair_args, env=env) + _run_quarantined_install( + install_cmd_prefix + repair_args, env=env, scripts_dir=scripts_dir + ) except subprocess.CalledProcessError as e: logger.warning("dep verification: repair install failed: %s", e) print(" ⚠ Repair install failed; check `hermes update` output above.") diff --git a/tests/hermes_cli/test_verify_core_dependencies.py b/tests/hermes_cli/test_verify_core_dependencies.py index b3d4e38459..615b31406e 100644 --- a/tests/hermes_cli/test_verify_core_dependencies.py +++ b/tests/hermes_cli/test_verify_core_dependencies.py @@ -191,6 +191,49 @@ class TestVerifyCoreDependencies: assert not mock_resolve.called assert not mock_install.called + def test_repair_reinstall_quarantines_running_shim_on_windows( + self, temp_pyproject, fake_venv_python + ): + """Regression: the ``--reinstall -e .`` repair must + quarantine the running ``hermes.exe`` on Windows before installing. + + That reinstall rewrites the editable entry-point shims, and on Windows + pip can't overwrite the live launcher — so without quarantine the shim + is left missing and ``hermes`` drops off PATH. Previously this path + called ``_run_install_with_heartbeat`` directly, bypassing the + quarantine that the primary install path performs. + """ + py, venv_root = fake_venv_python + env = {"VIRTUAL_ENV": str(venv_root)} + + probe_calls = {"count": 0} + + def fake_subprocess_run(cmd, **kwargs): + probe_calls["count"] += 1 + # 1st probe: pathspec missing → triggers --reinstall repair. + # 2nd probe (after repair): clean → stops before per-package path. + if probe_calls["count"] == 1: + return MagicMock(returncode=0, stdout="pathspec\n", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + fake_scripts = venv_root / "Scripts" # created by fake_venv_python + + with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \ + patch("hermes_cli.main.subprocess.run", side_effect=fake_subprocess_run), \ + patch("hermes_cli.main._is_windows", return_value=True), \ + patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts), \ + patch("hermes_cli.main._run_install_with_heartbeat"), \ + patch("hermes_cli.main._quarantine_running_hermes_exe", return_value=[]) as mock_quar: + + from hermes_cli.main import _verify_core_dependencies_installed + _verify_core_dependencies_installed(["uv", "pip"], env=env) + + assert mock_quar.called, ( + "the --reinstall -e . repair must quarantine the running " + "hermes.exe on Windows" + ) + assert mock_quar.call_args[0][0] == fake_scripts + class TestResolveInstallTargetPython: def test_uses_virtual_env_from_environment(self, tmp_path): From c37c6eaf296a1b39e41d67e9ca3eff72482495de Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 26 May 2026 14:06:33 +0530 Subject: [PATCH 10/14] refactor(gateway): migrate Home Assistant adapter to bundled plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move gateway/platforms/homeassistant.py into plugins/platforms/homeassistant/ following the same shape as the Mattermost and Discord migrations. - Adapter file is renamed via git mv (history is preserved). - register() exposes the platform via the plugin system instead of the hardcoded Platform.HOMEASSISTANT elif in gateway/run.py::build_adapter(). - _standalone_send() replaces the legacy _send_homeassistant() helper in tools/send_message_tool.py. Out-of-process cron delivery (deliver=homeassistant from a cron process not co-located with the gateway) now flows through the registry's standalone_sender_fn path instead of the hardcoded elif. - _is_connected() probes HASS_TOKEN via hermes_cli.gateway.get_env_value so existing connected-platform checks behave identically. The HASS_TOKEN / HASS_URL env-to-PlatformConfig seeding in gateway/config.py stays in core — same pattern bluebubbles, mattermost, and discord migrations followed. No setup_fn or apply_yaml_config_fn is registered because Home Assistant has no _setup_homeassistant wizard in hermes_cli/setup.py and no homeassistant: YAML block in config.yaml today; setup runs through the existing hermes_cli/tools_config.py toolset wizard. Test imports were rewritten across tests/gateway/test_homeassistant.py, tests/integration/test_ha_integration.py, and tests/tools/test_send_message_missing_platforms.py; the legacy (token, extra, chat_id, message)-shaped _send_homeassistant call site is preserved via a small SimpleNamespace shim in test_send_message_missing_platforms.py (same approach used when mattermost moved). - Focused HA suites (64 tests across the three rewritten files) pass. - Broader gateway/cron sweep produces 10 failures identical to main baseline (telegram approval/model-picker xdist isolation flakes, wecom_callback defusedxml issue, cron script_timeout fixture issue). Zero net new failures. --- gateway/run.py | 7 - plugins/platforms/homeassistant/__init__.py | 3 + .../platforms/homeassistant/adapter.py | 128 ++++++++++++++++++ plugins/platforms/homeassistant/plugin.yaml | 22 +++ tests/gateway/test_homeassistant.py | 12 +- tests/integration/test_ha_integration.py | 2 +- .../test_send_message_missing_platforms.py | 17 ++- tools/send_message_tool.py | 25 ---- 8 files changed, 176 insertions(+), 40 deletions(-) create mode 100644 plugins/platforms/homeassistant/__init__.py rename gateway/platforms/homeassistant.py => plugins/platforms/homeassistant/adapter.py (76%) create mode 100644 plugins/platforms/homeassistant/plugin.yaml diff --git a/gateway/run.py b/gateway/run.py index 8ec8eefb54..8db1a52a5b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6900,13 +6900,6 @@ class GatewayRunner: return None return SignalAdapter(config) - elif platform == Platform.HOMEASSISTANT: - from gateway.platforms.homeassistant import HomeAssistantAdapter, check_ha_requirements - if not check_ha_requirements(): - logger.warning("HomeAssistant: aiohttp not installed or HASS_TOKEN not set") - return None - return HomeAssistantAdapter(config) - elif platform == Platform.EMAIL: from gateway.platforms.email import EmailAdapter, check_email_requirements if not check_email_requirements(): diff --git a/plugins/platforms/homeassistant/__init__.py b/plugins/platforms/homeassistant/__init__.py new file mode 100644 index 0000000000..d4f1d7bf0e --- /dev/null +++ b/plugins/platforms/homeassistant/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/homeassistant.py b/plugins/platforms/homeassistant/adapter.py similarity index 76% rename from gateway/platforms/homeassistant.py rename to plugins/platforms/homeassistant/adapter.py index e7ea762e2e..1baa3da75a 100644 --- a/gateway/platforms/homeassistant.py +++ b/plugins/platforms/homeassistant/adapter.py @@ -447,3 +447,131 @@ class HomeAssistantAdapter(BasePlatformAdapter): "type": "channel", "url": self._hass_url, } + + +# --------------------------------------------------------------------------- +# Standalone (out-of-process) sender — used by cron deliver=homeassistant +# --------------------------------------------------------------------------- + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[list] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Send a notification via the HA ``notify.notify`` service without a + live gateway adapter. + + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway + runner is not in this process (typical for cron jobs running + out-of-process). The HTTP path is the same one the legacy + ``_send_homeassistant`` helper used in ``tools/send_message_tool.py`` + before this migration. + + Reads ``HASS_TOKEN`` from ``pconfig.token`` (set by the gateway config + loader from env) and falls back to the ``HASS_TOKEN`` env var. Server + URL comes from ``pconfig.extra["url"]`` (seeded by the env loader in + ``gateway/config.py``) or the ``HASS_URL`` env var. + + ``thread_id``, ``media_files`` and ``force_document`` are accepted for + signature parity with other standalone senders. HA notifications have + no native threading or attachment model — these arguments are ignored. + """ + if not AIOHTTP_AVAILABLE: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + extra = getattr(pconfig, "extra", {}) or {} + hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/") + token = (getattr(pconfig, "token", None) or os.getenv("HASS_TOKEN", "")).strip() + if not hass_url or not token: + return { + "error": ( + "Home Assistant standalone send: HASS_URL and HASS_TOKEN " + "must both be set" + ) + } + + url = f"{hass_url}/api/services/notify/notify" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + payload = {"message": message, "target": chat_id} + + try: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30) + ) as session: + async with session.post(url, headers=headers, json=payload) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + return { + "error": ( + f"Home Assistant API error ({resp.status}): {body}" + ) + } + return { + "success": True, + "platform": "homeassistant", + "chat_id": chat_id, + } + except asyncio.TimeoutError: + return {"error": "Timeout sending notification to Home Assistant"} + except Exception as e: + return {"error": f"Home Assistant send failed: {e}"} + + +# --------------------------------------------------------------------------- +# is_connected probe +# --------------------------------------------------------------------------- + + +def _is_connected(config) -> bool: + """Home Assistant is considered connected when ``HASS_TOKEN`` is set. + + Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via + the plugin's own bound import) so tests that patch + ``gateway_mod.get_env_value`` can suppress ambient ``HASS_TOKEN`` env + vars. Matches what the legacy connected-platforms check did before + this migration. + """ + import hermes_cli.gateway as gateway_mod + return bool((gateway_mod.get_env_value("HASS_TOKEN") or "").strip()) + + +# --------------------------------------------------------------------------- +# Plugin registration entry point +# --------------------------------------------------------------------------- + + +def _build_adapter(config): + """Factory wrapper that constructs HomeAssistantAdapter from a PlatformConfig.""" + return HomeAssistantAdapter(config) + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system.""" + ctx.register_platform( + name="homeassistant", + label="Home Assistant", + adapter_factory=_build_adapter, + check_fn=check_ha_requirements, + is_connected=_is_connected, + required_env=["HASS_TOKEN"], + install_hint="pip install aiohttp", + # Out-of-process cron delivery via the HA ``notify.notify`` service. + # Without this hook, ``deliver=homeassistant`` cron jobs would fail + # with "No live adapter" when cron runs separately from the gateway. + # Mirrors the Discord / Teams / Mattermost pattern. + standalone_sender_fn=_standalone_send, + # HA notification message cap — matches MAX_MESSAGE_LENGTH on the + # adapter class above. + max_message_length=HomeAssistantAdapter.MAX_MESSAGE_LENGTH, + # Display + emoji="🏠", + allow_update_command=True, + ) diff --git a/plugins/platforms/homeassistant/plugin.yaml b/plugins/platforms/homeassistant/plugin.yaml new file mode 100644 index 0000000000..b772d86004 --- /dev/null +++ b/plugins/platforms/homeassistant/plugin.yaml @@ -0,0 +1,22 @@ +name: homeassistant-platform +label: Home Assistant +kind: platform +version: 1.0.0 +description: > + Home Assistant gateway adapter for Hermes Agent. + Subscribes to HA's WebSocket event bus and forwards state-change events + (with per-entity cooldowns and domain/entity filtering) to the agent. + Outbound messages are delivered as HA persistent notifications via the + REST API. Out-of-process cron delivery via the ``notify.notify`` + service is also supported. +author: NousResearch +requires_env: + - name: HASS_TOKEN + description: "Home Assistant Long-Lived Access Token" + prompt: "Home Assistant Long-Lived Access Token" + password: true +optional_env: + - name: HASS_URL + description: "Home Assistant base URL (default: http://homeassistant.local:8123)" + prompt: "Home Assistant URL" + password: false diff --git a/tests/gateway/test_homeassistant.py b/tests/gateway/test_homeassistant.py index b4ff5d8a35..8b6b83cf9c 100644 --- a/tests/gateway/test_homeassistant.py +++ b/tests/gateway/test_homeassistant.py @@ -14,7 +14,7 @@ from gateway.config import ( Platform, PlatformConfig, ) -from gateway.platforms.homeassistant import ( +from plugins.platforms.homeassistant.adapter import ( HomeAssistantAdapter, check_ha_requirements, ) @@ -34,7 +34,7 @@ class TestCheckRequirements: monkeypatch.setenv("HASS_TOKEN", "test-token") assert check_ha_requirements() is True - @patch("gateway.platforms.homeassistant.AIOHTTP_AVAILABLE", False) + @patch("plugins.platforms.homeassistant.adapter.AIOHTTP_AVAILABLE", False) def test_returns_false_without_aiohttp(self, monkeypatch): monkeypatch.setenv("HASS_TOKEN", "test-token") assert check_ha_requirements() is False @@ -504,7 +504,7 @@ class TestSendViaRestApi: adapter = _make_adapter() mock_session = self._mock_aiohttp_session(200) - with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp: + with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp: mock_aiohttp.ClientSession = MagicMock(return_value=mock_session) mock_aiohttp.ClientTimeout = lambda total: total @@ -523,7 +523,7 @@ class TestSendViaRestApi: adapter = _make_adapter() mock_session = self._mock_aiohttp_session(401, "Unauthorized") - with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp: + with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp: mock_aiohttp.ClientSession = MagicMock(return_value=mock_session) mock_aiohttp.ClientTimeout = lambda total: total @@ -538,7 +538,7 @@ class TestSendViaRestApi: mock_session = self._mock_aiohttp_session(200) long_message = "x" * 10000 - with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp: + with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp: mock_aiohttp.ClientSession = MagicMock(return_value=mock_session) mock_aiohttp.ClientTimeout = lambda total: total @@ -554,7 +554,7 @@ class TestSendViaRestApi: adapter._ws = AsyncMock() # Simulate an active WS mock_session = self._mock_aiohttp_session(200) - with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp: + with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp: mock_aiohttp.ClientSession = MagicMock(return_value=mock_session) mock_aiohttp.ClientTimeout = lambda total: total diff --git a/tests/integration/test_ha_integration.py b/tests/integration/test_ha_integration.py index 7f7329bad2..4b61981697 100644 --- a/tests/integration/test_ha_integration.py +++ b/tests/integration/test_ha_integration.py @@ -16,7 +16,7 @@ pytestmark = pytest.mark.integration from unittest.mock import AsyncMock from gateway.config import Platform, PlatformConfig -from gateway.platforms.homeassistant import HomeAssistantAdapter +from plugins.platforms.homeassistant.adapter import HomeAssistantAdapter from tests.fakes.fake_ha_server import FakeHAServer, ENTITY_STATES from tools.homeassistant_tool import ( _async_call_service, diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index cb201f8914..05d1023bcf 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch from tools.send_message_tool import ( _send_dingtalk, - _send_homeassistant, _send_matrix, ) @@ -28,6 +27,22 @@ async def _send_mattermost(token, extra, chat_id, message): return await _mattermost_standalone_send(pconfig, chat_id, message) +# ``_send_homeassistant`` moved into the homeassistant plugin +# (``plugins/platforms/homeassistant/adapter.py::_standalone_send``). Same +# shim pattern as ``_send_mattermost`` above. +from plugins.platforms.homeassistant.adapter import ( + _standalone_send as _homeassistant_standalone_send, +) + + +async def _send_homeassistant(token, extra, chat_id, message): + """Pre-migration ``(token, extra, chat_id, message)`` shim around the + plugin's ``_standalone_send(pconfig, chat_id, message)``. + """ + pconfig = SimpleNamespace(token=token, extra=extra or {}) + return await _homeassistant_standalone_send(pconfig, chat_id, message) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 3009aac3b9..53a9fc6003 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -788,8 +788,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_sms(pconfig.api_key, chat_id, chunk) elif platform == Platform.MATRIX: result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk) - elif platform == Platform.HOMEASSISTANT: - result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk) elif platform == Platform.DINGTALK: result = await _send_dingtalk(pconfig.extra, chat_id, chunk) elif platform == Platform.FEISHU: @@ -1486,29 +1484,6 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, pass -async def _send_homeassistant(token, extra, chat_id, message): - """Send via Home Assistant notify service.""" - try: - import aiohttp - except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} - try: - hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/") - token = token or os.getenv("HASS_TOKEN", "") - if not hass_url or not token: - return {"error": "Home Assistant not configured (HASS_URL, HASS_TOKEN required)"} - url = f"{hass_url}/api/services/notify/notify" - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - return _error(f"Home Assistant API error ({resp.status}): {body}") - return {"success": True, "platform": "homeassistant", "chat_id": chat_id} - except Exception as e: - return _error(f"Home Assistant send failed: {e}") - - async def _send_dingtalk(extra, chat_id, message): """Send via DingTalk robot webhook. From ef7e5168b52e4d3b38028fb960b48af88b149154 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 26 May 2026 14:57:15 +0530 Subject: [PATCH 11/14] chore(gateway): drop plugin-migrated platforms from /update allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gateway/run.py::_UPDATE_ALLOWED_PLATFORMS` was a hardcoded frozenset listing every messaging platform allowed to invoke the `/update` slash command. Plugin-migrated platforms (currently Discord and Mattermost, soon also Home Assistant via #32500) declare `allow_update_command=True` on their `PlatformEntry`, and `_handle_update_command` already falls back to the registry when a platform isn't in the frozenset. The result was a silent redundancy: those entries said "allowed" twice, and the registry flag was a no-op for them in practice. - Removed `Platform.DISCORD` and `Platform.MATTERMOST` from the frozenset. - Updated the docstring to make the split explicit (built-ins live in the frozenset; plugins use `allow_update_command` on the registry entry). The remaining frozenset entries are all still built-in platforms living under `gateway/platforms/` today. Future plugin migrations should drop their entry from the frozenset as part of the migration PR (or in a sibling chore PR like this one). Added a `TestUpdateCommandPlatformGate` test class that pins down all three branches of the gate so future changes don't silently regress: - Programmatic interfaces (`Platform.WEBHOOK`, `Platform.API_SERVER`) must remain blocked. - Plugin-migrated platforms (Discord, Mattermost) must pass via the registry fallback. - Built-in platforms in the hardcoded frozenset (Telegram) must still pass without needing the registry. The gate previously had zero direct test coverage — its only existing coverage was `test_no_adapter_for_platform` which exercised a different code path. --- gateway/run.py | 12 ++- tests/gateway/test_update_command.py | 121 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 8db1a52a5b..dc8e0f14cc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14967,11 +14967,15 @@ class GatewayRunner: return t("gateway.deny.denied_plural", count=count) return t("gateway.deny.denied_singular") - # Platforms where /update is allowed. ACP, API server, and webhooks are - # programmatic interfaces that should not trigger system updates. + # Built-in messaging platforms where the ``/update`` command is allowed. + # ACP, API server, and webhooks are programmatic interfaces that should + # not trigger system updates. Plugin-migrated platforms (discord, + # mattermost, teams, irc, line, …) are NOT listed here — they declare + # ``allow_update_command=True`` on their ``PlatformEntry`` and are + # honored via the registry fallback at ``_handle_update_command`` below. _UPDATE_ALLOWED_PLATFORMS = frozenset({ - Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, - Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, + Platform.TELEGRAM, Platform.SLACK, Platform.WHATSAPP, + Platform.SIGNAL, Platform.MATRIX, Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, }) diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index 64998771d8..39d805edb4 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -365,6 +365,127 @@ class TestHandleUpdateCommand: assert "stream progress" in result +# --------------------------------------------------------------------------- +# Platform allowlist gate +# --------------------------------------------------------------------------- + + +class TestUpdateCommandPlatformGate: + """Tests for the platform-allowlist gate at the top of + ``_handle_update_command``. Built-in messaging platforms are listed in + ``_UPDATE_ALLOWED_PLATFORMS``; plugin-migrated platforms (discord, + mattermost, teams, …) are NOT in the frozenset and rely on the + registry's ``allow_update_command=True`` fallback. Programmatic + interfaces (ACP, API server, webhooks) must be blocked. + """ + + @pytest.mark.asyncio + async def test_blocks_programmatic_interface(self, monkeypatch): + """``Platform.WEBHOOK`` is not a messaging platform and must be + blocked by the allowlist gate before any side effects fire.""" + runner = _make_runner() + event = _make_event(platform=Platform.WEBHOOK) + # Stop _handle_update_command from progressing further if the gate + # somehow lets the event through — the assertion on the returned + # string is the real test. + monkeypatch.setenv("HERMES_MANAGED", "") + + result = await runner._handle_update_command(event) + + # The exact rejection message comes from + # ``gateway.update.platform_not_messaging`` translation key. + assert "only available from messaging platforms" in result + + @pytest.mark.asyncio + async def test_blocks_api_server_platform(self, monkeypatch): + """``Platform.API_SERVER`` (programmatic, not messaging) must be + blocked by the allowlist gate. + """ + runner = _make_runner() + event = _make_event(platform=Platform.API_SERVER) + monkeypatch.setenv("HERMES_MANAGED", "") + + result = await runner._handle_update_command(event) + + assert "only available from messaging platforms" in result + + @pytest.mark.asyncio + async def test_allows_plugin_platform_via_registry_fallback(self, monkeypatch): + """A plugin-migrated platform (DISCORD) is no longer in + ``_UPDATE_ALLOWED_PLATFORMS`` but must still pass the gate via + the registry's ``allow_update_command=True`` flag. + + This test is the empirical guarantee that removing DISCORD from + the hardcoded frozenset does not regress the /update command for + Discord users. + """ + from gateway.run import GatewayRunner + + # Precondition: DISCORD is NOT in the hardcoded set anymore. + assert Platform.DISCORD not in GatewayRunner._UPDATE_ALLOWED_PLATFORMS + + # Make sure the plugin registry is populated so the fallback fires. + from hermes_cli.plugins import PluginManager + PluginManager().discover_and_load(force=True) + from gateway.platform_registry import platform_registry + discord_entry = platform_registry.get("discord") + assert discord_entry is not None + assert discord_entry.allow_update_command is True + + runner = _make_runner() + event = _make_event(platform=Platform.DISCORD) + monkeypatch.setenv("HERMES_MANAGED", "") + + result = await runner._handle_update_command(event) + + # The gate must NOT have rejected us — anything other than the + # ``platform_not_messaging`` rejection string is acceptable here. + # Later steps may legitimately return success ("Starting Hermes + # update…") or fail for environment reasons. + assert "only available from messaging platforms" not in result + + @pytest.mark.asyncio + async def test_allows_mattermost_via_registry_fallback(self, monkeypatch): + """Same as DISCORD: MATTERMOST is now plugin-migrated and not in + the hardcoded frozenset; the registry must keep /update working. + """ + from gateway.run import GatewayRunner + + assert Platform.MATTERMOST not in GatewayRunner._UPDATE_ALLOWED_PLATFORMS + + from hermes_cli.plugins import PluginManager + PluginManager().discover_and_load(force=True) + from gateway.platform_registry import platform_registry + mm_entry = platform_registry.get("mattermost") + assert mm_entry is not None + assert mm_entry.allow_update_command is True + + runner = _make_runner() + event = _make_event(platform=Platform.MATTERMOST) + monkeypatch.setenv("HERMES_MANAGED", "") + + result = await runner._handle_update_command(event) + + assert "only available from messaging platforms" not in result + + @pytest.mark.asyncio + async def test_allows_builtin_platform_in_allowlist(self, monkeypatch): + """``Platform.TELEGRAM`` is in the hardcoded allowlist — gate + must pass without consulting the registry. + """ + from gateway.run import GatewayRunner + + assert Platform.TELEGRAM in GatewayRunner._UPDATE_ALLOWED_PLATFORMS + + runner = _make_runner() + event = _make_event(platform=Platform.TELEGRAM) + monkeypatch.setenv("HERMES_MANAGED", "") + + result = await runner._handle_update_command(event) + + assert "only available from messaging platforms" not in result + + # --------------------------------------------------------------------------- # _send_update_notification # --------------------------------------------------------------------------- From 7c4aa3e4da0161df0e6458a35df512a2e033717a Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:38:41 -0600 Subject: [PATCH 12/14] fix(image_gen): expose backend-visible artifact paths --- .../tools/test_image_generation_artifacts.py | 124 +++++++++++++++++ tools/image_generation_tool.py | 131 +++++++++++++++++- 2 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 tests/tools/test_image_generation_artifacts.py diff --git a/tests/tools/test_image_generation_artifacts.py b/tests/tools/test_image_generation_artifacts.py new file mode 100644 index 0000000000..2a1ce11135 --- /dev/null +++ b/tests/tools/test_image_generation_artifacts.py @@ -0,0 +1,124 @@ +import json +from types import SimpleNamespace + + +def test_postprocess_adds_agent_visible_image_for_active_ssh_env(monkeypatch, tmp_path): + from tools import image_generation_tool + + hermes_home = tmp_path / ".hermes" + image_dir = hermes_home / "cache" / "images" + image_dir.mkdir(parents=True) + image_path = image_dir / "xai_grok-imagine-image_test.jpg" + image_path.write_bytes(b"jpg") + + sync_calls = [] + + class FakeSyncManager: + def sync(self, *, force=False): + sync_calls.append(force) + + env = SimpleNamespace( + _remote_home="/home/remotesshuser", + _sync_manager=FakeSyncManager(), + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(image_generation_tool, "_active_terminal_env", lambda task_id: env) + + raw = json.dumps({"success": True, "image": str(image_path)}) + result = json.loads( + image_generation_tool._postprocess_image_generate_result(raw, task_id="task-1") + ) + + assert result["image"] == str(image_path) + assert result["host_image"] == str(image_path) + assert result["agent_visible_image"] == ( + "/home/remotesshuser/.hermes/cache/images/xai_grok-imagine-image_test.jpg" + ) + assert sync_calls == [True] + + +def test_postprocess_maps_docker_cache_path_without_active_env(monkeypatch, tmp_path): + from tools import image_generation_tool + + hermes_home = tmp_path / ".hermes" + image_dir = hermes_home / "cache" / "images" + image_dir.mkdir(parents=True) + image_path = image_dir / "generated.png" + image_path.write_bytes(b"png") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_ENV", "docker") + monkeypatch.setattr(image_generation_tool, "_active_terminal_env", lambda task_id: None) + + raw = json.dumps({"success": True, "image": str(image_path)}) + result = json.loads(image_generation_tool._postprocess_image_generate_result(raw)) + + assert result["image"] == str(image_path) + assert result["agent_visible_image"] == "/root/.hermes/cache/images/generated.png" + + +def test_postprocess_maps_ssh_cache_path_without_active_env(monkeypatch, tmp_path): + from tools import image_generation_tool + + hermes_home = tmp_path / ".hermes" + image_dir = hermes_home / "cache" / "images" + image_dir.mkdir(parents=True) + image_path = image_dir / "first-call.png" + image_path.write_bytes(b"png") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setattr(image_generation_tool, "_active_terminal_env", lambda task_id: None) + + raw = json.dumps({"success": True, "image": str(image_path)}) + result = json.loads(image_generation_tool._postprocess_image_generate_result(raw)) + + assert result["image"] == str(image_path) + assert result["agent_visible_image"] == "~/.hermes/cache/images/first-call.png" + + +def test_postprocess_leaves_remote_image_urls_unchanged(monkeypatch): + from tools import image_generation_tool + + monkeypatch.setattr(image_generation_tool, "_active_terminal_env", lambda task_id: None) + + raw = json.dumps({"success": True, "image": "https://example.com/image.png"}) + + assert image_generation_tool._postprocess_image_generate_result(raw) == raw + + +def test_handle_image_generate_postprocesses_plugin_result(monkeypatch, tmp_path): + from tools import image_generation_tool + + hermes_home = tmp_path / ".hermes" + image_dir = hermes_home / "cache" / "images" + image_dir.mkdir(parents=True) + image_path = image_dir / "plugin.png" + image_path.write_bytes(b"png") + + env = SimpleNamespace(_remote_home="/home/remote", _sync_manager=None) + + seen_task_ids = [] + + def fake_active_env(task_id): + seen_task_ids.append(task_id) + return env + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(image_generation_tool, "_active_terminal_env", fake_active_env) + monkeypatch.setattr( + image_generation_tool, + "_dispatch_to_plugin_provider", + lambda prompt, aspect_ratio: json.dumps({"success": True, "image": str(image_path)}), + ) + + result = json.loads( + image_generation_tool._handle_image_generate( + {"prompt": "draw", "aspect_ratio": "square"}, + task_id="plugin-task", + ) + ) + + assert seen_task_ids == ["plugin-task"] + assert result["agent_visible_image"] == "/home/remote/.hermes/cache/images/plugin.png" diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index d3263eae8a..7e2080a7a1 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -23,9 +23,11 @@ update when it's noticed. import json import logging import os +import posixpath import datetime import threading import uuid +from pathlib import Path from typing import Any, Dict, Optional # fal_client is imported lazily — see _load_fal_client(). Pulling it @@ -606,6 +608,124 @@ def _upscale_image(image_url: str, original_prompt: str) -> Optional[Dict[str, A # --------------------------------------------------------------------------- # Tool entry point # --------------------------------------------------------------------------- +def _looks_like_absolute_file_path(value: str) -> bool: + if not value or not isinstance(value, str): + return False + lower = value.lower() + if lower.startswith(("http://", "https://", "data:")): + return False + if os.path.isabs(value): + return True + return len(value) >= 3 and value[1] == ":" and value[2] in {"/", "\\"} + + +def _active_terminal_env(task_id: str | None): + try: + from tools.terminal_tool import get_active_env + + return get_active_env(task_id or "default") + except Exception as exc: # noqa: BLE001 - artifact hinting must not break generation + logger.debug("Could not inspect active terminal environment: %s", exc) + return None + + +def _agent_cache_base_for_env(env: Any) -> str | None: + if env is not None: + explicit = getattr(env, "agent_visible_cache_base", None) + if callable(explicit): + try: + value = explicit() + if value: + return str(value).rstrip("/") + except Exception as exc: # noqa: BLE001 + logger.debug("active env agent_visible_cache_base failed: %s", exc) + + remote_home = getattr(env, "_remote_home", None) + if remote_home: + return f"{str(remote_home).rstrip('/')}/.hermes" + + env_name = env.__class__.__name__ + if env_name in {"DockerEnvironment", "SingularityEnvironment", "ModalEnvironment"}: + return "/root/.hermes" + + # If no environment has been created yet, only backends with deterministic + # Hermes cache roots can be translated without side effects. SSH can still + # use a shell-visible tilde path; its first environment sync will upload + # the cache file before the first command runs. + backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() + if backend in {"docker", "singularity", "modal"}: + return "/root/.hermes" + if backend == "ssh": + return "~/.hermes" + return None + + +def _agent_visible_cache_path(host_path: str, env: Any) -> str | None: + if not _looks_like_absolute_file_path(host_path): + return None + + cache_base = _agent_cache_base_for_env(env) + if not cache_base: + return None + + try: + from tools.credential_files import get_cache_directory_mounts + + path = Path(host_path) + for mount in get_cache_directory_mounts(container_base=cache_base): + host_dir = Path(mount["host_path"]) + try: + rel = path.relative_to(host_dir) + except ValueError: + continue + return posixpath.join(mount["container_path"], rel.as_posix()) + except Exception as exc: # noqa: BLE001 + logger.debug("Could not translate image cache path for backend: %s", exc) + return None + + +def _force_artifact_sync(env: Any) -> None: + sync_manager = getattr(env, "_sync_manager", None) + if sync_manager is None: + return + try: + sync_manager.sync(force=True) + except Exception as exc: # noqa: BLE001 - keep generation success; log for operators + logger.warning("Could not force-sync generated image artifact: %s", exc) + + +def _postprocess_image_generate_result(raw: str, task_id: str | None = None) -> str: + """Annotate successful local image results with backend-visible paths. + + ``image`` remains the host/gateway-deliverable path. When the active + terminal backend has a different filesystem, ``agent_visible_image`` gives + the path the agent can use with terminal/file tools. + """ + try: + payload = json.loads(raw) if isinstance(raw, str) else raw + except Exception: + return raw + + if not isinstance(payload, dict) or not payload.get("success"): + return raw + + image = payload.get("image") + if not isinstance(image, str) or not _looks_like_absolute_file_path(image): + return raw + + env = _active_terminal_env(task_id) + agent_path = _agent_visible_cache_path(image, env) + if not agent_path or agent_path == image: + return raw + + if env is not None: + _force_artifact_sync(env) + + payload.setdefault("host_image", image) + payload.setdefault("agent_visible_image", agent_path) + return json.dumps(payload, ensure_ascii=False) + + def image_generate_tool( prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, @@ -891,7 +1011,10 @@ IMAGE_GENERATE_SCHEMA = { "backend (FAL, OpenAI, etc.) and model are user-configured and not " "selectable by the agent. Returns either a URL or an absolute file " "path in the `image` field; display it with markdown " - "![description](url-or-path) and the gateway will deliver it." + "![description](url-or-path) and the gateway will deliver it. When " + "the active terminal backend has a different filesystem, successful " + "local-file results may also include `agent_visible_image` for " + "follow-up terminal/file operations." ), "parameters": { "type": "object", @@ -1035,17 +1158,19 @@ def _handle_image_generate(args, **kw): if not prompt: return tool_error("prompt is required for image generation") aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO) + task_id = kw.get("task_id") # Route to a plugin-registered provider if one is active (and it's # not the in-tree FAL path). dispatched = _dispatch_to_plugin_provider(prompt, aspect_ratio) if dispatched is not None: - return dispatched + return _postprocess_image_generate_result(dispatched, task_id=task_id) - return image_generate_tool( + raw = image_generate_tool( prompt=prompt, aspect_ratio=aspect_ratio, ) + return _postprocess_image_generate_result(raw, task_id=task_id) registry.register( From c79e3fd0baf41c0adda616b73153eeaa8a4b8231 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:43:33 +0530 Subject: [PATCH 13/14] refactor(image_gen): delegate cache-path mapping to shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the backend-visible artifact-path fix. - Extract the cache-mount iteration loop into a reusable, backend-agnostic credential_files.map_cache_path_to_container(host_path, container_base) that returns the POSIX container path or None. to_agent_visible_cache_path() now delegates to it (keeping its Docker-only gate), and image_generation_tool's _agent_visible_cache_path() delegates to it too — eliminating the duplicated loop and the divergent path-join (posixpath vs Path) between the two. - Drop the now-unused posixpath/Path imports from image_generation_tool.py. - Document the agent_visible_cache_base getattr probe as a forward-looking optional hook (no producer yet) so it doesn't read as a typo'd attribute. - Add unit tests for map_cache_path_to_container. --- tests/tools/test_credential_files.py | 43 ++++++++++++++++++++++++++++ tools/credential_files.py | 38 +++++++++++++++++------- tools/image_generation_tool.py | 17 ++++------- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 32b4c7664d..ac2fb53f3a 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -13,6 +13,7 @@ from tools.credential_files import ( get_skills_directory_mount, iter_cache_files, iter_skills_files, + map_cache_path_to_container, register_credential_file, register_credential_files, ) @@ -423,6 +424,48 @@ class TestCacheDirectoryMounts: assert get_cache_directory_mounts() == [] +class TestMapCachePathToContainer: + """Tests for map_cache_path_to_container() — the backend-agnostic mapper.""" + + def test_maps_path_under_cache_dir(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + img_dir = hermes_home / "cache" / "images" + img_dir.mkdir(parents=True) + host_path = str(img_dir / "generated.png") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + assert ( + map_cache_path_to_container(host_path) + == "/root/.hermes/cache/images/generated.png" + ) + + def test_custom_container_base_for_remote_home(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + img_dir = hermes_home / "cache" / "images" + img_dir.mkdir(parents=True) + host_path = str(img_dir / "remote.png") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + assert ( + map_cache_path_to_container(host_path, container_base="/home/agent/.hermes") + == "/home/agent/.hermes/cache/images/remote.png" + ) + + def test_returns_none_when_outside_cache_dirs(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + (hermes_home / "cache" / "images").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + assert map_cache_path_to_container(str(tmp_path / "elsewhere.png")) is None + + def test_returns_none_when_no_cache_dirs_exist(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + assert map_cache_path_to_container(str(hermes_home / "cache" / "images" / "x.png")) is None + + class TestIterCacheFiles: """Tests for iter_cache_files().""" diff --git a/tools/credential_files.py b/tools/credential_files.py index 381115e095..5fbd27bd07 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -22,9 +22,10 @@ from __future__ import annotations import logging import os +import posixpath from contextvars import ContextVar from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional from hermes_cli.config import cfg_get logger = logging.getLogger(__name__) @@ -374,6 +375,30 @@ def get_cache_directory_mounts( return mounts +def map_cache_path_to_container( + host_path: str, + container_base: str = "/root/.hermes", +) -> Optional[str]: + """Map a host cache path to its mounted path under *container_base*. + + Returns the POSIX container path when *host_path* lives under one of the + auto-mounted cache directories, otherwise ``None``. Backend-agnostic: the + caller decides which ``container_base`` applies (Docker ``/root/.hermes``, + SSH ``/.hermes``, etc.) and whether translation is wanted. + Always joins with ``posixpath`` because container/remote paths are POSIX + regardless of the host OS. + """ + path = Path(host_path) + for mount in get_cache_directory_mounts(container_base=container_base): + host_dir = Path(mount["host_path"]) + try: + rel = path.relative_to(host_dir) + except ValueError: + continue + return posixpath.join(mount["container_path"], rel.as_posix()) + return None + + def to_agent_visible_cache_path( host_path: str, container_base: str = "/root/.hermes", @@ -391,15 +416,8 @@ def to_agent_visible_cache_path( if os.environ.get("TERMINAL_ENV", "local") != "docker": return host_path - path = Path(host_path) - for mount in get_cache_directory_mounts(container_base=container_base): - host_dir = Path(mount["host_path"]) - try: - rel = path.relative_to(host_dir) - return str(Path(mount["container_path"]) / rel) - except ValueError: - continue - return host_path + mapped = map_cache_path_to_container(host_path, container_base=container_base) + return mapped if mapped is not None else host_path def iter_cache_files( diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 7e2080a7a1..d7eeb30d17 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -23,11 +23,9 @@ update when it's noticed. import json import logging import os -import posixpath import datetime import threading import uuid -from pathlib import Path from typing import Any, Dict, Optional # fal_client is imported lazily — see _load_fal_client(). Pulling it @@ -631,6 +629,10 @@ def _active_terminal_env(task_id: str | None): def _agent_cache_base_for_env(env: Any) -> str | None: if env is not None: + # Forward-looking optional override: an environment may expose its own + # agent-visible cache root via this callable. No backend defines it yet + # — it's an extension hook, not a typo. The getattr/callable guards make + # it a safe no-op until a producer exists. explicit = getattr(env, "agent_visible_cache_base", None) if callable(explicit): try: @@ -669,16 +671,9 @@ def _agent_visible_cache_path(host_path: str, env: Any) -> str | None: return None try: - from tools.credential_files import get_cache_directory_mounts + from tools.credential_files import map_cache_path_to_container - path = Path(host_path) - for mount in get_cache_directory_mounts(container_base=cache_base): - host_dir = Path(mount["host_path"]) - try: - rel = path.relative_to(host_dir) - except ValueError: - continue - return posixpath.join(mount["container_path"], rel.as_posix()) + return map_cache_path_to_container(host_path, container_base=cache_base) except Exception as exc: # noqa: BLE001 logger.debug("Could not translate image cache path for backend: %s", exc) return None From f033b7dbfbe81dc5b0dbafe3c7eef7d25b6718ae Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 6 Jun 2026 16:32:47 -0500 Subject: [PATCH 14/14] feat(desktop): unified overlay design system, BrandMark & onboarding redesign (#40708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): unify dialog/overlay buttons on shared Button component Replace raw + ) +} + +function EmptyPanel({ action, description, title }: { action?: ReactNode; description: string; title?: string }) { + return ( +
+
+ {title && ( +
{title}
+ )} +
+ {description} +
+ {action &&
{action}
} +
+
+ ) +} + +export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) { const { t } = useI18n() const cc = t.commandCenter const sessions = useStore($sessions) @@ -161,8 +122,6 @@ export function CommandCenterView({ 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) @@ -172,78 +131,30 @@ export function CommandCenterView({ 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 sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) + 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 filteredSessions = useMemo( - () => - [...sessions].sort((a, b) => { - const left = a.last_active || a.started_at || 0 - const right = b.last_active || b.started_at || 0 + return right - left + }) - return right - left - }), - [sessions] - ) + const needle = debouncedQuery.toLowerCase() - 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 - })) + if (!needle) { + return sorted + } - 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 sorted.filter(session => { + const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase() - 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] - ) + return haystack.includes(needle) + }) + }, [debouncedQuery, sessions]) const refreshSystem = useCallback(async () => { setSystemLoading(true) @@ -290,42 +201,6 @@ export function CommandCenterView({ } }, []) - 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() @@ -346,8 +221,6 @@ export function CommandCenterView({ } }) - const showGlobalSearchResults = debouncedQuery.length > 0 - const hasGlobalSearchResults = searchGroups.length > 0 const sessionListHasResults = filteredSessions.length > 0 const runSystemAction = useCallback( @@ -391,40 +264,8 @@ export function CommandCenterView({ [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 => ( @@ -439,178 +280,100 @@ export function CommandCenterView({ -
-
-

{cc.sections[section]}

-

{cc.sectionDescriptions[section]}

+
+
+

+ {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} - - )} -
- - {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' && ( + setQuery(next)} + placeholder={cc.searchPlaceholder} + value={query} + /> + )} + {section === 'usage' && ( + setUsagePeriod(Number(id) as UsagePeriod)} + options={USAGE_PERIODS.map(value => ({ id: String(value), label: cc.days(value) }))} + value={String(usagePeriod)} + /> )}
- ) : section === 'sessions' ? ( +
+ + {section === 'sessions' ? (
{!sessionListHasResults ? ( - {cc.noSessions} + ) : ( -
+
    {filteredSessions.map(session => { - const pinned = pinnedSessionIds.includes(session.id) + const pinId = sessionPinId(session) + const pinned = pinnedSessionIds.includes(pinId) return ( - +
  • - (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} - > - - - +
    + (pinned ? unpinSession(pinId) : pinSession(pinId))} + 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 ? (
@@ -622,49 +385,51 @@ export function CommandCenterView({ status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500' )} /> - + {status.gateway_running ? cc.gatewayRunning : cc.gatewayStopped}
-
+
{cc.hermesActiveSessions(status.version, status.active_sessions)}
- void runSystemAction('restart')}> + +
{systemAction && ( -
+
{systemAction.name} ·{' '} {systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
)}
) : ( -
{cc.loadingStatus}
+ )} - +
- +
- {cc.recentLogs} + + {cc.recentLogs} + {systemError && ( - + {systemError} )}
-
+                
                   {logs.length ? logs.join('\n') : cc.noLogs}
                 
- +
)} @@ -708,13 +473,12 @@ 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, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { +function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProps) { const { t } = useI18n() const cc = t.commandCenter const daily = useMemo(() => usage?.daily ?? [], [usage]) @@ -730,171 +494,161 @@ function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage } return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) }, [daily]) - return ( -
- -
- {USAGE_PERIODS.map(value => ( - - ))} -
- {error && ( - - - {error} - - )} -
- - - {totals ? ( -
- - - - 0 ? cc.actualCost(formatCost(totals.total_actual_cost)) : undefined} - label={cc.statCost} - value={formatCost(totals.total_estimated_cost)} - /> -
- ) : loading ? ( -
{cc.loadingUsage}
+ if (!totals) { + return ( +
+ {loading ? ( + ) : ( -
- {cc.noUsage(period)}{' '} - -
+ + {cc.retry} + + } + description={cc.noUsage(period)} + /> )} - +
+ ) + } -
- -
- {cc.dailyTokens} - - - {cc.input} - - - {cc.output} - + return ( +
+ {error && ( + + + {error} + + )} + +
+ + + + 0 ? cc.actualCost(formatCost(totals.total_actual_cost)) : undefined} + label={cc.statCost} + value={formatCost(totals.total_estimated_cost)} + /> +
+ +
+
+ + {cc.dailyTokens} + + + + {cc.input} + + {cc.output} + + +
+ {daily.length === 0 ? ( +
+ {cc.noDailyActivity}
- {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) + ) : ( + <> +
+ {daily.map(entry => { + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) - return ( + return ( +
-
0 ? 1 : 0) }} - /> -
0 ? 1 : 0) }} - /> -
- ) - })} -
-
- {daily[0]?.day} - {daily[daily.length - 1]?.day} -
- - )} - + className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50" + style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }} + /> +
0 ? 1 : 0) }} + /> +
+ ) + })} +
+
+ {daily[0]?.day} + {daily[daily.length - 1]?.day} +
+ + )} +
- -
-
-
- {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())} - -
  • - ))} -
- )} -
-
-
+
+ ({ + key: entry.model, + label: entry.model, + value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} · ${formatCost(entry.estimated_cost)}` + }))} + title={cc.topModels} + /> + ({ + key: entry.skill, + label: entry.skill, + value: cc.actions(entry.total_count.toLocaleString()) + }))} + title={cc.topSkills} + />
) } +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/index.tsx b/apps/desktop/src/app/cron/index.tsx index dcf852e6aa..075dd3380b 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -434,7 +434,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou {c.newCron}
-
+
{visibleJobs.map(job => ( 0 && (
+ {profiles.map(profile => ( + setSelectedName(profile.name)} + profile={profile} + /> + ))} + {profiles.length === 0 && ( +

{p.noProfiles}

+ )} + -
- {!profiles ? ( - - ) : ( -
- +
+ )} + + + )} -
- {selected ? ( - setPendingDelete(selected)} - onRename={newName => handleRename(selected.name, newName)} - profile={selected} - /> - ) : ( -
-
- -

{p.selectPrompt}

-
-
- )} -
-
- )} -
- - setCreateOpen(false)} onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)} open={createOpen} @@ -261,7 +219,6 @@ export function ProfilesView({ - ) } @@ -273,7 +230,7 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: return (
-
+
{profile.model ? ( <> @@ -475,9 +432,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
{loading ? ( -
- {p.loadingSoul} -
+ ) : (