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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+115
-8
@@ -4763,11 +4763,106 @@ class GatewayRunner:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Auto-decompose: turn fresh triage tasks into ready workgraphs
|
||||
# before the dispatcher fans out workers. Gated by
|
||||
# ``kanban.auto_decompose`` (default True). Capped by
|
||||
# ``kanban.auto_decompose_per_tick`` (default 3) so a bulk-load
|
||||
# of triage tasks doesn't burst-spend the aux LLM in one tick;
|
||||
# remainder defers to subsequent ticks.
|
||||
auto_decompose_enabled = bool(kanban_cfg.get("auto_decompose", True))
|
||||
try:
|
||||
auto_decompose_per_tick = int(
|
||||
kanban_cfg.get("auto_decompose_per_tick", 3) or 3
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
auto_decompose_per_tick = 3
|
||||
if auto_decompose_per_tick < 1:
|
||||
auto_decompose_per_tick = 1
|
||||
|
||||
def _auto_decompose_tick() -> int:
|
||||
"""Run the auto-decomposer for up to N triage tasks across all
|
||||
boards. Returns the number of triage tasks that were
|
||||
successfully decomposed or specified this tick.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import kanban_decompose as _decomp
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning(
|
||||
"kanban auto-decompose: import failed (%s); skipping", exc,
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
boards = _kb.list_boards(include_archived=False)
|
||||
except Exception:
|
||||
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
|
||||
attempted = 0
|
||||
successes = 0
|
||||
for b in boards:
|
||||
slug = b.get("slug") or _kb.DEFAULT_BOARD
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
# Pin this board for the duration of the call — same
|
||||
# pattern as the dashboard specify endpoint. The
|
||||
# decomposer module connects with no board kwarg and
|
||||
# relies on the env var.
|
||||
prev_env = os.environ.get("HERMES_KANBAN_BOARD")
|
||||
try:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = slug
|
||||
try:
|
||||
triage_ids = _decomp.list_triage_ids()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"kanban auto-decompose: list_triage_ids failed on board %s (%s)",
|
||||
slug, exc,
|
||||
)
|
||||
triage_ids = []
|
||||
for tid in triage_ids:
|
||||
if attempted >= auto_decompose_per_tick:
|
||||
break
|
||||
attempted += 1
|
||||
try:
|
||||
outcome = _decomp.decompose_task(
|
||||
tid, author="auto-decomposer",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"kanban auto-decompose: decompose_task crashed on %s",
|
||||
tid,
|
||||
)
|
||||
continue
|
||||
if outcome.ok:
|
||||
successes += 1
|
||||
if outcome.fanout and outcome.child_ids:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → %d children",
|
||||
slug, tid, len(outcome.child_ids),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"kanban auto-decompose [%s]: %s → single task (no fanout)",
|
||||
slug, tid,
|
||||
)
|
||||
else:
|
||||
# Common no-op reasons (no aux client configured) shouldn't
|
||||
# spam logs every tick. Log at debug.
|
||||
logger.debug(
|
||||
"kanban auto-decompose [%s]: %s skipped: %s",
|
||||
slug, tid, outcome.reason,
|
||||
)
|
||||
finally:
|
||||
if prev_env is None:
|
||||
os.environ.pop("HERMES_KANBAN_BOARD", None)
|
||||
else:
|
||||
os.environ["HERMES_KANBAN_BOARD"] = prev_env
|
||||
return successes
|
||||
|
||||
logger.info(
|
||||
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
||||
)
|
||||
while self._running:
|
||||
try:
|
||||
if auto_decompose_enabled:
|
||||
await asyncio.to_thread(_auto_decompose_tick)
|
||||
results = await asyncio.to_thread(_tick_once)
|
||||
any_spawned = False
|
||||
for slug, res in (results or []):
|
||||
@@ -8845,7 +8940,7 @@ class GatewayRunner:
|
||||
lines.append("Failed/paused: (none)")
|
||||
return "\n".join(lines)
|
||||
|
||||
if action in ("pause", "resume"):
|
||||
if action in {"pause", "resume"}:
|
||||
if not target:
|
||||
return f"Usage: /platform {action} <name>"
|
||||
platform = _resolve_platform(target)
|
||||
@@ -8953,13 +9048,15 @@ class GatewayRunner:
|
||||
logger.debug("Failed to write restart dedup marker: %s", e)
|
||||
|
||||
active_agents = self._running_agent_count()
|
||||
# When running under a service manager (systemd/launchd), use the
|
||||
# service restart path: exit with code 75 so the service manager
|
||||
# restarts us. The detached subprocess approach (setsid + bash)
|
||||
# doesn't work under systemd because KillMode=mixed kills all
|
||||
# processes in the cgroup, including the detached helper.
|
||||
# When running under a service manager (systemd/launchd) or inside a
|
||||
# Docker/Podman container, use the service restart path: exit with
|
||||
# code 75 so the service manager / container restart policy restarts
|
||||
# us. The detached subprocess approach (setsid + bash) doesn't work
|
||||
# under systemd (KillMode=mixed kills the cgroup) or Docker (tini
|
||||
# exits when the gateway dies, taking the detached helper with it).
|
||||
_under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
|
||||
if _under_service:
|
||||
_in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
||||
if _under_service or _in_container:
|
||||
self.request_restart(detached=False, via_service=True)
|
||||
else:
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
@@ -12528,6 +12625,12 @@ class GatewayRunner:
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
):
|
||||
metadata["telegram_dm_topic_reply_fallback"] = True
|
||||
# Telegram DM topic lanes need direct_messages_topic_id in metadata
|
||||
# so synthetic/queued messages (goal continuations, status notices)
|
||||
# route to the correct topic even when reply anchor is unavailable.
|
||||
tid = str(thread_id)
|
||||
if tid and tid not in {"", "1"}:
|
||||
metadata["direct_messages_topic_id"] = tid
|
||||
anchor = reply_to_message_id or getattr(source, "message_id", None)
|
||||
if anchor is not None:
|
||||
metadata["telegram_reply_to_message_id"] = str(anchor)
|
||||
@@ -12813,7 +12916,11 @@ class GatewayRunner:
|
||||
update_cmd = (
|
||||
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway"
|
||||
f" > {shlex.quote(str(output_path))} 2>&1; "
|
||||
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
|
||||
# Avoid `status=$?`: `status` is a read-only special parameter
|
||||
# in zsh, and this command string is copied/reused in macOS/zsh
|
||||
# operator wrappers. Keep the template zsh-safe even though this
|
||||
# specific subprocess currently runs under bash.
|
||||
f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}"
|
||||
)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
|
||||
Reference in New Issue
Block a user