Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
+98
-2
@@ -32,6 +32,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import site
|
||||
import sys
|
||||
import signal
|
||||
import tempfile
|
||||
@@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
|
||||
)
|
||||
|
||||
|
||||
def _ensure_windows_gateway_venv_imports() -> None:
|
||||
"""Make detached Windows gateway runs see the Hermes venv packages.
|
||||
|
||||
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
|
||||
to avoid the venv launcher respawning a visible console interpreter. That
|
||||
mode can import the source tree via cwd/PYTHONPATH but still miss optional
|
||||
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
|
||||
Patch the live process before MCP discovery so tool injection does not
|
||||
depend on every launcher preserving PYTHONPATH perfectly.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
|
||||
candidates.append(project_root / "venv")
|
||||
|
||||
seen: set[str] = set()
|
||||
for venv_dir in candidates:
|
||||
try:
|
||||
resolved_venv = venv_dir.resolve()
|
||||
except OSError:
|
||||
resolved_venv = venv_dir
|
||||
venv_key = str(resolved_venv).lower()
|
||||
if venv_key in seen:
|
||||
continue
|
||||
seen.add(venv_key)
|
||||
|
||||
site_packages = resolved_venv / "Lib" / "site-packages"
|
||||
if not site_packages.exists():
|
||||
continue
|
||||
|
||||
project_entry = str(project_root)
|
||||
site_entry = str(site_packages)
|
||||
if project_entry not in sys.path:
|
||||
sys.path.insert(0, project_entry)
|
||||
# addsitepackages() semantics matter here: pywin32, used by the MCP
|
||||
# SDK on Windows, relies on .pth processing to expose pywintypes.
|
||||
site.addsitedir(site_entry)
|
||||
if site_entry in sys.path:
|
||||
sys.path.remove(site_entry)
|
||||
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
|
||||
sys.path.insert(insert_at, site_entry)
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
|
||||
pythonpath = [project_entry, site_entry]
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
pythonpath.append(os.environ["PYTHONPATH"])
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
return
|
||||
|
||||
|
||||
def _gateway_platform_value(platform: Any) -> str:
|
||||
"""Return a normalized gateway platform value for enums or raw strings."""
|
||||
return str(getattr(platform, "value", platform) or "").strip().lower()
|
||||
@@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
watcher_env = os.environ.copy()
|
||||
# This watcher is intentionally outside the running gateway. If it
|
||||
# inherits the gateway marker, `hermes gateway restart` refuses to
|
||||
# run as a self-restart loop guard and the gateway stays stopped.
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
if site_packages.exists():
|
||||
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
pythonpath = [str(project_root), str(site_packages)]
|
||||
if watcher_env.get("PYTHONPATH"):
|
||||
pythonpath.append(watcher_env["PYTHONPATH"])
|
||||
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
return
|
||||
@@ -4268,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; "
|
||||
f"{cmd} gateway restart"
|
||||
)
|
||||
# Same marker scrub as the Windows watcher above: this watcher runs
|
||||
# `hermes gateway restart` from outside the gateway, but it inherits
|
||||
# _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard
|
||||
# refuses to run when that marker is set — silently (DEVNULL), so the
|
||||
# gateway stops and never comes back.
|
||||
watcher_env = os.environ.copy()
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
subprocess.Popen(
|
||||
[setsid_bin, "bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
@@ -4281,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
["bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
@@ -12946,6 +13025,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
# True when the previously enqueued progress line was a terminal
|
||||
# fenced code block — consecutive terminal calls then drop the
|
||||
# repeated "💻 terminal" header and render back-to-back blocks.
|
||||
last_was_terminal_block = [False]
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
@@ -13102,7 +13185,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
):
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_cmd_full = args["command"].rstrip()
|
||||
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
|
||||
# Consecutive terminal calls: drop the repeated
|
||||
# "💻 terminal" header so back-to-back commands render as
|
||||
# adjacent code blocks under a single header.
|
||||
_block_header = (
|
||||
"" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||||
)
|
||||
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
|
||||
# Single-line, capped preview for non-verbose modes.
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
@@ -13113,13 +13202,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_cmd_short = _cmd_short[:_cap - 3] + "..."
|
||||
elif _multiline:
|
||||
_cmd_short = _cmd_short + " ..."
|
||||
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
|
||||
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if _code_block_full is not None:
|
||||
last_was_terminal_block[0] = True
|
||||
progress_queue.put(_code_block_full)
|
||||
return
|
||||
last_was_terminal_block[0] = False
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13144,6 +13235,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# fenced block (built above) instead of the truncated preview.
|
||||
if _code_block_short is not None:
|
||||
msg = _code_block_short
|
||||
last_was_terminal_block[0] = True
|
||||
elif preview:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13151,8 +13243,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if len(preview) > _cap:
|
||||
preview = preview[:_cap - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
last_was_terminal_block[0] = False
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
last_was_terminal_block[0] = False
|
||||
|
||||
# Dedup: collapse consecutive identical progress messages.
|
||||
# Common with execute_code where models iterate with the same
|
||||
@@ -15909,6 +16003,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
_ensure_windows_gateway_venv_imports()
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
|
||||
Reference in New Issue
Block a user