Compare commits

..

8 Commits

Author SHA1 Message Date
teknium1
2bd1977d8f chore: release v0.17.0 (2026.6.19) 2026-06-19 12:38:31 -07:00
emozilla
40722058e5 fix(mcp): keep short-TTL HTTP sessions alive with configurable ping keepalive
MCP Streamable HTTP servers that garbage-collect idle sessions on a short
TTL (e.g. Unreal Engine's editor MCP, ~15s) were unusable: the keepalive
was hardcoded at 180s, so the session was always dead by the time it ran,
and every idle tool call then landed on an expired session and paid the
full reconnect path (observed hangs of 113-143s until interrupt, bounded
only by the 300s tool_timeout).

Two coordinated, backward-compatible changes:

- Add per-server `keepalive_interval` (config.yaml, not an env var per the
  contribution rubric). Default 180s — byte-identical to the old hardcoded
  value when unset — floored at 5s. Servers with short session TTLs set it
  below their TTL so the session stays warm.

- Switch the keepalive probe from `list_tools()` to `ping` (the MCP base
  protocol liveness primitive). On large servers `list_tools` pulled ~1 MB
  every cycle (830 tools = 1,068,041 bytes); `ping` is ~55 bytes and works
  uniformly across tool/prompt/resource servers. Tool-list changes still
  arrive out-of-band via notifications/tools/list_changed -> _refresh_tools.

`ping` is an OPTIONAL utility, so to guarantee zero regression for a
tool-capable server that doesn't implement it: the first -32601 latches
`_ping_unsupported` and the probe falls back to the pre-ping `list_tools`
path for that connection (no reconnect loop). The latch resets on each
fresh connection (_discover_tools, all transport paths) so a server that
gains ping support after a reconnect is re-probed with the cheap path.
Non-(-32601) ping errors propagate as genuine liveness failures.

Verified end-to-end against a live Unreal MCP server (idle 22s past the
~15s TTL -> post-idle tool call returns in 0.31s, no teardown) and with a
simulated ping-less tool server driving the real keepalive loop (ping once,
list_tools thereafter, no reconnect). 25/25 unit tests pass.

Note: a separate upstream defect (modelcontextprotocol/python-sdk#2604)
still tears down the whole session when one tool-call POST returns 4xx;
that is not addressed here.
2026-06-19 12:16:33 -07:00
kshitij
4c5217b717 Merge pull request #49207 from kshitijk4poor/fix/cron-script-env-sanitize
fix(cron): sanitize env for job script subprocesses
2026-06-20 00:36:26 +05:30
Teknium
ba49fb51a5 fix(discord): hydrate channel context when replying to a message (#49212)
* fix(discord): hydrate channel context when replying to a message

Replying to a message in a free-response (non-mention, threads-off)
channel previously received only the 500-char "[Replying to: ...]"
snippet — the history-backfill gate fired only for mention-gated
channels and threads, so a reply got no surrounding channel context.

Replies now route through the same _fetch_channel_context hydration
that threads use. When the user replied to a specific (often older)
message, a reply-anchored window is scanned ending at that message so
the agent sees the exchange around what was pointed at, even when the
target sits before the self-message partition. The two windows are
merged chronologically and de-duplicated by message id.

Also hardens the recent-window scan to skip non-conversational status
bumps before the self-message partition check, and makes author-name
resolution defensive against partial/deleted authors.

* fix(discord): duck-type reply-target resolution instead of isinstance(discord.Message)

The e2e suite stubs the discord module, so discord.Message is a MagicMock
and isinstance(_resolved, discord.Message) raises 'isinstance() arg 2 must
be a type'. Any object with an int .id works as a scan anchor, so resolve
the reply target by duck-typing on .id and fall back to a _Snowflake from
the reference message_id.
2026-06-19 12:03:08 -07:00
kshitijk4poor
f06508836d docs(security): enumerate cron job scripts in §2.3 credential scoping
The cron-script subprocess is now sanitized alongside shell/MCP/
code-exec children; §2.3 listed only the original three. Makes the
_run_job_script docstring's §2.3 citation fully accurate.

Follow-up to salvaged PR #49207.
2026-06-20 00:30:42 +05:30
kshitijk4poor
8dc0b18894 refactor(cron): copy os.environ before sanitizing for subprocess
Matches the env= callsite convention at the other sanitized
subprocess spawns (cua_backend dict(os.environ), gateway
os.environ.copy()). Functionally equivalent — _sanitize_subprocess_env
never mutates its input — but avoids handing the live mapping to the
helper.

Follow-up to salvaged PR #49207.
2026-06-20 00:29:46 +05:30
kshitijk4poor
2d978bf44a test(cron): make env-sanitize probe var deterministic
next(iter(frozenset)) picked a different blocklist var each run
(PYTHONHASHSEED-dependent), hurting reproducibility. sorted()[0]
keeps the invariant-style assertion (any real blocklisted var)
while making failures reproducible.

Follow-up to salvaged PR #49207.
2026-06-20 00:22:55 +05:30
0z1-ghb
da7253215d fix(cron): sanitize env for job script subprocesses
Cron no_agent and pre-check scripts ran with the full gateway/agent
environment, allowing scripts under HERMES_HOME/scripts/ to read provider
credentials. Apply _sanitize_subprocess_env like terminal and MCP paths
(SECURITY.md section 2.3).

Add regression test asserting blocklisted provider vars are absent in the
child process.
2026-06-20 00:13:11 +05:30
14 changed files with 705 additions and 73 deletions

View File

@@ -121,10 +121,11 @@ outside the supported security posture.
### 2.3 Credential Scoping
Hermes Agent filters the environment it passes to its lower-trust
in-process components: shell subprocesses, MCP subprocesses, and
the code-execution child. Credentials like provider API keys and
gateway tokens are stripped by default; variables explicitly
declared by the operator or by a loaded skill are passed through.
in-process components: shell subprocesses, MCP subprocesses,
cron job scripts, and the code-execution child. Credentials like
provider API keys and gateway tokens are stripped by default;
variables explicitly declared by the operator or by a loaded
skill are passed through.
This reduces casual exfiltration. It is not containment. Any
component running inside the agent process (skills, plugins, hook

View File

@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.16.0",
"version": "0.17.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.16.0",
"package": "hermes-agent[acp]==0.17.0",
"args": ["hermes-acp"]
}
}

View File

@@ -817,6 +817,10 @@ platform_toolsets:
# Optional per-server settings:
# timeout: tool call timeout in seconds (default: 120)
# connect_timeout: initial connection timeout (default: 60)
# keepalive_interval: liveness ping cadence in seconds (default: 180).
# Lower it below the server's session TTL for servers that expire idle
# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle
# tool calls hit an expired session and pay a slow reconnect. Floored at 5s.
#
# mcp_servers:
# time:

View File

@@ -961,6 +961,10 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs
(the `memory-watchdog.sh` pattern) without wrapping them in Python.
Subprocess environment is passed through ``_sanitize_subprocess_env`` so
provider credentials and other Hermes-managed secrets are not inherited
(SECURITY.md §2.3), matching terminal and MCP child processes.
Args:
script_path: Path to the script. Relative paths are resolved
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
@@ -1022,6 +1026,8 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
argv = [sys.executable, str(path)]
try:
from tools.environments.local import _sanitize_subprocess_env
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
argv,
@@ -1029,6 +1035,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
text=True,
timeout=script_timeout,
cwd=str(path.parent),
env=_sanitize_subprocess_env(os.environ.copy()),
**popen_kwargs,
)
stdout = (result.stdout or "").strip()

View File

@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.16.0"
__release_date__ = "2026.6.5"
__version__ = "0.17.0"
__release_date__ = "2026.6.19"
def _ensure_utf8():

View File

@@ -26,6 +26,19 @@ from typing import Callable, Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
class _Snowflake:
"""Minimal object exposing ``.id`` — satisfies discord.py's Snowflake
protocol for ``channel.history(before=...)`` without constructing a
``discord.Object`` (which test doubles that stub the discord module
cannot build). Used to anchor reply-context scans inclusively.
"""
__slots__ = ("id",)
def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
self.id = id
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
_DISCORD_COMMAND_SYNC_POLICIES = {"safe", "bulk", "off"}
_DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway"
@@ -4255,6 +4268,7 @@ class DiscordAdapter(BasePlatformAdapter):
self,
channel: Any,
before: "DiscordMessage",
reply_target: Optional[Any] = None,
) -> str:
"""Fetch recent channel messages for conversational context.
@@ -4262,6 +4276,13 @@ class DiscordAdapter(BasePlatformAdapter):
a message sent by this bot (the natural partition point between
bot turns) or reaches ``history_backfill_limit``.
When ``reply_target`` is provided (the user replied to a specific
message), a second backward scan is run ending at that target so the
agent sees the conversation surrounding what the user pointed at —
even when the reply target sits *before* the most recent bot turn and
would otherwise be cut off by the self-message partition. The two
windows are merged chronologically and de-duplicated by message ID.
Returns a formatted block like::
[Recent channel messages]
@@ -4295,7 +4316,47 @@ class DiscordAdapter(BasePlatformAdapter):
pass # Malformed cache entry — fall back to cold-start scan
try:
collected = []
def _keep(msg) -> Optional[str]:
"""Return a formatted ``[name] content`` line, or None to skip.
Encapsulates the system-message / non-conversational / other-bot
filtering so both the primary and reply-anchored scans apply
identical rules. Does NOT enforce the self-message partition —
callers decide where to stop.
"""
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
return None
content = getattr(msg, "clean_content", msg.content) or ""
if (
str(getattr(msg, "id", "")) in self._nonconversational_messages
or _looks_like_nonconversational_history_message(content)
):
return None
# Respect DISCORD_ALLOW_BOTS for other bots. For history
# context, "mentions" is treated as "all" — we are deciding
# what context to show, not whether to respond.
if (
getattr(msg.author, "bot", False)
and msg.author != self._client.user
and not include_other_bots
):
return None
if not content and msg.attachments:
content = "(attachment)"
if not content:
return None
name = (
getattr(msg.author, "display_name", None)
or getattr(msg.author, "name", None)
or "unknown"
)
if getattr(msg.author, "bot", False):
name = f"{name} [bot]"
return f"[{name}] {content}"
# ── Primary window: recent channel activity since the last bot turn ──
collected: List[Tuple[str, str]] = [] # (message_id, line)
seen_ids: set = set()
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
# silently flips the default to True when `after=` is supplied,
# which would select the *earliest* N messages after our last
@@ -4309,45 +4370,89 @@ class DiscordAdapter(BasePlatformAdapter):
after=_after_obj,
oldest_first=False,
):
# Skip system messages (pins, joins, thread renames, etc.)
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
continue
content = getattr(msg, "clean_content", msg.content) or ""
# Non-conversational lifecycle/status bumps (self-improvement
# reviews, background-process notices, restart banners) must be
# skipped BEFORE the partition check — otherwise a delayed
# status bump authored by us would be mistaken for the real
# last bot turn and hide messages that came after it.
_content = getattr(msg, "clean_content", msg.content) or ""
if (
str(getattr(msg, "id", "")) in self._nonconversational_messages
or _looks_like_nonconversational_history_message(content)
or _looks_like_nonconversational_history_message(_content)
):
continue
# Stop at our own message — this is the partition point.
# Everything before this is already in the session transcript.
# (Redundant when _after_obj is set, but needed for cold start.)
# Stop at our own (conversational) message — this is the
# partition point. Everything before this is already in the
# session transcript. (Redundant when _after_obj is set, but
# needed for cold start.)
if msg.author == self._client.user:
break
# Respect DISCORD_ALLOW_BOTS for other bots.
# For history context, "mentions" is treated as "all" — we are
# deciding what context to show, not whether to respond.
if getattr(msg.author, "bot", False) and not include_other_bots:
line = _keep(msg)
if line is None:
continue
mid = str(getattr(msg, "id", ""))
collected.append((mid, line))
if mid:
seen_ids.add(mid)
if not content and msg.attachments:
content = "(attachment)"
if not content:
continue
# ── Reply window: context around the message the user pointed at ──
# When the user replied to a specific message that sits BEFORE the
# primary window's partition point, the surrounding exchange isn't
# captured above. Fetch a small window ending just after the reply
# target so the agent sees what it was referencing. This window is
# NOT partitioned on the self-message boundary — the whole point is
# to surface older context the transcript lacks.
reply_collected: List[Tuple[str, str]] = []
reply_target_id = str(getattr(reply_target, "id", "")) if reply_target else ""
if reply_target is not None and reply_target_id and reply_target_id not in seen_ids:
# Reuse the same cap as the primary scan but keep the reply
# window modest — it's anchored context, not a full backfill.
reply_limit = max(1, min(limit, 10))
# `before` is exclusive in discord.py, so to *include* the
# target we anchor at target_id + 1. Use a minimal snowflake
# shim (any object exposing ``.id`` satisfies discord.py's
# Snowflake protocol) rather than discord.Object, so this path
# works under test doubles that stub the discord module too.
try:
_before_obj = _Snowflake(int(reply_target_id) + 1)
except (ValueError, TypeError):
_before_obj = before
async for msg in channel.history(
limit=reply_limit,
before=_before_obj,
oldest_first=False,
):
line = _keep(msg)
if line is None:
continue
mid = str(getattr(msg, "id", ""))
if mid and mid in seen_ids:
continue
reply_collected.append((mid, line))
if mid:
seen_ids.add(mid)
name = msg.author.display_name
if getattr(msg.author, "bot", False):
name = f"{name} [bot]"
collected.append(f"[{name}] {content}")
if not collected:
if not collected and not reply_collected:
return ""
# channel.history returns newest-first (oldest_first=False); reverse for chronological order
# channel.history returns newest-first; reverse each window for
# chronological order, then present reply context first (it is
# older) followed by the recent activity.
collected.reverse()
return "[Recent channel messages]\n" + "\n".join(collected)
reply_collected.reverse()
blocks: List[str] = []
if reply_collected:
blocks.append(
"[Context around the replied-to message]\n"
+ "\n".join(line for _id, line in reply_collected)
)
if collected:
blocks.append(
"[Recent channel messages]\n"
+ "\n".join(line for _id, line in collected)
)
return "\n\n".join(blocks)
except discord.Forbidden:
logger.debug("[%s] Missing permissions to fetch channel history", self.name)
@@ -5381,14 +5486,40 @@ class DiscordAdapter(BasePlatformAdapter):
# - any thread (in_bot_thread bypasses the mention check, but
# processing-window gaps and post-restart context still need
# recovery)
# - any reply (the user pointed at a specific message; hydrate
# the context around it even in a free-response channel where
# no mention gap exists — otherwise replies get only the short
# "[Replying to: ...]" snippet with no surrounding context)
# DMs skip entirely because every DM message triggers the bot,
# so the session transcript already has everything.
# Auto-threaded messages also skip — we just created the thread,
# there's nothing prior to backfill.
_has_mention_gap = require_mention and not is_free_channel and not in_bot_thread
if (_has_mention_gap or is_thread) and auto_threaded_channel is None:
_is_reply = message.reference is not None
# Resolve the replied-to message into an object exposing ``.id``.
# discord.py may give us a full Message (resolved), a
# DeletedReferencedMessage, or nothing. Duck-type on ``.id``
# rather than isinstance(discord.Message) — under test doubles the
# discord module (and thus discord.Message) can be a mock, which is
# not a valid isinstance() second argument. Any object with an int
# id works as a scan anchor; otherwise fall back to a bare snowflake
# built from the reference's message_id.
_reply_target = None
if _is_reply:
_resolved = getattr(message.reference, "resolved", None)
_resolved_id = getattr(_resolved, "id", None) if _resolved is not None else None
if _resolved_id is not None:
_reply_target = _resolved
else:
_ref_mid = getattr(message.reference, "message_id", None)
if _ref_mid is not None:
with suppress(ValueError, TypeError):
_reply_target = _Snowflake(int(_ref_mid))
if (_has_mention_gap or is_thread or _is_reply) and auto_threaded_channel is None:
_backfill_text = await self._fetch_channel_context(
message.channel, before=message,
message.channel, before=message, reply_target=_reply_target,
)
if _backfill_text:
_channel_context = _backfill_text

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.16.0"
version = "0.17.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's

View File

@@ -1587,6 +1587,26 @@ AUTHOR_MAP = {
"infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
"eurekaxun@163.com": "huangxun375-stack", # PR #37251 / #48894 structured OpenViking sync
"218421507+Sahil-SS9@users.noreply.github.com": "Sahil-SS9", # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill)
# v0.17.0 additions
"2081789787@qq.com": "pengyuyanITYU", # PR #43618 (harden local file tree paths)
"adalsteinni@gmail.com": "AIalliAI", # PR #44159 (desktop hover-reveal inset)
"ameobius@local.host": "ameobius", # PR #44383 co-author (discord gateway task recovery)
"andyfieb@gmail.com": "mollusk", # PR #44493 (desktop assistant-ui recovery)
"drmani215@gmail.com": "bionicbutterfly13", # direct email match
"enesilhaydin@gmail.com": "enesilhaydin", # direct email match
"evisolpxe@gmail.com": "Evisolpxe", # direct email match
"fyzan.shaik@gmail.com": "fyzanshaik", # direct email match
"info@amik.co": "AMIK-coorporations", # PR #40578 (Urdu README) co-author
"info@amikchat.site": "AMIK-coorporations", # PR #40578 (Urdu README)
"kyssta69@gmail.com": "kyssta-exe", # PR #44282 (Windows dashboard re-exec)
"loongfay@foxmail.com": "loongfay", # PR #43508 (Yuanbao wechat forward msg)
"maplestoryjuni222@gmail.com": "BROCCOLO1D", # PR #42733 (lazy-parse docker env config)
"marvin@photon.codes": "underthestars-zhy", # PR #46907 co-author (Photon Spectrum project ids)
"omar@kostudios.io": "OmarB97", # PR #43977 (desktop session model metadata)
"omarbaradei21@gmail.com": "OmarB97", # PR #43977 (desktop session model metadata)
"philip.a.dsouza@gmail.com": "PhilipAD", # direct email match
"qs2816661685@gmail.com": "qingshan89", # PR #46895 co-author (desktop remote artifact download)
"yspdev@gmail.com": "AJ", # PR #44510 co-author (desktop named-profile boot loop)
}

View File

@@ -132,6 +132,31 @@ class TestRunJobScript:
assert "exited with code 1" in output
assert "error info" in output
def test_script_subprocess_env_sanitized(self, cron_env, monkeypatch):
"""Cron scripts must not inherit Hermes provider env (SECURITY.md §2.3)."""
from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST
from cron.scheduler import _run_job_script
# sorted() so the probed var is deterministic across runs
# (frozenset iteration order varies with PYTHONHASHSEED).
blocked_var = sorted(_HERMES_PROVIDER_ENV_BLOCKLIST)[0]
monkeypatch.setenv(blocked_var, "must_not_leak")
script = cron_env / "scripts" / "env_probe.py"
script.write_text(
textwrap.dedent(
f"""\
import os
key = {blocked_var!r}
print("PRESENT" if os.environ.get(key) else "ABSENT")
"""
)
)
success, output = _run_job_script("env_probe.py")
assert success is True
assert output == "ABSENT"
def test_script_empty_output(self, cron_env):
from cron.scheduler import _run_job_script

View File

@@ -27,6 +27,8 @@ def _ensure_discord_mock():
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.Object = lambda *, id: SimpleNamespace(id=id)
discord_mod.Message = type("Message", (), {})
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
@@ -721,6 +723,84 @@ async def test_fetch_channel_context_skips_self_improvement_boundary_message(ada
)
@pytest.mark.asyncio
async def test_fetch_channel_context_hydrates_around_reply_target(adapter, monkeypatch):
"""Replying to an older message pulls the surrounding exchange into context.
The reply target sits *before* the self-message partition point, so the
primary scan alone would miss it. The reply-anchored window must surface
the target and its neighbours under a distinct header, with the recent
activity still appearing afterwards.
"""
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
adapter.config.extra["history_backfill_limit"] = 10
bot_user = adapter._client.user
human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False)
other = SimpleNamespace(id=58, display_name="Carol", name="Carol", bot=False)
channel = FakeHistoryChannel(
[
# Recent activity (after our last response, captured by primary scan)
make_history_message(author=human, content="latest note", msg_id=6),
make_history_message(author=bot_user, content="our prior response", msg_id=5),
# Older exchange — behind the partition, only reachable via reply anchor
make_history_message(author=bot_user, content="the bot answer being replied to", msg_id=3),
make_history_message(author=other, content="older question", msg_id=2),
make_history_message(author=human, content="even older", msg_id=1),
],
channel_id=123,
)
# User replied to the bot's older answer (msg_id=3).
reply_target = SimpleNamespace(id=3)
trigger = make_message(channel=channel, content="follow-up about that")
result = await adapter._fetch_channel_context(
channel, before=trigger, reply_target=reply_target,
)
# Reply context comes first (older), then recent activity. The reply
# window is NOT cut off at the self-message boundary, so msg_id=3 (a bot
# message) and its neighbours appear.
assert "[Context around the replied-to message]" in result
assert "the bot answer being replied to" in result
assert "older question" in result
assert "[Recent channel messages]" in result
assert "latest note" in result
assert result.index("[Context around the replied-to message]") < result.index("[Recent channel messages]")
@pytest.mark.asyncio
async def test_fetch_channel_context_reply_target_in_primary_window_not_duplicated(adapter, monkeypatch):
"""When the reply target is already in the recent window, don't double it."""
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
adapter.config.extra["history_backfill_limit"] = 10
bot_user = adapter._client.user
human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False)
channel = FakeHistoryChannel(
[
make_history_message(author=human, content="recent reply target", msg_id=4),
make_history_message(author=human, content="another recent", msg_id=3),
make_history_message(author=bot_user, content="our prior response", msg_id=2),
],
channel_id=123,
)
reply_target = SimpleNamespace(id=4) # already inside the primary window
trigger = make_message(channel=channel, content="re: that")
result = await adapter._fetch_channel_context(
channel, before=trigger, reply_target=reply_target,
)
# No separate reply block, and the target text appears exactly once.
assert "[Context around the replied-to message]" not in result
assert result.count("recent reply target") == 1
def test_nonconversational_fallback_requires_self_improvement_emoji():
assert discord_platform._looks_like_nonconversational_history_message(
"💾 Self-improvement review: Memory updated"
@@ -1016,3 +1096,61 @@ async def test_discord_auto_thread_skips_backfill(adapter, monkeypatch):
adapter._auto_create_thread.assert_awaited_once()
adapter._fetch_channel_context.assert_not_awaited()
@pytest.mark.asyncio
async def test_discord_reply_in_free_channel_triggers_backfill(adapter, monkeypatch):
"""Replying to a message hydrates context even in a free-response channel.
This is the gap the reply-context feature closes: with no mention
requirement there is no "mention gap", so the old gate skipped backfill
and a reply received only the short "[Replying to: ...]" snippet. A reply
must now route through _fetch_channel_context with the replied-to message
as the anchor.
"""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") # free-response
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
adapter.config.extra["history_backfill"] = True
adapter._fetch_channel_context = AsyncMock(
return_value="[Context around the replied-to message]\n[Hermes [bot]] earlier answer"
)
message = make_message(channel=FakeTextChannel(channel_id=321), content="what about edge cases?")
# Simulate a Discord reply: reference points at an earlier message id.
message.reference = SimpleNamespace(message_id=42, resolved=None)
await adapter._handle_message(message)
adapter._fetch_channel_context.assert_awaited_once()
# The reply target is passed as the anchor, carrying the referenced id.
call = adapter._fetch_channel_context.await_args
assert getattr(call.kwargs.get("reply_target"), "id", None) == 42
event = adapter.handle_message.await_args.args[0]
assert event.channel_context == (
"[Context around the replied-to message]\n[Hermes [bot]] earlier answer"
)
@pytest.mark.asyncio
async def test_discord_non_reply_free_channel_skips_backfill(adapter, monkeypatch):
"""A plain (non-reply) message in a free-response channel still skips backfill.
Guards against the reply gate accidentally widening to every free-channel
message — only replies (and the existing mention-gap / thread cases) should
hydrate context.
"""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
adapter.config.extra["history_backfill"] = True
adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] noise")
message = make_message(channel=FakeTextChannel(channel_id=321), content="just chatting")
assert message.reference is None # not a reply
await adapter._handle_message(message)
adapter._fetch_channel_context.assert_not_awaited()

