Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 156 additions & 16 deletions agents/hermes/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import ipaddress
import json
import os
import re
import subprocess
import sys
from dataclasses import replace as dataclass_replace
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<body>.+?)\s*$",
flags=re.IGNORECASE | re.DOTALL,
)
_RAW_MESSAGING_TARGET_RE = re.compile(
r"^(?:to\s+)?(?P<platform>"
+ "|".join(re.escape(platform) for platform in _MESSAGING_PLATFORMS)
+ r")\s*:\s*(?P<message>.+)$",
flags=re.IGNORECASE | re.DOTALL,
)

_BROKER_ALWAYS_BLOCKED_HOSTNAMES = {
"localhost",
"metadata.google.internal",
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:
Expand Down Expand Up @@ -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."
Expand All @@ -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"))}


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Loading
Loading