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:"); + }); });