diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 4b4a22e075..3e4e92a33a 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1620,13 +1620,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: @@ -1640,6 +1664,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 @@ -1659,6 +1684,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 @@ -1666,12 +1692,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 "", @@ -1679,89 +1706,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 a07d0ed4b0..72955251dc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1239,6 +1239,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 ( @@ -1291,6 +1313,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: @@ -1349,7 +1372,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/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 09e5dfac6b..7daf5054d6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -247,6 +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 +// 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_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) @@ -534,6 +553,59 @@ 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 { + size = fs.statSync(DESKTOP_LOG_PATH).size + } catch { + 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 { + size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size + } catch { + 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. + } + } +} + function flushDesktopLogBufferSync() { if (!desktopLogBuffer) return const chunk = desktopLogBuffer @@ -541,6 +613,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 +628,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(() => { diff --git a/apps/desktop/public/nous-girl.jpg b/apps/desktop/public/nous-girl.jpg new file mode 100644 index 0000000000..19861544bb Binary files /dev/null and b/apps/desktop/public/nous-girl.jpg differ diff --git a/apps/desktop/src/app/chat/composer/url-dialog.tsx b/apps/desktop/src/app/chat/composer/url-dialog.tsx index b37e58fc33..6b3dc21adb 100644 --- a/apps/desktop/src/app/chat/composer/url-dialog.tsx +++ b/apps/desktop/src/app/chat/composer/url-dialog.tsx @@ -38,17 +38,9 @@ export function UrlDialog({ return ( - - - - -
- {c.attachUrlTitle} - {c.attachUrlDesc} -
+ + {c.attachUrlTitle} + {c.attachUrlDesc}
void onDeleteSession: (sessionId: string) => Promise - onNavigateRoute: (path: string) => void + // Accepted for call-site parity; navigation lives in the global Cmd+K palette. + onNavigateRoute?: (path: string) => void onOpenSession: (sessionId: string) => void } -type NavKey = 'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts' - -const NAV_ROUTES: readonly { key: NavKey; route: string }[] = [ - { key: 'newChat', route: NEW_CHAT_ROUTE }, - { key: 'settings', route: SETTINGS_ROUTE }, - { key: 'skills', route: SKILLS_ROUTE }, - { key: 'messaging', route: MESSAGING_ROUTE }, - { key: 'artifacts', route: ARTIFACTS_ROUTE } -] - -interface SessionSearchHit { - detail?: string - kind: 'session' - sessionId: string - snippet: string - title: string -} - -interface RouteSearchHit { - detail?: string - kind: 'route' - route: string - title: string -} - -interface SectionSearchHit { - detail?: string - kind: 'section' - section: CommandCenterSection - title: string -} - -type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit - -interface CommandCenterSearchProvider { - id: string - label: string - search: (query: string) => Promise -} - -interface CommandCenterSearchGroup { - id: string - label: string - results: CommandCenterSearchResult[] -} - function formatTimestamp(value?: number | null): string { if (!value) { return '' @@ -116,24 +59,6 @@ function formatTimestamp(value?: number | null): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date) } -function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map) { - const row = sessionsById.get(result.session_id) - const title = row ? sessionTitle(row) : result.session_id - const detail = [result.model, result.source].filter(Boolean).join(' · ') - - return { detail, title } -} - -function matchesSearchQuery(query: string, ...values: Array): boolean { - const normalized = query.trim().toLowerCase() - - if (!normalized) { - return true - } - - return values.some(value => value?.toLowerCase().includes(normalized)) -} - function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -146,13 +71,49 @@ function useDebouncedValue(value: T, delayMs: number): T { return debounced } -export function CommandCenterView({ - initialSection, - onClose, - onDeleteSession, - onNavigateRoute, - onOpenSession -}: CommandCenterViewProps) { +function RowIconButton({ + children, + className, + onClick, + title +}: { + children: ReactNode + className?: string + onClick: (event: MouseEvent) => void + title: string +}) { + return ( + + ) +} + +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/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 2b5c4da494..158360aea8 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -449,7 +449,7 @@ function PlatformDetail({ {hiddenCount > 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 (