Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
385
gateway/run.py
385
gateway/run.py
@@ -1391,6 +1391,65 @@ class GatewayRunner:
|
||||
except Exception as e:
|
||||
logger.debug("Failed interrupting agent during shutdown: %s", e)
|
||||
|
||||
async def _notify_active_sessions_of_shutdown(self) -> None:
|
||||
"""Send a notification to every chat with an active agent.
|
||||
|
||||
Called at the very start of stop() — adapters are still connected so
|
||||
messages can be delivered. Best-effort: individual send failures are
|
||||
logged and swallowed so they never block the shutdown sequence.
|
||||
"""
|
||||
active = self._snapshot_running_agents()
|
||||
if not active:
|
||||
return
|
||||
|
||||
action = "restarting" if self._restart_requested else "shutting down"
|
||||
hint = (
|
||||
"Your current task will be interrupted. "
|
||||
"Use /retry after restart to continue."
|
||||
if self._restart_requested
|
||||
else "Your current task will be interrupted."
|
||||
)
|
||||
msg = f"⚠️ Gateway {action} — {hint}"
|
||||
|
||||
notified: set = set()
|
||||
for session_key in active:
|
||||
# Parse platform + chat_id from the session key.
|
||||
# Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]
|
||||
parts = session_key.split(":")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
platform_str = parts[2]
|
||||
chat_id = parts[4]
|
||||
|
||||
# Deduplicate: one notification per chat, even if multiple
|
||||
# sessions (different users/threads) share the same chat.
|
||||
dedup_key = (platform_str, chat_id)
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
|
||||
try:
|
||||
platform = Platform(platform_str)
|
||||
adapter = self.adapters.get(platform)
|
||||
if not adapter:
|
||||
continue
|
||||
|
||||
# Include thread_id if present so the message lands in the
|
||||
# correct forum topic / thread.
|
||||
thread_id = parts[5] if len(parts) > 5 else None
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
await adapter.send(chat_id, msg, metadata=metadata)
|
||||
notified.add(dedup_key)
|
||||
logger.info(
|
||||
"Sent shutdown notification to %s:%s",
|
||||
platform_str, chat_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to %s:%s: %s",
|
||||
platform_str, chat_id, e,
|
||||
)
|
||||
|
||||
def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
|
||||
for agent in active_agents.values():
|
||||
try:
|
||||
@@ -2018,6 +2077,10 @@ class GatewayRunner:
|
||||
self._running = False
|
||||
self._draining = True
|
||||
|
||||
# Notify all chats with active agents BEFORE draining.
|
||||
# Adapters are still connected here, so messages can be sent.
|
||||
await self._notify_active_sessions_of_shutdown()
|
||||
|
||||
timeout = self._restart_drain_timeout
|
||||
active_agents, timed_out = await self._drain_active_agents(timeout)
|
||||
if timed_out:
|
||||
@@ -2088,12 +2151,23 @@ class GatewayRunner:
|
||||
|
||||
# Write a clean-shutdown marker so the next startup knows this
|
||||
# wasn't a crash. suspend_recently_active() only needs to run
|
||||
# after unexpected exits — graceful shutdowns already drain
|
||||
# active agents, so there's no stuck-session risk.
|
||||
try:
|
||||
(_hermes_home / ".clean_shutdown").touch()
|
||||
except Exception:
|
||||
pass
|
||||
# after unexpected exits. However, if the drain timed out and
|
||||
# agents were force-interrupted, their sessions may be in an
|
||||
# incomplete state (trailing tool response, no final assistant
|
||||
# message). Skip the marker in that case so the next startup
|
||||
# suspends those sessions — giving users a clean slate instead
|
||||
# of resuming a half-finished tool loop.
|
||||
if not timed_out:
|
||||
try:
|
||||
(_hermes_home / ".clean_shutdown").touch()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.info(
|
||||
"Skipping .clean_shutdown marker — drain timed out with "
|
||||
"interrupted agents; next startup will suspend recently "
|
||||
"active sessions."
|
||||
)
|
||||
|
||||
if self._restart_requested and self._restart_via_service:
|
||||
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
@@ -3978,6 +4052,11 @@ class GatewayRunner:
|
||||
_cached = self._agent_cache.get(session_key)
|
||||
_old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None
|
||||
if _old_agent is not None:
|
||||
try:
|
||||
if hasattr(_old_agent, "shutdown_memory_provider"):
|
||||
_old_agent.shutdown_memory_provider()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(_old_agent, "close"):
|
||||
_old_agent.close()
|
||||
@@ -7500,6 +7579,263 @@ class GatewayRunner:
|
||||
with _lock:
|
||||
self._agent_cache.pop(session_key, None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proxy mode: forward messages to a remote Hermes API server
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_proxy_url(self) -> Optional[str]:
|
||||
"""Return the proxy URL if proxy mode is configured, else None.
|
||||
|
||||
Checks GATEWAY_PROXY_URL env var first (convenient for Docker),
|
||||
then ``gateway.proxy_url`` in config.yaml.
|
||||
"""
|
||||
url = os.getenv("GATEWAY_PROXY_URL", "").strip()
|
||||
if url:
|
||||
return url.rstrip("/")
|
||||
cfg = _load_gateway_config()
|
||||
url = (cfg.get("gateway") or {}).get("proxy_url", "").strip()
|
||||
if url:
|
||||
return url.rstrip("/")
|
||||
return None
|
||||
|
||||
async def _run_agent_via_proxy(
|
||||
self,
|
||||
message: str,
|
||||
context_prompt: str,
|
||||
history: List[Dict[str, Any]],
|
||||
source: "SessionSource",
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
event_message_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward the message to a remote Hermes API server instead of
|
||||
running a local AIAgent.
|
||||
|
||||
When ``GATEWAY_PROXY_URL`` (or ``gateway.proxy_url`` in config.yaml)
|
||||
is set, the gateway becomes a thin relay: it handles platform I/O
|
||||
(encryption, threading, media) and delegates all agent work to the
|
||||
remote server via ``POST /v1/chat/completions`` with SSE streaming.
|
||||
|
||||
This lets a Docker container handle Matrix E2EE while the actual
|
||||
agent runs on the host with full access to local files, memory,
|
||||
skills, and a unified session store.
|
||||
"""
|
||||
try:
|
||||
from aiohttp import ClientSession as _AioClientSession, ClientTimeout
|
||||
except ImportError:
|
||||
return {
|
||||
"final_response": "⚠️ Proxy mode requires aiohttp. Install with: pip install aiohttp",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
proxy_url = self._get_proxy_url()
|
||||
if not proxy_url:
|
||||
return {
|
||||
"final_response": "⚠️ Proxy URL not configured (GATEWAY_PROXY_URL or gateway.proxy_url)",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
|
||||
|
||||
# Build messages in OpenAI chat format --------------------------
|
||||
#
|
||||
# The remote api_server can maintain session continuity via
|
||||
# X-Hermes-Session-Id, so it loads its own history. We only
|
||||
# need to send the current user message. If the remote has
|
||||
# no history for this session yet, include what we have locally
|
||||
# so the first exchange has context.
|
||||
#
|
||||
# We always include the current message. For history, send a
|
||||
# compact version (text-only user/assistant turns) — the remote
|
||||
# handles tool replay and system prompts.
|
||||
api_messages: List[Dict[str, str]] = []
|
||||
|
||||
if context_prompt:
|
||||
api_messages.append({"role": "system", "content": context_prompt})
|
||||
|
||||
for msg in history:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
if role in ("user", "assistant") and content:
|
||||
api_messages.append({"role": role, "content": content})
|
||||
|
||||
api_messages.append({"role": "user", "content": message})
|
||||
|
||||
# HTTP headers ---------------------------------------------------
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if proxy_key:
|
||||
headers["Authorization"] = f"Bearer {proxy_key}"
|
||||
if session_id:
|
||||
headers["X-Hermes-Session-Id"] = session_id
|
||||
|
||||
body = {
|
||||
"model": "hermes-agent",
|
||||
"messages": api_messages,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
# Set up platform streaming if available -------------------------
|
||||
_stream_consumer = None
|
||||
_scfg = getattr(getattr(self, "config", None), "streaming", None)
|
||||
if _scfg is None:
|
||||
from gateway.config import StreamingConfig
|
||||
_scfg = StreamingConfig()
|
||||
|
||||
platform_key = _platform_config_key(source.platform)
|
||||
user_config = _load_gateway_config()
|
||||
from gateway.display_config import resolve_display_setting
|
||||
_plat_streaming = resolve_display_setting(
|
||||
user_config, platform_key, "streaming"
|
||||
)
|
||||
_streaming_enabled = (
|
||||
_scfg.enabled and _scfg.transport != "off"
|
||||
if _plat_streaming is None
|
||||
else bool(_plat_streaming)
|
||||
)
|
||||
|
||||
if source.thread_id:
|
||||
_thread_metadata: Optional[Dict[str, Any]] = {"thread_id": source.thread_id}
|
||||
else:
|
||||
_thread_metadata = None
|
||||
|
||||
if _streaming_enabled:
|
||||
try:
|
||||
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
||||
from gateway.config import Platform
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
|
||||
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
|
||||
if source.platform == Platform.MATRIX:
|
||||
_effective_cursor = ""
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
cursor=_effective_cursor,
|
||||
)
|
||||
_stream_consumer = GatewayStreamConsumer(
|
||||
adapter=_adapter,
|
||||
chat_id=source.chat_id,
|
||||
config=_consumer_cfg,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception as _sc_err:
|
||||
logger.debug("Proxy: could not set up stream consumer: %s", _sc_err)
|
||||
|
||||
# Run the stream consumer task in the background
|
||||
stream_task = None
|
||||
if _stream_consumer:
|
||||
stream_task = asyncio.create_task(_stream_consumer.run())
|
||||
|
||||
# Send typing indicator
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
try:
|
||||
await _adapter.send_typing(source.chat_id, metadata=_thread_metadata)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Make the HTTP request with SSE streaming -----------------------
|
||||
full_response = ""
|
||||
_start = time.time()
|
||||
|
||||
try:
|
||||
_timeout = ClientTimeout(total=0, sock_read=1800)
|
||||
async with _AioClientSession(timeout=_timeout) as session:
|
||||
async with session.post(
|
||||
f"{proxy_url}/v1/chat/completions",
|
||||
json=body,
|
||||
headers=headers,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
logger.warning(
|
||||
"Proxy error (%d) from %s: %s",
|
||||
resp.status, proxy_url, error_text[:500],
|
||||
)
|
||||
return {
|
||||
"final_response": f"⚠️ Proxy error ({resp.status}): {error_text[:300]}",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
# Parse SSE stream
|
||||
buffer = ""
|
||||
async for chunk in resp.content.iter_any():
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
buffer += text
|
||||
|
||||
# Process complete SSE lines
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
choices = obj.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
full_response += content
|
||||
if _stream_consumer:
|
||||
_stream_consumer.on_delta(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Proxy connection error to %s: %s", proxy_url, e)
|
||||
if not full_response:
|
||||
return {
|
||||
"final_response": f"⚠️ Proxy connection error: {e}",
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"tools": [],
|
||||
}
|
||||
# Partial response — return what we got
|
||||
finally:
|
||||
# Finalize stream consumer
|
||||
if _stream_consumer:
|
||||
_stream_consumer.finish()
|
||||
if stream_task:
|
||||
try:
|
||||
await asyncio.wait_for(stream_task, timeout=5.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
stream_task.cancel()
|
||||
|
||||
_elapsed = time.time() - _start
|
||||
logger.info(
|
||||
"proxy response: url=%s session=%s time=%.1fs response=%d chars",
|
||||
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
|
||||
)
|
||||
|
||||
return {
|
||||
"final_response": full_response or "(No response from remote agent)",
|
||||
"messages": [
|
||||
{"role": "user", "content": message},
|
||||
{"role": "assistant", "content": full_response},
|
||||
],
|
||||
"api_calls": 1,
|
||||
"tools": [],
|
||||
"history_offset": len(history),
|
||||
"session_id": session_id,
|
||||
"response_previewed": _stream_consumer is not None and bool(full_response),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_agent(
|
||||
self,
|
||||
message: str,
|
||||
@@ -7523,6 +7859,18 @@ class GatewayRunner:
|
||||
This is run in a thread pool to not block the event loop.
|
||||
Supports interruption via new messages.
|
||||
"""
|
||||
# ---- Proxy mode: delegate to remote API server ----
|
||||
if self._get_proxy_url():
|
||||
return await self._run_agent_via_proxy(
|
||||
message=message,
|
||||
context_prompt=context_prompt,
|
||||
history=history,
|
||||
source=source,
|
||||
session_id=session_id,
|
||||
session_key=session_key,
|
||||
event_message_id=event_message_id,
|
||||
)
|
||||
|
||||
from run_agent import AIAgent
|
||||
import queue
|
||||
|
||||
@@ -9010,8 +9358,18 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
# Track whether a signal initiated the shutdown (vs. internal request).
|
||||
# When an unexpected SIGTERM kills the gateway, we exit non-zero so
|
||||
# systemd's Restart=on-failure revives the process. systemctl stop
|
||||
# is safe: systemd tracks stop-requested state independently of exit
|
||||
# code, so Restart= never fires for a deliberate stop.
|
||||
_signal_initiated_shutdown = False
|
||||
|
||||
# Set up signal handlers
|
||||
def shutdown_signal_handler():
|
||||
nonlocal _signal_initiated_shutdown
|
||||
_signal_initiated_shutdown = True
|
||||
logger.info("Received SIGTERM/SIGINT — initiating shutdown")
|
||||
asyncio.create_task(runner.stop())
|
||||
|
||||
def restart_signal_handler():
|
||||
@@ -9081,6 +9439,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
if runner.exit_code is not None:
|
||||
raise SystemExit(runner.exit_code)
|
||||
|
||||
# When a signal (SIGTERM/SIGINT) caused the shutdown and it wasn't a
|
||||
# planned restart (/restart, /update, SIGUSR1), exit non-zero so
|
||||
# systemd's Restart=on-failure revives the process. This covers:
|
||||
# - hermes update killing the gateway mid-work
|
||||
# - External kill commands
|
||||
# - WSL2/container runtime sending unexpected signals
|
||||
# systemctl stop is safe: systemd tracks "stop requested" state
|
||||
# independently of exit code, so Restart= never fires for it.
|
||||
if _signal_initiated_shutdown and not runner._restart_requested:
|
||||
logger.info(
|
||||
"Exiting with code 1 (signal-initiated shutdown without restart "
|
||||
"request) so systemd Restart=on-failure can revive the gateway."
|
||||
)
|
||||
return False # → sys.exit(1) in the caller
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user