diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py
index c246fc52c3a..24aff8969cb 100644
--- a/agents/hermes/plugin/__init__.py
+++ b/agents/hermes/plugin/__init__.py
@@ -25,6 +25,7 @@
import ipaddress
import json
import os
+import re
import subprocess
import sys
from dataclasses import replace as dataclass_replace
@@ -41,6 +42,7 @@
_URL_SAFETY_PATCH_ATTR = "_nemoclaw_broker_url_safety_patch_installed"
_BROWSER_CDP_TUNNEL_PATCH_ATTR = "_nemoclaw_browser_use_cdp_tunnel_patch_installed"
_BROWSER_SESSION_STATE_PATCH_ATTR = "_nemoclaw_browser_use_session_state_patch_installed"
+_MESSAGING_RESPONSE_PATCH_ATTR = "_nemoclaw_messaging_response_patch_installed"
_BROWSER_USE_CDP_TUNNELS = {}
_TOOL_GATEWAY_URL_ENV = {
@@ -73,6 +75,36 @@
"whoami",
)
+_MESSAGING_PLATFORMS = (
+ "telegram",
+ "discord",
+ "slack",
+ "whatsapp",
+ "signal",
+ "sms",
+ "email",
+ "matrix",
+ "mattermost",
+ "dingtalk",
+ "feishu",
+ "wecom",
+ "wecom_callback",
+ "weixin",
+ "qqbot",
+ "yuanbao",
+ "webhook",
+)
+_RAW_MESSAGING_TOOL_RE = re.compile(
+ r"^\s*send_message\s*:\s*(?P
.+?)\s*$",
+ flags=re.IGNORECASE | re.DOTALL,
+)
+_RAW_MESSAGING_TARGET_RE = re.compile(
+ r"^(?:to\s+)?(?P"
+ + "|".join(re.escape(platform) for platform in _MESSAGING_PLATFORMS)
+ + r")\s*:\s*(?P.+)$",
+ flags=re.IGNORECASE | re.DOTALL,
+)
+
_BROKER_ALWAYS_BLOCKED_HOSTNAMES = {
"localhost",
"metadata.google.internal",
@@ -1040,6 +1072,98 @@ def _active_managed_gateway_services():
return services
+def _strip_wrapping_quotes(text):
+ value = str(text or "").strip()
+ while len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
+ value = value[1:-1].strip()
+ return value
+
+
+# Tracks the messaging platform of the active LLM call so the normalizer
+# below can refuse to silently rewrite cross-platform send_message pseudo-calls
+# into the wrong chat (PR #4175 review feedback from @cv). Updated on every
+# `_pre_llm_call` and read by the patched `_strip_think_blocks`.
+_current_messaging_platform = {"value": None}
+
+
+def _set_current_messaging_platform(platform):
+ raw = str(platform or "").strip().lower()
+ _current_messaging_platform["value"] = raw if raw in _MESSAGING_PLATFORMS else None
+
+
+def _get_current_messaging_platform():
+ return _current_messaging_platform["value"]
+
+
+def _normalize_raw_messaging_tool_response(response, current_platform=None):
+ """Convert a raw send_message pseudo-call into the message body.
+
+ Defense-in-depth fallback for the first-turn race in #3893 where the
+ Hermes tool-dispatch isn't ready when the messaging adapter delivers the
+ first user turn, so the model emits text like
+ ``send_message: "to telegram: Hello"`` as the final answer instead of
+ using the structured tool-calling channel. Returning the body is the
+ correct delivery path **only when the target platform matches the current
+ chat platform** — otherwise (per the #4175 review) a stray
+ ``send_message: "to slack: ..."`` from a Telegram chat would be silently
+ delivered back to Telegram, misrouting a cross-platform send_message
+ intent.
+
+ Source of truth for send_message routing is the Hermes tool dispatcher
+ in ``agents/hermes/run_agent.py`` (openclaw runtime); this normalizer is
+ purely an output filter on `AIAgent._strip_think_blocks`. End-to-end
+ coverage runs via ``hermes-e2e``, ``hermes-discord-e2e``, and
+ ``hermes-slack-e2e`` against a real gateway first-message path.
+ """
+ if not isinstance(response, str):
+ return response
+ match = _RAW_MESSAGING_TOOL_RE.match(response)
+ if not match:
+ return response
+
+ body = _strip_wrapping_quotes(match.group("body"))
+ target_match = _RAW_MESSAGING_TARGET_RE.match(body)
+ if not target_match:
+ return response
+
+ target_platform = (target_match.group("platform") or "").strip().lower()
+ current = (str(current_platform or "").strip().lower()) or None
+ if current is None or target_platform != current:
+ # Cross-platform or unknown-platform pseudo-call — leave it intact so
+ # it surfaces as a dispatch/error path rather than getting silently
+ # delivered into the wrong chat.
+ return response
+
+ message = _strip_wrapping_quotes(target_match.group("message"))
+ return message if message else response
+
+
+def _install_messaging_response_patch():
+ """Prevent raw messaging pseudo-tool calls from leaking as final text."""
+ try:
+ module = __import__("run_agent", fromlist=["AIAgent"])
+ except (ImportError, ModuleNotFoundError):
+ return False
+
+ agent_cls = getattr(module, "AIAgent", None)
+ if agent_cls is None or getattr(agent_cls, _MESSAGING_RESPONSE_PATCH_ATTR, False):
+ return False
+
+ original = getattr(agent_cls, "_strip_think_blocks", None)
+ if not callable(original):
+ return False
+
+ def _strip_think_blocks(content):
+ return _normalize_raw_messaging_tool_response(
+ original(content),
+ current_platform=_get_current_messaging_platform(),
+ )
+
+ agent_cls._strip_think_blocks = staticmethod(_strip_think_blocks)
+ setattr(agent_cls, _MESSAGING_RESPONSE_PATCH_ATTR, True)
+ return True
+
+
def _should_inject_nemoclaw_context(user_message=None, is_first_turn=False):
"""Return whether this turn needs NemoClaw runtime grounding."""
if is_first_turn:
@@ -1068,6 +1192,14 @@ def _build_nemoclaw_agent_context(platform=None):
else "- Messaging adapters run in the parent Hermes gateway sandbox; child "
+ "tool-execution containers will not show their host/gateway config."
)
+ reply_line = None
+ if platform_text.lower() in _MESSAGING_PLATFORMS:
+ reply_line = (
+ f"- Reply to the current {platform_text} chat by returning normal assistant text. "
+ + "Use send_message only when the user explicitly asks you to send a separate "
+ + "cross-platform message; never write raw text such as `send_message: ...` "
+ + "or `to telegram: ...` as the final answer."
+ )
agent_identity_line = (
"- You are Hermes Agent running in a NemoClaw-managed OpenShell sandbox, "
+ "not a host-only assistant."
@@ -1089,32 +1221,38 @@ def _build_nemoclaw_agent_context(platform=None):
+ "nemoclaw_reload_skills, transcribe_audio."
)
- return "\n".join(
- [
- "NemoClaw runtime context:",
- agent_identity_line,
- child_tool_line,
- config_line,
- f"- NemoClaw provider state: model={info['model']}, "
- f"provider={info['provider']}, endpoint={info['base_url']}, "
- f"gateway={info['gateway']}.",
- tools_line,
- f"- Managed Nous tool broker: {broker_state}; configured services: "
- f"{service_text}. Raw Nous OAuth tokens are host-managed by NemoClaw "
- "and should not be expected inside the sandbox.",
- platform_line,
- ],
- )
+ lines = [
+ "NemoClaw runtime context:",
+ agent_identity_line,
+ child_tool_line,
+ config_line,
+ f"- NemoClaw provider state: model={info['model']}, "
+ f"provider={info['provider']}, endpoint={info['base_url']}, "
+ f"gateway={info['gateway']}.",
+ tools_line,
+ f"- Managed Nous tool broker: {broker_state}; configured services: "
+ f"{service_text}. Raw Nous OAuth tokens are host-managed by NemoClaw "
+ "and should not be expected inside the sandbox.",
+ platform_line,
+ ]
+ if reply_line:
+ lines.append(reply_line)
+ return "\n".join(lines)
def _pre_llm_call(**kwargs):
"""Inject non-visible NemoClaw runtime context into relevant Hermes turns."""
+ # Track platform on every turn (not gated on context injection) so the
+ # `_strip_think_blocks` normalizer (#4175) has a current-platform anchor
+ # even on non-first turns and non-grounding turns.
+ _set_current_messaging_platform(kwargs.get("platform"))
if not _should_inject_nemoclaw_context(
user_message=kwargs.get("user_message"),
is_first_turn=bool(kwargs.get("is_first_turn")),
):
return None
_install_nous_tool_broker_patch()
+ _install_messaging_response_patch()
return {"context": _build_nemoclaw_agent_context(platform=kwargs.get("platform"))}
@@ -1215,6 +1353,7 @@ def _handle_reload_skills(tool_input=None, context=None, **_kwargs):
def register(ctx):
"""Register NemoClaw tools and hooks with Hermes."""
_install_nous_tool_broker_patch()
+ _install_messaging_response_patch()
# Register status tool
ctx.register_tool(
@@ -1313,6 +1452,7 @@ def register(ctx):
# interrupt queue. Keep startup native and expose status through tools.
def _on_session_start(**kwargs):
_install_nous_tool_broker_patch()
+ _install_messaging_response_patch()
_reload_skills()
ctx.register_hook("on_session_start", _on_session_start)
diff --git a/test/hermes-plugin-handlers.test.ts b/test/hermes-plugin-handlers.test.ts
index 22487517f03..d84e5fdc6d2 100644
--- a/test/hermes-plugin-handlers.test.ts
+++ b/test/hermes-plugin-handlers.test.ts
@@ -219,4 +219,241 @@ print(json.dumps(result))
"http://host.openshell.internal:11436/firecrawl/v2/search",
);
});
+
+ it("normalizes raw messaging pseudo-tool responses before delivery", () => {
+ const output = runPython(`
+import importlib.util
+import json
+import pathlib
+import sys
+import types
+
+plugin_path = pathlib.Path(sys.argv[1])
+yaml_stub = types.ModuleType("yaml")
+yaml_stub.safe_load = lambda *_args, **_kwargs: {}
+sys.modules.setdefault("yaml", yaml_stub)
+
+run_agent = types.ModuleType("run_agent")
+class AIAgent:
+ @staticmethod
+ def _strip_think_blocks(content):
+ return content
+run_agent.AIAgent = AIAgent
+sys.modules["run_agent"] = run_agent
+
+spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path)
+plugin = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(plugin)
+
+patched = plugin._install_messaging_response_patch()
+
+# Same-platform: normalizes
+plugin._set_current_messaging_platform("telegram")
+class_patch_same = run_agent.AIAgent._strip_think_blocks(
+ 'send_message: "to telegram: Hello from the first message."'
+)
+
+# Cross-platform (telegram chat, slack target): MUST NOT normalize (#4175 review)
+class_patch_cross = run_agent.AIAgent._strip_think_blocks(
+ 'send_message: "to slack: should not leak to telegram"'
+)
+
+# Unknown current platform: MUST NOT normalize even when target is valid (#4175 review)
+plugin._set_current_messaging_platform(None)
+class_patch_unknown = run_agent.AIAgent._strip_think_blocks(
+ 'send_message: "to telegram: should not normalize without context"'
+)
+
+result = {
+ "patched": patched,
+ "targeted": plugin._normalize_raw_messaging_tool_response(
+ 'send_message: "to telegram: Hello! I am Hermes."',
+ current_platform="telegram",
+ ),
+ "untargeted": plugin._normalize_raw_messaging_tool_response(
+ "send_message: this is documentation, not a delivery target",
+ current_platform="telegram",
+ ),
+ "cross_platform_blocked": plugin._normalize_raw_messaging_tool_response(
+ 'send_message: "to slack: leaked into telegram chat"',
+ current_platform="telegram",
+ ),
+ "unknown_platform_blocked": plugin._normalize_raw_messaging_tool_response(
+ 'send_message: "to telegram: should not normalize without context"',
+ current_platform=None,
+ ),
+ "class_patch": class_patch_same,
+ "class_patch_cross": class_patch_cross,
+ "class_patch_unknown": class_patch_unknown,
+}
+print(json.dumps(result))
+`);
+
+ const result = JSON.parse(output) as {
+ patched: boolean;
+ targeted: string;
+ untargeted: string;
+ cross_platform_blocked: string;
+ unknown_platform_blocked: string;
+ class_patch: string;
+ class_patch_cross: string;
+ class_patch_unknown: string;
+ };
+
+ expect(result.patched).toBe(true);
+ // Same-platform: normalizer extracts the body
+ expect(result.targeted).toBe("Hello! I am Hermes.");
+ expect(result.class_patch).toBe("Hello from the first message.");
+ // No-platform body: original send_message: text is left intact
+ expect(result.untargeted).toBe(
+ "send_message: this is documentation, not a delivery target",
+ );
+ // Cross-platform target (telegram chat, slack target): must NOT be
+ // silently delivered into the telegram chat. The raw send_message: text
+ // is preserved so dispatch / error surfaces upstream. (#4175 review.)
+ expect(result.cross_platform_blocked).toBe(
+ 'send_message: "to slack: leaked into telegram chat"',
+ );
+ expect(result.class_patch_cross).toBe(
+ 'send_message: "to slack: should not leak to telegram"',
+ );
+ // Unknown current-platform context: refuse to normalize even when the
+ // target platform is valid, so a stray pseudo-call outside a known
+ // messaging session doesn't get delivered into the wrong chat.
+ expect(result.unknown_platform_blocked).toBe(
+ 'send_message: "to telegram: should not normalize without context"',
+ );
+ expect(result.class_patch_unknown).toBe(
+ 'send_message: "to telegram: should not normalize without context"',
+ );
+ });
+
+ it("anchors the strip_think_blocks patch via _pre_llm_call gateway hook", () => {
+ const output = runPython(`
+import importlib.util
+import json
+import pathlib
+import sys
+import types
+
+plugin_path = pathlib.Path(sys.argv[1])
+yaml_stub = types.ModuleType("yaml")
+yaml_stub.safe_load = lambda *_args, **_kwargs: {}
+sys.modules.setdefault("yaml", yaml_stub)
+
+run_agent = types.ModuleType("run_agent")
+class AIAgent:
+ @staticmethod
+ def _strip_think_blocks(content):
+ return content
+run_agent.AIAgent = AIAgent
+sys.modules["run_agent"] = run_agent
+
+spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path)
+plugin = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(plugin)
+
+plugin._get_sandbox_info = lambda: {
+ "agent": "hermes",
+ "model": "n",
+ "provider": "p",
+ "base_url": "b",
+ "gateway": "g",
+ "port": 1,
+}
+
+# Simulate the first Telegram turn arriving via the gateway hook chain.
+# _pre_llm_call should set the platform anchor AND install the patch on the
+# stubbed run_agent.AIAgent so the subsequent _strip_think_blocks call goes
+# through the platform-aware wrapper. This is the integration shape of the
+# Hermes gateway first-message path (#4175 review feedback from @cv).
+plugin._pre_llm_call(user_message="hello", is_first_turn=True, platform="telegram")
+on_telegram_same = AIAgent._strip_think_blocks(
+ 'send_message: "to telegram: Hello from gateway."'
+)
+on_telegram_cross = AIAgent._strip_think_blocks(
+ 'send_message: "to slack: leaked into telegram chat"'
+)
+
+# Simulate the next first-turn message on a different platform (Discord). The
+# patch is already installed; only the platform anchor should follow the new
+# turn so the wrapper re-evaluates target_platform against current_platform.
+plugin._pre_llm_call(user_message="hi", is_first_turn=True, platform="discord")
+on_discord_same = AIAgent._strip_think_blocks(
+ 'send_message: "to discord: Hello from gateway."'
+)
+on_discord_stale_target = AIAgent._strip_think_blocks(
+ 'send_message: "to telegram: should not normalize on discord"'
+)
+
+print(json.dumps({
+ "telegram_same": on_telegram_same,
+ "telegram_cross": on_telegram_cross,
+ "discord_same": on_discord_same,
+ "discord_stale_target": on_discord_stale_target,
+}))
+`);
+
+ const result = JSON.parse(output) as {
+ telegram_same: string;
+ telegram_cross: string;
+ discord_same: string;
+ discord_stale_target: string;
+ };
+
+ // First-turn Telegram hook path: body extracted via the patched chain.
+ expect(result.telegram_same).toBe("Hello from gateway.");
+ // Cross-platform target on the same Telegram session: preserved verbatim
+ // — proves the anchor refuses to silently deliver into the wrong chat.
+ expect(result.telegram_cross).toBe(
+ 'send_message: "to slack: leaked into telegram chat"',
+ );
+ // Subsequent Discord-turn hook path: anchor follows the new platform.
+ expect(result.discord_same).toBe("Hello from gateway.");
+ // Stale-target after platform switch (telegram body on a discord turn):
+ // also preserved, pinning that the anchor refreshes per-turn.
+ expect(result.discord_stale_target).toBe(
+ 'send_message: "to telegram: should not normalize on discord"',
+ );
+ });
+
+ it("grounds first Telegram turns to reply directly instead of spelling tool calls", () => {
+ const output = runPython(`
+import importlib.util
+import json
+import pathlib
+import sys
+import types
+
+plugin_path = pathlib.Path(sys.argv[1])
+yaml_stub = types.ModuleType("yaml")
+yaml_stub.safe_load = lambda *_args, **_kwargs: {}
+sys.modules.setdefault("yaml", yaml_stub)
+spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+module._get_sandbox_info = lambda: {
+ "agent": "hermes",
+ "model": "nemotron",
+ "provider": "nvidia",
+ "base_url": "http://localhost:8642/v1",
+ "gateway": "running",
+ "port": 8642,
+}
+
+context = module._pre_llm_call(
+ user_message="hello",
+ is_first_turn=True,
+ platform="telegram",
+)["context"]
+print(json.dumps({"context": context}))
+`);
+
+ const { context } = JSON.parse(output) as { context: string };
+
+ expect(context).toContain("Current Hermes messaging platform: telegram");
+ expect(context).toContain("Reply to the current telegram chat by returning normal assistant text");
+ expect(context).toContain("never write raw text such as `send_message:");
+ });
});