Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-12 13:18:55 -05:00
131 changed files with 12350 additions and 1164 deletions

View File

@@ -76,7 +76,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
# Resolve Hermes home directory (respects HERMES_HOME override)
from hermes_constants import get_hermes_home
from utils import atomic_yaml_write
from utils import atomic_yaml_write, is_truthy_value
_hermes_home = get_hermes_home()
# Load environment variables from ~/.hermes/.env first.
@@ -206,6 +206,15 @@ if _config_path.exists():
except Exception:
pass # Non-fatal; gateway can still run with .env values
# Apply IPv4 preference if configured (before any HTTP clients are created).
try:
from hermes_constants import apply_ipv4_preference
_network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {})
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception:
pass
# Validate config structure early — log warnings so gateway operators see problems
try:
from hermes_cli.config import print_config_warnings
@@ -867,13 +876,47 @@ class GatewayRunner:
"api_mode": override.get("api_mode"),
}
if override_runtime.get("api_key"):
logger.debug(
"Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s",
(resolved_session_key or "")[:30], model, override_model,
override_runtime.get("provider"),
)
return override_model, override_runtime
# Override exists but has no api_key — fall through to env-based
# resolution and apply model/provider from the override on top.
logger.debug(
"Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
(resolved_session_key or "")[:30], model, override_model,
)
else:
logger.debug(
"No session model override: session=%s config_model=%s override_keys=%s",
(resolved_session_key or "")[:30], model,
list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]",
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
if override and resolved_session_key:
model, runtime_kwargs = self._apply_session_model_override(
resolved_session_key, model, runtime_kwargs
)
# When the config has no model.default but a provider was resolved
# (e.g. user ran `hermes auth add openai-codex` without `hermes model`),
# fall back to the provider's first catalog model so the API call
# doesn't fail with "model must be a non-empty string".
if not model and runtime_kwargs.get("provider"):
try:
from hermes_cli.models import get_default_model_for_provider
model = get_default_model_for_provider(runtime_kwargs["provider"])
if model:
logger.info(
"No model configured — defaulting to %s for provider %s",
model, runtime_kwargs["provider"],
)
except Exception:
pass
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
@@ -916,6 +959,12 @@ class GatewayRunner:
adapter.fatal_error_code or "unknown",
adapter.fatal_error_message or "unknown error",
)
self._update_platform_runtime_status(
adapter.platform.value,
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
existing = self.adapters.get(adapter.platform)
if existing is adapter:
@@ -993,6 +1042,25 @@ class GatewayRunner:
)
except Exception:
pass
def _update_platform_runtime_status(
self,
platform: str,
*,
platform_state: Optional[str] = None,
error_code: Optional[str] = None,
error_message: Optional[str] = None,
) -> None:
try:
from gateway.status import write_runtime_status
write_runtime_status(
platform=platform,
platform_state=platform_state,
error_code=error_code,
error_message=error_message,
)
except Exception:
pass
@staticmethod
def _load_prefill_messages() -> List[Dict[str, Any]]:
@@ -1426,6 +1494,7 @@ class GatewayRunner:
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
"FEISHU_ALLOWED_USERS",
"WECOM_ALLOWED_USERS",
"WECOM_CALLBACK_ALLOWED_USERS",
"WEIXIN_ALLOWED_USERS",
"BLUEBUBBLES_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
@@ -1439,6 +1508,7 @@ class GatewayRunner:
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
"FEISHU_ALLOW_ALL_USERS",
"WECOM_ALLOW_ALL_USERS",
"WECOM_CALLBACK_ALLOW_ALL_USERS",
"WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_ALLOW_ALL_USERS")
)
@@ -1465,12 +1535,25 @@ class GatewayRunner:
# This prevents stuck sessions from being blindly resumed on restart,
# which can create an unrecoverable loop (#7536). Suspended sessions
# auto-reset on the next incoming message, giving the user a clean start.
try:
suspended = self.session_store.suspend_recently_active()
if suspended:
logger.info("Suspended %d in-flight session(s) from previous run", suspended)
except Exception as e:
logger.warning("Session suspension on startup failed: %s", e)
#
# SKIP suspension after a clean (graceful) shutdown — the previous
# process already drained active agents, so sessions aren't stuck.
# This prevents unwanted auto-resets after `hermes update`,
# `hermes gateway restart`, or `/restart`.
_clean_marker = _hermes_home / ".clean_shutdown"
if _clean_marker.exists():
logger.info("Previous gateway exited cleanly — skipping session suspension")
try:
_clean_marker.unlink()
except Exception:
pass
else:
try:
suspended = self.session_store.suspend_recently_active()
if suspended:
logger.info("Suspended %d in-flight session(s) from previous run", suspended)
except Exception as e:
logger.warning("Session suspension on startup failed: %s", e)
connected_count = 0
enabled_platform_count = 0
@@ -1496,16 +1579,34 @@ class GatewayRunner:
# Try to connect
logger.info("Connecting to %s...", platform.value)
self._update_platform_runtime_status(
platform.value,
platform_state="connecting",
error_code=None,
error_message=None,
)
try:
success = await adapter.connect()
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
connected_count += 1
self._update_platform_runtime_status(
platform.value,
platform_state="connected",
error_code=None,
error_message=None,
)
logger.info("%s connected", platform.value)
else:
logger.warning("%s failed to connect", platform.value)
if adapter.has_fatal_error:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying" if adapter.fatal_error_retryable else "fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
target = (
startup_retryable_errors
if adapter.fatal_error_retryable
@@ -1522,6 +1623,12 @@ class GatewayRunner:
"next_retry": time.monotonic() + 30,
}
else:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message="failed to connect",
)
startup_retryable_errors.append(
f"{platform.value}: failed to connect"
)
@@ -1533,6 +1640,12 @@ class GatewayRunner:
}
except Exception as e:
logger.error("%s error: %s", platform.value, e)
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message=str(e),
)
startup_retryable_errors.append(f"{platform.value}: {e}")
# Unexpected exceptions are typically transient — queue for retry
self._failed_platforms[platform] = {
@@ -1811,6 +1924,12 @@ class GatewayRunner:
self._sync_voice_mode_state_to_adapter(adapter)
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
self._update_platform_runtime_status(
platform.value,
platform_state="connected",
error_code=None,
error_message=None,
)
logger.info("%s reconnected successfully", platform.value)
# Rebuild channel directory with the new adapter
@@ -1822,12 +1941,24 @@ class GatewayRunner:
else:
# Check if the failure is non-retryable
if adapter.has_fatal_error and not adapter.fatal_error_retryable:
self._update_platform_runtime_status(
platform.value,
platform_state="fatal",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message,
)
logger.warning(
"Reconnect %s: non-retryable error (%s), removing from retry queue",
platform.value, adapter.fatal_error_message,
)
del self._failed_platforms[platform]
else:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=adapter.fatal_error_code,
error_message=adapter.fatal_error_message or "failed to reconnect",
)
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
info["attempts"] = attempt
info["next_retry"] = time.monotonic() + backoff
@@ -1836,6 +1967,12 @@ class GatewayRunner:
platform.value, backoff,
)
except Exception as e:
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
error_code=None,
error_message=str(e),
)
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
info["attempts"] = attempt
info["next_retry"] = time.monotonic() + backoff
@@ -1942,6 +2079,15 @@ class GatewayRunner:
from gateway.status import remove_pid_file
remove_pid_file()
# 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
if self._restart_requested and self._restart_via_service:
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
self._exit_reason = self._exit_reason or "Gateway restart requested"
@@ -2043,6 +2189,16 @@ class GatewayRunner:
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM_CALLBACK:
from gateway.platforms.wecom_callback import (
WecomCallbackAdapter,
check_wecom_callback_requirements,
)
if not check_wecom_callback_requirements():
logger.warning("WeComCallback: aiohttp/httpx not installed")
return None
return WecomCallbackAdapter(config)
elif platform == Platform.WECOM:
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
if not check_wecom_requirements():
@@ -2132,6 +2288,7 @@ class GatewayRunner:
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
}
@@ -2148,6 +2305,7 @@ class GatewayRunner:
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
}
@@ -3454,8 +3612,18 @@ class GatewayRunner:
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
session_entry.session_id = agent_result["session_id"]
# Prepend reasoning/thinking if display is enabled
if getattr(self, "_show_reasoning", False) and response:
# Prepend reasoning/thinking if display is enabled (per-platform)
try:
from gateway.display_config import resolve_display_setting as _rds
_show_reasoning_effective = _rds(
_load_gateway_config(),
_platform_config_key(source.platform),
"show_reasoning",
getattr(self, "_show_reasoning", False),
)
except Exception:
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
if _show_reasoning_effective and response:
last_reasoning = agent_result.get("last_reasoning")
if last_reasoning:
# Collapse long reasoning to keep messages readable
@@ -3860,9 +4028,16 @@ class GatewayRunner:
except Exception:
pass
# Append a random tip to the reset message
try:
from hermes_cli.tips import get_random_tip
_tip_line = f"\n✦ Tip: {get_random_tip()}"
except Exception:
_tip_line = ""
if session_info:
return f"{header}\n\n{session_info}"
return header
return f"{header}\n\n{session_info}{_tip_line}"
return f"{header}{_tip_line}"
async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile — show active profile name and home directory."""
@@ -4282,6 +4457,11 @@ class GatewayRunner:
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh
# agent from the override rather than relying on the
# stale cache signature to trigger a rebuild.
_self._evict_cached_agent(_session_key)
# Build confirmation text
plabel = result.provider_label or result.target_provider
lines = [f"Model switched to `{result.new_model}`"]
@@ -4395,6 +4575,10 @@ class GatewayRunner:
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Persist to config if --global
if persist_global:
try:
@@ -5531,16 +5715,20 @@ class GatewayRunner:
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`"
)
# Display toggle
# Display toggle (per-platform)
platform_key = _platform_config_key(event.source.platform)
if args in ("show", "on"):
self._show_reasoning = True
_save_config_key("display.show_reasoning", True)
return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
return (
"🧠 ✓ Reasoning display: **ON**\n"
f"Model thinking will be shown before each response on **{platform_key}**."
)
if args in ("hide", "off"):
self._show_reasoning = False
_save_config_key("display.show_reasoning", False)
return "🧠 ✓ Reasoning display: **OFF**"
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**"
# Effort level change
effort = args.strip()
@@ -5643,11 +5831,14 @@ class GatewayRunner:
Gated by ``display.tool_progress_command`` in config.yaml (default off).
When enabled, cycles the tool progress mode through off → new → all →
verbose → off, same as the CLI.
verbose → off for the *current platform*. The setting is saved to
``display.platforms.<platform>.tool_progress`` so each channel can
have its own verbosity level independently.
"""
import yaml
config_path = _hermes_home / "config.yaml"
platform_key = _platform_config_key(event.source.platform)
# --- check config gate ------------------------------------------------
try:
@@ -5666,7 +5857,7 @@ class GatewayRunner:
"display:\n tool_progress_command: true\n```"
)
# --- cycle mode -------------------------------------------------------
# --- cycle mode (per-platform) ----------------------------------------
cycle = ["off", "new", "all", "verbose"]
descriptions = {
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
@@ -5675,32 +5866,40 @@ class GatewayRunner:
"verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.",
}
raw_progress = user_config.get("display", {}).get("tool_progress", "all")
# YAML 1.1 parses bare "off" as boolean False — normalise back
if raw_progress is False:
current = "off"
elif raw_progress is True:
current = "all"
else:
current = str(raw_progress).lower()
# Read current effective mode for this platform via the resolver
from gateway.display_config import resolve_display_setting
current = resolve_display_setting(user_config, platform_key, "tool_progress", "all")
if current not in cycle:
current = "all"
idx = (cycle.index(current) + 1) % len(cycle)
new_mode = cycle[idx]
# Save to config.yaml
# Save to display.platforms.<platform>.tool_progress
try:
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
user_config["display"] = {}
user_config["display"]["tool_progress"] = new_mode
display = user_config["display"]
if "platforms" not in display or not isinstance(display.get("platforms"), dict):
display["platforms"] = {}
if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
display["platforms"][platform_key] = {}
display["platforms"][platform_key]["tool_progress"] = new_mode
atomic_yaml_write(config_path, user_config)
return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
return (
f"{descriptions[new_mode]}\n"
f"_(saved for **{platform_key}** — takes effect on next message)_"
)
except Exception as e:
logger.warning("Failed to save tool_progress mode: %s", e)
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
"""Handle /compress command -- manually compress conversation context.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else.
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(session_entry.session_id)
@@ -5708,6 +5907,9 @@ class GatewayRunner:
if not history or len(history) < 4:
return "Not enough conversation to compress (need at least 4 messages)."
# Extract optional focus topic from command args
focus_topic = (event.get_command_args() or "").strip() or None
try:
from run_agent import AIAgent
from agent.manual_compression_feedback import summarize_manual_compression
@@ -5749,7 +5951,7 @@ class GatewayRunner:
loop = asyncio.get_event_loop()
compressed, _ = await loop.run_in_executor(
None,
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens)
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic)
)
# _compress_context already calls end_session() on the old session
@@ -5773,7 +5975,10 @@ class GatewayRunner:
approx_tokens,
new_tokens,
)
lines = [f"🗜️ {summary['headline']}", summary["token_line"]]
lines = [f"🗜️ {summary['headline']}"]
if focus_topic:
lines.append(f"Focus: \"{focus_topic}\"")
lines.append(summary["token_line"])
if summary["note"]:
lines.append(summary["note"])
return "\n".join(lines)
@@ -6315,7 +6520,7 @@ class GatewayRunner:
Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP,
Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX,
Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK,
Platform.FEISHU, Platform.WECOM, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
})
async def _handle_update_command(self, event: MessageEvent) -> str:
@@ -6560,8 +6765,12 @@ class GatewayRunner:
if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval:
await _flush_buffer()
# Check for prompts
if prompt_path.exists() and session_key:
# Check for prompts — only forward if we haven't already sent
# one that's still awaiting a response. Without this guard the
# watcher would re-read the same .update_prompt.json every poll
# cycle and spam the user with duplicate prompt messages.
if (prompt_path.exists() and session_key
and not self._update_prompt_pending.get(session_key)):
try:
prompt_data = json.loads(prompt_path.read_text())
prompt_text = prompt_data.get("prompt", "")
@@ -6593,6 +6802,11 @@ class GatewayRunner:
f"or type your answer directly."
)
self._update_prompt_pending[session_key] = True
# Remove the prompt file so it isn't re-read on the
# next poll cycle. The update process only needs
# .update_response to continue — it doesn't re-check
# .update_prompt.json while waiting.
prompt_path.unlink(missing_ok=True)
logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80])
except (json.JSONDecodeError, OSError) as e:
logger.debug("Failed to read update prompt: %s", e)
@@ -6720,6 +6934,7 @@ class GatewayRunner:
thread_id=str(context.source.thread_id) if context.source.thread_id else "",
user_id=str(context.source.user_id) if context.source.user_id else "",
user_name=str(context.source.user_name) if context.source.user_name else "",
session_key=context.session_key,
)
def _clear_session_env(self, tokens: list) -> None:
@@ -6965,7 +7180,9 @@ class GatewayRunner:
if session.exited:
# --- Agent-triggered completion: inject synthetic message ---
if agent_notify:
# Skip if the agent already consumed the result via wait/poll/log
from tools.process_registry import process_registry as _pr_check
if agent_notify and not _pr_check.is_completion_consumed(session_id):
from tools.ansi_strip import strip_ansi
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
synth_text = (
@@ -7161,31 +7378,27 @@ class GatewayRunner:
from hermes_cli.tools_config import _get_platform_tools
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
display_config = user_config.get("display", {})
if not isinstance(display_config, dict):
display_config = {}
# Per-platform display settings — resolve via display_config module
# which checks display.platforms.<platform>.<key> first, then
# display.<key> global, then built-in platform defaults.
from gateway.display_config import resolve_display_setting
# Apply tool preview length config (0 = no limit)
try:
from agent.display import set_tool_preview_max_len
_tpl = user_config.get("display", {}).get("tool_preview_length", 0)
_tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0)
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
except Exception:
pass
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
# Falls back to env vars for backward compatibility.
# YAML 1.1 parses bare `off` as boolean False — normalise before
# the `or` chain so it doesn't silently fall through to "all".
#
# Per-platform overrides (display.tool_progress_overrides) take
# priority over the global setting — e.g. Signal users can set
# tool_progress to "off" while keeping Telegram on "all".
_display_cfg = user_config.get("display", {})
_overrides = _display_cfg.get("tool_progress_overrides", {})
_raw_tp = _overrides.get(platform_key)
if _raw_tp is None:
_raw_tp = _display_cfg.get("tool_progress")
if _raw_tp is False:
_raw_tp = "off"
# Tool progress mode — resolved per-platform with env var fallback
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
progress_mode = (
_raw_tp
_resolved_tp
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all"
)
@@ -7193,6 +7406,16 @@ class GatewayRunner:
# so each progress line would be sent as a separate message.
from gateway.config import Platform
tool_progress_enabled = progress_mode != "off" and source.platform != Platform.WEBHOOK
# Natural assistant status messages are intentionally independent from
# tool progress and token streaming. Users can keep tool_progress quiet
# in chat platforms while opting into concise mid-turn updates.
interim_assistant_messages_enabled = (
source.platform != Platform.WEBHOOK
and is_truthy_value(
display_config.get("interim_assistant_messages"),
default=True,
)
)
# Queue for progress messages (thread-safe)
progress_queue = queue.Queue() if tool_progress_enabled else None
@@ -7462,8 +7685,8 @@ class GatewayRunner:
# `_resolve_turn_agent_config(message, …)`.
nonlocal message
# Pass session_key to process registry via env var so background
# processes can be mapped back to this gateway session
# session_key is now set via contextvars in _set_session_env()
# (concurrency-safe). Keep os.environ as fallback for CLI/cron.
os.environ["HERMES_SESSION_KEY"] = session_key or ""
# Read from env var or use default (same as CLI)
@@ -7493,6 +7716,10 @@ class GatewayRunner:
session_key=session_key,
user_config=user_config,
)
logger.debug(
"run_agent resolved: model=%s provider=%s session=%s",
model, runtime_kwargs.get("provider"), (session_key or "")[:30],
)
except Exception as exc:
return {
"final_response": f"⚠️ Provider authentication failed: {exc}",
@@ -7505,7 +7732,7 @@ class GatewayRunner:
reasoning_config = self._load_reasoning_config()
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
# Set up streaming consumer if enabled
# Set up stream consumer for token streaming or interim commentary.
_stream_consumer = None
_stream_delta_cb = None
_scfg = getattr(getattr(self, 'config', None), 'streaming', None)
@@ -7513,7 +7740,22 @@ class GatewayRunner:
from gateway.config import StreamingConfig
_scfg = StreamingConfig()
if _scfg.enabled and _scfg.transport != "off":
# Per-platform streaming gate: display.platforms.<plat>.streaming
# can disable streaming for specific platforms even when the global
# streaming config is enabled.
_plat_streaming = resolve_display_setting(
user_config, platform_key, "streaming"
)
# None = no per-platform override → follow global config
_streaming_enabled = (
_scfg.enabled and _scfg.transport != "off"
if _plat_streaming is None
else bool(_plat_streaming)
)
_want_stream_deltas = _streaming_enabled
_want_interim_messages = interim_assistant_messages_enabled
_want_interim_consumer = _want_interim_messages
if _want_stream_deltas or _want_interim_consumer:
try:
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
_adapter = self.adapters.get(source.platform)
@@ -7529,11 +7771,33 @@ class GatewayRunner:
config=_consumer_cfg,
metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
)
_stream_delta_cb = _stream_consumer.on_delta
if _want_stream_deltas:
_stream_delta_cb = _stream_consumer.on_delta
stream_consumer_holder[0] = _stream_consumer
except Exception as _sc_err:
logger.debug("Could not set up stream consumer: %s", _sc_err)
def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
if _stream_consumer is not None:
if already_streamed:
_stream_consumer.on_segment_break()
else:
_stream_consumer.on_commentary(text)
return
if already_streamed or not _status_adapter or not str(text or "").strip():
return
try:
asyncio.run_coroutine_threadsafe(
_status_adapter.send(
_status_chat_id,
text,
metadata=_status_thread_metadata,
),
_loop_for_step,
)
except Exception as _e:
logger.debug("interim_assistant_callback error: %s", _e)
turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs)
# Check agent cache — reuse the AIAgent from the previous message
@@ -7591,6 +7855,7 @@ class GatewayRunner:
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
agent.status_callback = _status_callback_sync
agent.reasoning_config = reasoning_config
agent.service_tier = self._service_tier
@@ -7894,6 +8159,7 @@ class GatewayRunner:
"output_tokens": _output_toks,
"model": _resolved_model,
"session_id": effective_session_id,
"response_previewed": result.get("response_previewed", False),
}
# Start progress message sender if enabled
@@ -7928,26 +8194,51 @@ class GatewayRunner:
tracking_task = asyncio.create_task(track_agent())
# Monitor for interrupts from the adapter (new messages arriving)
# Monitor for interrupts from the adapter (new messages arriving).
# This is the PRIMARY interrupt path for regular text messages —
# Level 1 (base.py) catches them before _handle_message() is reached,
# so the Level 2 running_agent.interrupt() path never fires.
# The inactivity poll loop below has a BACKUP check in case this
# task dies (no error handling = silent death = lost interrupts).
_interrupt_detected = asyncio.Event() # shared with backup check
async def monitor_for_interrupt():
adapter = self.adapters.get(source.platform)
if not adapter or not session_key:
if not session_key:
return
while True:
await asyncio.sleep(0.2) # Check every 200ms
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
pending_event = adapter.get_pending_message(session_key)
pending_text = pending_event.text if pending_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
break
try:
# Re-resolve adapter each iteration so reconnects don't
# leave us holding a stale reference.
_adapter = self.adapters.get(source.platform)
if not _adapter:
continue
# Check if adapter has a pending interrupt for this session.
# Must use session_key (build_session_key output) — NOT
# source.chat_id — because the adapter stores interrupt events
# under the full session key.
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
agent = agent_holder[0]
if agent:
# Peek at the pending message text WITHOUT consuming it.
# The message must remain in _pending_messages so the
# post-run dequeue at _dequeue_pending_event() can
# retrieve the full MessageEvent (with media metadata).
# If we pop here, a race exists: the agent may finish
# before checking _interrupt_requested, and the message
# is lost — neither the interrupt path nor the dequeue
# path finds it.
_peek_event = _adapter._pending_messages.get(session_key)
pending_text = _peek_event.text if _peek_event else None
logger.debug("Interrupt detected from adapter, signaling agent...")
agent.interrupt(pending_text)
_interrupt_detected.set()
break
except asyncio.CancelledError:
raise
except Exception as _mon_err:
logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err)
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
@@ -8012,8 +8303,34 @@ class GatewayRunner:
_POLL_INTERVAL = 5.0
if _agent_timeout is None:
# Unlimited — just await the result.
response = await _executor_task
# Unlimited — still poll periodically for backup interrupt
# detection in case monitor_for_interrupt() silently died.
response = None
while True:
done, _ = await asyncio.wait(
{_executor_task}, timeout=_POLL_INTERVAL
)
if done:
response = _executor_task.result()
break
# Backup interrupt check: if the monitor task died or
# missed the interrupt, catch it here.
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter._pending_messages.get(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
else:
# Poll loop: check the agent's built-in activity tracker
# (updated by _touch_activity() on every tool call, API
@@ -8057,6 +8374,23 @@ class GatewayRunner:
if _idle_secs >= _agent_timeout:
_inactivity_timeout = True
break
# Backup interrupt check (same as unlimited path).
if not _interrupt_detected.is_set() and session_key:
_backup_adapter = self.adapters.get(source.platform)
_backup_agent = agent_holder[0]
if (_backup_adapter and _backup_agent
and hasattr(_backup_adapter, 'has_pending_interrupt')
and _backup_adapter.has_pending_interrupt(session_key)):
_bp_event = _backup_adapter._pending_messages.get(session_key)
_bp_text = _bp_event.text if _bp_event else None
logger.info(
"Backup interrupt detected for session %s "
"(monitor task state: %s)",
session_key[:20],
"done" if interrupt_monitor.done() else "running",
)
_backup_agent.interrupt(_bp_text)
_interrupt_detected.set()
if _inactivity_timeout:
# Build a diagnostic summary from the agent's activity tracker.
@@ -8216,12 +8550,36 @@ class GatewayRunner:
# response before processing the queued follow-up.
# Skip if streaming already delivered it.
_sc = stream_consumer_holder[0]
_already_streamed = _sc and getattr(_sc, "already_sent", False)
if _sc and stream_task:
try:
await asyncio.wait_for(stream_task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
stream_task.cancel()
try:
await stream_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.debug("Stream consumer wait before queued message failed: %s", e)
_response_previewed = bool(result.get("response_previewed"))
_already_streamed = bool(
_sc
and (
getattr(_sc, "final_response_sent", False)
or (
_response_previewed
and getattr(_sc, "already_sent", False)
)
)
)
first_response = result.get("final_response", "")
if first_response and not _already_streamed:
try:
await adapter.send(source.chat_id, first_response,
metadata={"thread_id": source.thread_id} if source.thread_id else None)
await adapter.send(
source.chat_id,
first_response,
metadata=_status_thread_metadata,
)
except Exception as e:
logger.warning("Failed to send first response before queued message: %s", e)
# else: interrupted — discard the interrupted response ("Operation
@@ -8294,8 +8652,15 @@ class GatewayRunner:
# message is new content the user hasn't seen, and it must reach
# them even if streaming had sent earlier partial output.
_sc = stream_consumer_holder[0]
if _sc and _sc.already_sent and isinstance(response, dict):
if not response.get("failed"):
if _sc and isinstance(response, dict) and not response.get("failed"):
_response_previewed = bool(response.get("response_previewed"))
if (
getattr(_sc, "final_response_sent", False)
or (
_response_previewed
and getattr(_sc, "already_sent", False)
)
):
response["already_sent"] = True
return response
@@ -8444,23 +8809,11 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
except Exception:
pass
# Centralized logging — agent.log (INFO+) and errors.log (WARNING+).
# Centralized logging — agent.log (INFO+), errors.log (WARNING+),
# and gateway.log (INFO+, gateway-component records only).
# Idempotent, so repeated calls from AIAgent.__init__ won't duplicate.
from hermes_logging import setup_logging
log_dir = setup_logging(hermes_home=_hermes_home, mode="gateway")
# Gateway-specific rotating log — captures all gateway-level messages
# (session management, platform adapters, slash commands, etc.).
from agent.redact import RedactingFormatter
from hermes_logging import _add_rotating_handler
_add_rotating_handler(
logging.getLogger(),
log_dir / 'gateway.log',
level=logging.INFO,
max_bytes=5 * 1024 * 1024,
backup_count=3,
formatter=RedactingFormatter('%(asctime)s %(levelname)s %(name)s: %(message)s'),
)
setup_logging(hermes_home=_hermes_home, mode="gateway")
# Optional stderr handler — level driven by -v/-q flags on the CLI.
# verbosity=None (-q/--quiet): no stderr output
@@ -8468,6 +8821,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# verbosity=1 (-v): INFO and above
# verbosity=2+ (-vv/-vvv): DEBUG
if verbosity is not None:
from agent.redact import RedactingFormatter
_stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
_stderr_handler = logging.StreamHandler()
_stderr_handler.setLevel(_stderr_level)