Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+41 -4
View File
@@ -71,6 +71,35 @@ def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int:
return default
_TRUE_REQUEST_BOOL_STRINGS = frozenset({"1", "true", "yes", "on"})
_FALSE_REQUEST_BOOL_STRINGS = frozenset({"0", "false", "no", "off"})
def _coerce_request_bool(value: Any, default: bool = False) -> bool:
"""Normalize boolean-like API payload values.
External clients should send real JSON booleans, but some OpenAI-compatible
frontends and middleware serialize flags like ``stream`` as strings. Using
Python truthiness on those values misroutes requests because ``"false"`` is
still truthy. Treat only explicit bool-ish scalars as booleans; everything
else falls back to the caller's default.
"""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in _TRUE_REQUEST_BOOL_STRINGS:
return True
if normalized in _FALSE_REQUEST_BOOL_STRINGS:
return False
return default
if isinstance(value, (int, float)):
return bool(value)
return default
def _normalize_chat_content(
content: Any, *, _max_depth: int = 10, _depth: int = 0,
) -> str:
@@ -481,7 +510,12 @@ else:
body_limit_middleware = None # type: ignore[assignment]
_SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "0",
"Referrer-Policy": "no-referrer",
}
@@ -1005,7 +1039,7 @@ class APIServerAdapter(BasePlatformAdapter):
status=400,
)
stream = body.get("stream", False)
stream = _coerce_request_bool(body.get("stream"), default=False)
# Extract system message (becomes ephemeral system prompt layered ON TOP of core)
system_prompt = None
@@ -2082,7 +2116,7 @@ class APIServerAdapter(BasePlatformAdapter):
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
conversation = body.get("conversation")
store = body.get("store", True)
store = _coerce_request_bool(body.get("store"), default=True)
# conversation and previous_response_id are mutually exclusive
if conversation and previous_response_id:
@@ -2165,7 +2199,7 @@ class APIServerAdapter(BasePlatformAdapter):
# groups the entire conversation under one session entry.
session_id = stored_session_id or str(uuid.uuid4())
stream = bool(body.get("stream", False))
stream = _coerce_request_bool(body.get("stream"), default=False)
if stream:
# Streaming branch — emit OpenAI Responses SSE events as the
# agent runs so frontends can render text deltas and tool
@@ -3228,7 +3262,10 @@ class APIServerAdapter(BasePlatformAdapter):
status=409,
)
resolve_all = bool(body.get("all") or body.get("resolve_all"))
resolve_all = (
_coerce_request_bool(body.get("all"), default=False)
or _coerce_request_bool(body.get("resolve_all"), default=False)
)
try:
from tools.approval import resolve_gateway_approval
+8 -1
View File
@@ -2014,6 +2014,13 @@ class BasePlatformAdapter(ABC):
text = f"{caption}\n{text}"
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
def prepare_tts_text(self, text: str) -> str:
"""Prepare text for TTS. Override to filter tool output, code, etc.
Default strips markdown formatting and truncates to 4000 chars.
"""
return re.sub(r'[*_`#\[\]()]', '', text)[:4000].strip()
async def play_tts(
self,
chat_id: str,
@@ -3144,7 +3151,7 @@ class BasePlatformAdapter(ABC):
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
import json as _json
speech_text = re.sub(r'[*_`#\[\]()]', '', text_content)[:4000].strip()
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
raise ValueError("Empty text after markdown cleanup")
tts_result_str = await asyncio.to_thread(
+5 -5
View File
@@ -3639,18 +3639,18 @@ class DiscordAdapter(BasePlatformAdapter):
configured = self.config.extra.get("thread_require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in ("false", "0", "no", "off")
return configured.lower() not in {"false", "0", "no", "off"}
return bool(configured)
return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
def _discord_history_backfill(self) -> bool:
"""Return whether history backfill is enabled for shared sessions."""
configured = self.config.extra.get("history_backfill")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in ("false", "0", "no", "off")
return configured.lower() not in {"false", "0", "no", "off"}
return bool(configured)
return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes")
return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in {"true", "1", "yes"}
def _discord_history_backfill_limit(self) -> int:
"""Return the max number of messages to scan backwards for context.
@@ -3737,7 +3737,7 @@ class DiscordAdapter(BasePlatformAdapter):
break
# Skip system messages (pins, joins, thread renames, etc.)
if msg.type not in (discord.MessageType.default, discord.MessageType.reply):
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
continue
# Respect DISCORD_ALLOW_BOTS for other bots.
+2 -2
View File
@@ -168,8 +168,8 @@ class TextBatchAggregator:
# Pre-compiled regexes for performance
_RE_BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
_RE_ITALIC_STAR = re.compile(r"\*(.+?)\*", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"__(.+?)__", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"_(.+?)_", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"\b__(?![\s_])(.+?)(?<![\s_])__\b", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"\b_(?![\s_])(.+?)(?<![\s_])_\b", re.DOTALL)
_RE_CODE_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*\n?")
_RE_INLINE_CODE = re.compile(r"`(.+?)`")
_RE_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
+59
View File
@@ -348,6 +348,17 @@ class MatrixAdapter(BasePlatformAdapter):
self._sync_task: Optional[asyncio.Task] = None
self._closing = False
self._startup_ts: float = 0.0
# Clock-skew detection: count grace-check drops that happen well
# after startup (i.e. not initial-sync backfill). If the host's
# system clock is set ahead of real time, the startup grace check
# `event_ts < startup_ts - 5` silently drops every live message.
# See #12614 — the symptom is "bot joins rooms but never replies".
# Drops only count when their skew matches the first sampled drop
# (within 60s), so varied-age backfill from freshly-invited rooms
# doesn't trip the heuristic.
self._late_grace_drops: int = 0
self._late_grace_skew: float = 0.0
self._clock_skew_warned: bool = False
# Cache: room_id → bool (is DM)
self._dm_rooms: Dict[str, bool] = {}
@@ -842,6 +853,11 @@ class MatrixAdapter(BasePlatformAdapter):
# Initial sync to catch up, then start background sync.
self._startup_ts = time.time()
# Reset clock-skew detector for each connect cycle so a reconnect
# after the user fixes NTP doesn't inherit stale counters.
self._late_grace_drops = 0
self._late_grace_skew = 0.0
self._clock_skew_warned = False
self._closing = False
try:
@@ -1542,6 +1558,49 @@ class MatrixAdapter(BasePlatformAdapter):
)
event_ts = raw_ts / 1000.0 if raw_ts else 0.0
if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
# If we are well past startup but events are still being dropped
# by the grace check, the host clock is probably set ahead of
# real time — every live event then looks "older than startup".
# Warn once so users can fix NTP instead of chasing a ghost.
# See #12614 (Schnurzel700, April 2026).
#
# Filter out backfill (events legitimately old) by requiring:
# - we are >30s past startup (initial-sync replay window closed)
# - the skew is *consistent* across consecutive drops, which is
# the signature of a constant clock offset rather than a
# variable-age room history. Backfill from a freshly invited
# room can deliver events spanning hours/days — those skews
# will be all over the place and reset the counter.
if not self._clock_skew_warned and (
time.time() - self._startup_ts > 30
):
skew = self._startup_ts - event_ts
# Sanity bound: malformed events with negative or absurd
# timestamps shouldn't count.
if 5 < skew < 86400:
if self._late_grace_drops == 0:
self._late_grace_skew = skew
self._late_grace_drops = 1
elif abs(skew - self._late_grace_skew) < 60:
# Consistent offset → likely real clock skew.
self._late_grace_drops += 1
else:
# Varied skew → likely backfill, restart sampling.
self._late_grace_skew = skew
self._late_grace_drops = 1
if self._late_grace_drops >= 3:
logger.warning(
"Matrix: dropped %d consecutive live events as "
"'too old' more than 30s after startup (skew "
"%.0fs). The host system clock is likely set "
"ahead of real time, which causes the startup "
"grace filter to silently discard every incoming "
"message. Run `timedatectl set-ntp true` (or "
"sync NTP) and restart the bot.",
self._late_grace_drops,
skew,
)
self._clock_skew_warned = True
return
# Extract content from the event.
+1 -1
View File
@@ -482,7 +482,7 @@ class SlackAdapter(BasePlatformAdapter):
"text": text,
}
try:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
ctx["response_url"],
json=payload,
+2
View File
@@ -128,6 +128,7 @@ class SmsAdapter(BasePlatformAdapter):
await site.start()
self._http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
trust_env=True,
)
self._running = True
@@ -169,6 +170,7 @@ class SmsAdapter(BasePlatformAdapter):
session = self._http_session or aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
trust_env=True,
)
try:
for chunk in chunks:
+11 -1
View File
@@ -1663,7 +1663,17 @@ class TelegramAdapter(BasePlatformAdapter):
continue
raise
message_ids.append(str(msg.message_id))
# Re-trigger typing indicator after sending a message.
# Telegram clears the typing state when a new message is delivered,
# so without this the "...typing" bubble disappears mid-response
# (especially noticeable when the agent sends intermediate progress
# messages like "Checking:" before running tools).
try:
await self.send_typing(chat_id, metadata=metadata)
except Exception:
pass # Typing failures are non-fatal
return SendResult(
success=True,
message_id=message_ids[0] if message_ids else None,