Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
130 changes: 114 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,60 @@ 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


def _normalize_raw_messaging_tool_response(response):
"""Convert a raw send_message pseudo-call into the message body.

Some first-turn messaging models emit text like
``send_message: "to telegram: Hello"`` as the whole final answer instead
of using the structured tool-calling channel. In a gateway chat, returning
the body is the correct delivery path because Hermes will send the final
response back to the origin chat.
"""
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

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 Exception:
return False
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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))

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 +1154,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,22 +1183,23 @@ 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):
Expand All @@ -1115,6 +1210,7 @@ def _pre_llm_call(**kwargs):
):
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 +1311,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 +1410,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)
95 changes: 95 additions & 0 deletions test/hermes-plugin-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,99 @@ 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)

result = {
"patched": plugin._install_messaging_response_patch(),
"targeted": plugin._normalize_raw_messaging_tool_response(
'send_message: "to telegram: Hello! I am Hermes."'
),
"untargeted": plugin._normalize_raw_messaging_tool_response(
"send_message: this is documentation, not a delivery target"
),
"class_patch": run_agent.AIAgent._strip_think_blocks(
'send_message: "to telegram: Hello from the first message."'
),
}
print(json.dumps(result))
`);

const result = JSON.parse(output) as {
patched: boolean;
targeted: string;
untargeted: string;
class_patch: string;
};

expect(result.patched).toBe(true);
expect(result.targeted).toBe("Hello! I am Hermes.");
expect(result.untargeted).toBe(
"send_message: this is documentation, not a delivery target",
);
expect(result.class_patch).toBe("Hello from the first message.");
});

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