View File

@@ -2,12 +2,18 @@
Prompt-only / resource-only MCP servers do not implement the ``tools/*``
request family. Per the MCP spec, ``InitializeResult.capabilities.tools``
is non-None iff the server supports it. Before this fix, Hermes always
called ``tools/list`` during discovery and as the keepalive probe — both
raised ``McpError(-32601 Method not found)`` against such servers, so a
prompt-only server could never stay connected.
is non-None iff the server supports it. Before the capability gate, Hermes
always called ``tools/list`` during discovery, which raised
``McpError(-32601 Method not found)`` against such servers, so a prompt-only
server could never stay connected. Discovery/refresh remain capability-gated.
Ported from anomalyco/opencode#31271.
The keepalive probe uses ``ping`` (MCP base-protocol liveness) for every
server regardless of capability: it works uniformly and stays a few bytes
instead of pulling the full ``tools/list`` payload (which is ~1 MB on large
servers like Unreal Engine's editor MCP). Its cadence is configurable via
``keepalive_interval`` so servers with short session TTLs stay alive.
Discovery gating ported from anomalyco/opencode#31271.
"""
import asyncio
from types import SimpleNamespace
@@ -143,7 +149,10 @@ class TestKeepaliveProbe:
task.session.send_ping.assert_awaited_once()
task.session.list_tools.assert_not_called()
async def test_keepalive_uses_list_tools_for_tool_capable_server(self):
async def test_keepalive_uses_ping_for_tool_capable_server(self):
"""Keepalive uses ``ping`` even for tool-capable servers, so the probe
stays a few bytes regardless of tool count (no ``list_tools`` payload).
Tool-list changes still arrive via tools/list_changed notifications."""
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task.session = SimpleNamespace(
@@ -154,5 +163,195 @@ class TestKeepaliveProbe:
reason = await self._run_one_keepalive_cycle(task)
assert reason == "shutdown"
task.session.send_ping.assert_awaited_once()
task.session.list_tools.assert_not_called()
async def test_keepalive_uses_ping_legacy_fallback(self):
"""No captured capabilities → still pings (no spurious list_tools)."""
task = MCPServerTask("test")
assert task.initialize_result is None
task.session = SimpleNamespace(
list_tools=AsyncMock(),
send_ping=AsyncMock(),
)
reason = await self._run_one_keepalive_cycle(task)
assert reason == "shutdown"
task.session.send_ping.assert_awaited_once()
task.session.list_tools.assert_not_called()
class TestKeepaliveInterval:
"""The keepalive cadence is configurable so servers with short session
TTLs (e.g. Unreal Engine editor MCP, ~15s) can refresh fast enough to keep
the session alive instead of hitting an expired session on every idle call.
"""
async def _captured_interval(self, config):
"""Run one keepalive cycle and capture the ``asyncio.wait`` timeout."""
task = MCPServerTask("test")
task._config = config
task.session = SimpleNamespace(send_ping=AsyncMock())
captured = {}
real_wait = asyncio.wait
async def fake_wait(tasks, timeout=None, return_when=None):
captured["timeout"] = timeout
task._shutdown_event.set()
return await real_wait(
tasks, timeout=0.5, return_when=return_when or asyncio.FIRST_COMPLETED
)
import tools.mcp_tool as mcp_mod
orig = mcp_mod.asyncio.wait
mcp_mod.asyncio.wait = fake_wait
try:
await task._wait_for_lifecycle_event()
finally:
mcp_mod.asyncio.wait = orig
return captured["timeout"]
@pytest.mark.asyncio
async def test_default_interval_when_unset(self):
from tools.mcp_tool import _DEFAULT_KEEPALIVE_INTERVAL
assert await self._captured_interval({}) == _DEFAULT_KEEPALIVE_INTERVAL
@pytest.mark.asyncio
async def test_configured_interval_honored(self):
assert await self._captured_interval({"keepalive_interval": 10}) == 10
@pytest.mark.asyncio
async def test_interval_clamped_to_floor(self):
from tools.mcp_tool import _MIN_KEEPALIVE_INTERVAL
# A sub-floor value must clamp up, never busy-loop the keepalive.
assert (
await self._captured_interval({"keepalive_interval": 0.1})
== _MIN_KEEPALIVE_INTERVAL
)
def _mcp_error(code, message="boom"):
"""Build a real McpError carrying a JSON-RPC error code."""
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
return McpError(ErrorData(code=code, message=message))
class TestMethodNotFoundDetection:
"""``_is_method_not_found_error`` underpins the ping→list_tools fallback."""
def test_structural_code_match(self):
from tools.mcp_tool import _is_method_not_found_error
assert _is_method_not_found_error(_mcp_error(-32601)) is True
def test_other_mcp_error_code_is_not_match(self):
from tools.mcp_tool import _is_method_not_found_error
# Invalid params (-32602) is a real error, NOT "ping unsupported".
assert _is_method_not_found_error(_mcp_error(-32602)) is False
def test_substring_fallback(self):
from tools.mcp_tool import _is_method_not_found_error
assert _is_method_not_found_error(Exception("Method not found")) is True
def test_unrelated_exception_is_not_match(self):
from tools.mcp_tool import _is_method_not_found_error
assert _is_method_not_found_error(TimeoutError()) is False
assert _is_method_not_found_error(Exception("session terminated")) is False
@pytest.mark.asyncio
class TestKeepaliveProbeFallback:
"""The probe prefers ``ping`` but falls back to ``list_tools`` for servers
that don't implement the optional ping utility — without reconnect-looping,
and without regressing servers that DO support ping."""
async def test_uses_ping_when_supported(self):
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task.session = SimpleNamespace(
send_ping=AsyncMock(),
list_tools=AsyncMock(),
)
await task._keepalive_probe()
task.session.send_ping.assert_awaited_once()
task.session.list_tools.assert_not_called()
assert task._ping_unsupported is False
async def test_falls_back_to_list_tools_on_method_not_found(self):
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task.session = SimpleNamespace(
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
)
await task._keepalive_probe()
# First cycle: ping tried, failed -32601, list_tools used as fallback.
task.session.send_ping.assert_awaited_once()
task.session.list_tools.assert_awaited_once()
task.session.send_ping.assert_not_called()
assert task._ping_unsupported is True
async def test_latch_skips_ping_on_subsequent_cycles(self):
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task.session = SimpleNamespace(
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
)
await task._keepalive_probe() # latches _ping_unsupported
await task._keepalive_probe() # should NOT ping again
task.session.send_ping.assert_awaited_once() # only the first cycle
assert task.session.list_tools.await_count == 2
async def test_real_liveness_failure_propagates_not_swallowed(self):
"""A non-(-32601) ping error is a genuine connection failure: it must
propagate so the caller reconnects, and must NOT latch the fallback."""
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task.session = SimpleNamespace(
send_ping=AsyncMock(side_effect=Exception("session terminated")),
list_tools=AsyncMock(),
)
with pytest.raises(Exception, match="session terminated"):
await task._keepalive_probe()
task.session.list_tools.assert_not_called()
assert task._ping_unsupported is False
async def test_no_ping_no_tools_propagates_method_not_found(self):
"""A server advertising neither working ping nor tools has no cheaper
probe — the -32601 must propagate rather than calling list_tools on a
server that doesn't support it."""
task = MCPServerTask("test")
task.initialize_result = _caps(prompts=SimpleNamespace()) # not tool-capable
task.session = SimpleNamespace(
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
list_tools=AsyncMock(),
)
with pytest.raises(Exception):
await task._keepalive_probe()
task.session.list_tools.assert_not_called()
async def test_discover_resets_latch(self):
"""A fresh connection (_discover_tools) re-enables the cheap ping path."""
task = MCPServerTask("test")
task.initialize_result = _caps(tools=SimpleNamespace())
task._ping_unsupported = True
task.session = SimpleNamespace(
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
)
await task._discover_tools()
assert task._ping_unsupported is False

View File

@@ -19,6 +19,10 @@ Example config::
env: {}
timeout: 120 # per-tool-call timeout in seconds (default: 300)
connect_timeout: 60 # initial connection timeout (default: 60)
keepalive_interval: 10 # liveness ping cadence in seconds (default:
# 180). Set below the server's session TTL for
# servers that GC idle sessions quickly (e.g.
# Unreal Engine editor MCP, ~15s). Floored at 5s.
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
@@ -276,6 +280,17 @@ _MAX_RECONNECT_RETRIES = 5
_MAX_INITIAL_CONNECT_RETRIES = 3 # retries for the very first connection attempt
_MAX_BACKOFF_SECONDS = 60
# Keepalive cadence for HTTP/SSE sessions. The MCP spec lets a server expire
# idle sessions on any TTL it chooses (Streamable HTTP "Session Management"),
# so a client that wants a session to survive idle periods MUST refresh faster
# than that TTL. The default suits long LB/NAT idle windows (commonly
# 300-600s); servers with short session TTLs (e.g. Unreal Engine's editor MCP,
# ~15s) need a smaller ``keepalive_interval`` in their config or every idle
# tool call lands on a dead session and pays the full reconnect path. The floor
# stops a misconfigured tiny interval from busy-looping the keepalive.
_DEFAULT_KEEPALIVE_INTERVAL = 180 # seconds between liveness pings
_MIN_KEEPALIVE_INTERVAL = 5 # clamp floor for configured intervals
# Environment variables that are safe to pass to stdio subprocesses
_SAFE_ENV_KEYS = frozenset({
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
@@ -382,6 +397,40 @@ def _exc_str(exc: BaseException) -> str:
return text if text else repr(exc)
# JSON-RPC "method not found" — the error a server returns when it does not
# implement a requested method (e.g. a tool-capable server that never wired up
# the optional ``ping`` utility). Defined locally with a fallback so detection
# works even on SDK builds that don't export the constant.
try:
from mcp.types import METHOD_NOT_FOUND as _JSONRPC_METHOD_NOT_FOUND
except Exception: # pragma: no cover — older/newer SDK without the constant
_JSONRPC_METHOD_NOT_FOUND = -32601
def _is_method_not_found_error(exc: BaseException) -> bool:
"""Return True if *exc* is a JSON-RPC ``method not found`` (-32601).
``ping`` is an *optional* MCP utility (spec: "optional ping mechanism").
A server that doesn't implement it answers a ping with -32601 rather than
an empty result. Structurally inspect ``McpError.error.code`` first, then
fall back to a substring match so detection survives SDK version drift and
servers that surface the condition as a plain message.
"""
# Structural: mcp.shared.exceptions.McpError carries ErrorData.code.
err = getattr(exc, "error", None)
code = getattr(err, "code", None)
if code == _JSONRPC_METHOD_NOT_FOUND:
return True
msg = str(exc).lower()
if not msg:
return False
return (
str(_JSONRPC_METHOD_NOT_FOUND) in msg
or "method not found" in msg
or "not found: ping" in msg
)
# ---------------------------------------------------------------------------
# MCP tool description content scanning
# ---------------------------------------------------------------------------
@@ -1362,7 +1411,7 @@ class MCPServerTask:
"_registered_tool_names", "_auth_type", "_refresh_lock",
"_rpc_lock", "_pending_refresh_tasks",
"_pending_call_context",
"initialize_result",
"initialize_result", "_ping_unsupported",
)
def __init__(self, name: str):
@@ -1410,6 +1459,12 @@ class MCPServerTask:
# ``.capabilities.prompts``) instead of assuming every ``ClientSession``
# method attribute corresponds to a supported server method. See #18051.
self.initialize_result: Optional[Any] = None
# Set True the first time a keepalive ``ping`` returns JSON-RPC
# -32601 (method not found): the server is tool-capable but doesn't
# implement the optional ``ping`` utility. Subsequent keepalives fall
# back to ``list_tools`` (the pre-ping probe) so we neither spam pings
# nor reconnect-loop. Reset on each fresh transport connection.
self._ping_unsupported: bool = False
def _is_http(self) -> bool:
"""Check if this server uses HTTP transport."""
@@ -1564,6 +1619,46 @@ class MCPServerTask:
self.name, len(self._registered_tool_names),
)
async def _keepalive_probe(self) -> None:
"""Exercise the session to detect a stale/expired connection.
Uses ``ping`` (cheap, transport-agnostic liveness) by default. ``ping``
is an OPTIONAL MCP utility: a server that doesn't implement it answers
JSON-RPC -32601. The first time that happens we latch
``_ping_unsupported`` and fall back to the pre-ping probe — capability
permitting, ``list_tools``; otherwise ``ping`` is the only option and
the -32601 propagates (a server advertising neither a working ping nor
tools has no liveness primitive left). The latch resets on each fresh
transport connection so a server that gains ping support after a
reconnect is re-probed with the cheap path.
Raises on a genuine connection failure so the caller triggers a
reconnect; returns normally when the session is alive.
"""
if not self._ping_unsupported:
try:
await asyncio.wait_for(self.session.send_ping(), timeout=30.0)
return
except Exception as exc:
# Only a "method not found" means ping is unsupported. Any
# other error (timeout, closed transport, session expired) is
# a real liveness failure — propagate so we reconnect.
if not _is_method_not_found_error(exc):
raise
if not self._advertises_tools():
# No ping, no tools → no cheaper probe to fall back to.
raise
self._ping_unsupported = True
logger.info(
"MCP server '%s': does not implement the optional 'ping' "
"utility (-32601); using 'list_tools' for keepalive on "
"this connection.",
self.name,
)
# Fallback probe for servers without ping support.
await asyncio.wait_for(self.session.list_tools(), timeout=30.0)
async def _wait_for_lifecycle_event(self) -> str:
"""Block until either _shutdown_event or _reconnect_event fires.
@@ -1577,13 +1672,29 @@ class MCPServerTask:
Shutdown takes precedence if both events are set simultaneously.
Periodically sends a lightweight keepalive (``list_tools``) to
prevent TCP connections from going stale during long idle
periods (#17003). If the keepalive fails, triggers a reconnect.
Periodically sends a lightweight keepalive (``ping``, with a
``list_tools`` fallback for servers that don't implement the optional
ping utility — see :meth:`_keepalive_probe`) to prevent TCP/session
state from going stale during idle periods (#17003). If the keepalive
fails, triggers a reconnect.
The cadence is ``keepalive_interval`` from server config (default
:data:`_DEFAULT_KEEPALIVE_INTERVAL`, floored at
:data:`_MIN_KEEPALIVE_INTERVAL`). Servers that GC idle sessions on a
short TTL (e.g. Unreal Engine's editor MCP, ~15s) need an interval
below that TTL, otherwise every idle tool call lands on an
already-expired session and pays the full reconnect path.
"""
# Keepalive interval in seconds. Must be shorter than typical
# LB / NAT idle-timeout (commonly 300-600s).
_KEEPALIVE_INTERVAL = 180 # 3 minutes
# Refresh faster than the server's session TTL. ``ping`` (MCP base
# protocol liveness) is used rather than ``list_tools`` so the probe
# stays a few bytes regardless of how many tools the server exposes —
# a ``list_tools`` keepalive against an 830-tool server would pull
# ~1 MB every cycle. Tool-list changes still arrive out-of-band via
# ``notifications/tools/list_changed`` → ``_refresh_tools``.
keepalive_interval = max(
_MIN_KEEPALIVE_INTERVAL,
float(self._config.get("keepalive_interval", _DEFAULT_KEEPALIVE_INTERVAL)),
)
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
@@ -1591,30 +1702,23 @@ class MCPServerTask:
while True:
done, _pending = await asyncio.wait(
{shutdown_task, reconnect_task},
timeout=_KEEPALIVE_INTERVAL,
timeout=keepalive_interval,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
break
# Timeout — no lifecycle event fired. Send a keepalive
# to exercise the connection and detect stale sockets.
# Prompt-only / resource-only servers don't implement
# ``tools/list`` (McpError -32601), so use the universal
# ``ping`` request for them instead — otherwise every
# keepalive cycle would trigger a spurious reconnect.
# Timeout — no lifecycle event fired. Probe the connection
# to detect stale/expired sessions. Prefer ``ping`` (MCP base
# protocol liveness): it works uniformly and stays a few bytes
# regardless of tool count, unlike ``list_tools`` (~1 MB on an
# 830-tool server). ``ping`` is an OPTIONAL utility, so a
# tool-capable server that doesn't implement it answers -32601;
# in that case fall back to the pre-ping ``list_tools`` probe
# for the rest of this connection rather than reconnect-looping.
if self.session:
try:
if self._advertises_tools():
await asyncio.wait_for(
self.session.list_tools(),
timeout=30.0,
)
else:
await asyncio.wait_for(
self.session.send_ping(),
timeout=30.0,
)
await self._keepalive_probe()
except Exception as exc:
logger.warning(
"MCP server '%s' keepalive failed, "
@@ -2040,6 +2144,10 @@ class MCPServerTask:
server doesn't advertise the ``tools`` capability.
(Ported from anomalyco/opencode#31271.)
"""
# Fresh transport connection → re-probe with the cheap ``ping`` path.
# Clears any latch from a prior connection in case the server gained
# ping support across the reconnect.
self._ping_unsupported = False
if self.session is None:
return
if not self._advertises_tools():

View File

@@ -3512,8 +3512,7 @@ def _schedule_mcp_late_refresh(sid: str, agent) -> None:
The agent snapshots ``agent.tools`` once at build time and never re-reads
the registry (run_agent/agent_init). ``_make_agent`` briefly joins the
background MCP discovery thread (``wait_for_mcp_discovery``, bounded by the
``mcp_discovery_timeout`` config value, default 1.5s) so
background MCP discovery thread (``wait_for_mcp_discovery``, ~0.75s) so
already-spawning servers land in that snapshot — but a server that takes
longer than the bound to connect (common for an HTTP MCP server on first
connect) lands *after* the agent is built. Its tools are then absent from

2
uv.lock generated
View File

@@ -1424,7 +1424,7 @@ wheels = [
[[package]]
name = "hermes-agent"
version = "0.16.0"
version = "0.17.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },