Skip to content

Commit ca8c6e2

Browse files
committed
fix(hermes): harden messaging onboarding
1 parent 0b2f15d commit ca8c6e2

11 files changed

Lines changed: 610 additions & 129 deletions

File tree

agents/hermes/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ ARG NEMOCLAW_INFERENCE_API=openai-completions
101101
ARG CHAT_UI_URL=http://127.0.0.1:8642
102102
ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=
103103
ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=
104+
ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=
104105
ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10=
105106
ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0
106107
ARG NEMOCLAW_BUILD_ID=default
@@ -114,6 +115,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
114115
CHAT_UI_URL=${CHAT_UI_URL} \
115116
NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \
116117
NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \
118+
NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \
117119
NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64} \
118120
NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER}
119121

agents/hermes/generate-config.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const TOKEN_ENV: Record<string, string> = {
2424
slack: "SLACK_BOT_TOKEN",
2525
};
2626

27+
const APP_TOKEN_ENV: Record<string, string> = {
28+
slack: "SLACK_APP_TOKEN",
29+
};
30+
2731
const ALLOWED_USERS_ENV: Record<string, string> = {
2832
telegram: "TELEGRAM_ALLOWED_USERS",
2933
discord: "DISCORD_ALLOWED_USERS",
@@ -60,6 +64,14 @@ type ToolGatewayMatrixEntry = {
6064

6165
type ToolGatewayMatrix = Record<string, ToolGatewayMatrixEntry>;
6266

67+
type DiscordGuildConfig = Record<
68+
string,
69+
{
70+
requireMention?: boolean;
71+
users?: string[];
72+
}
73+
>;
74+
6375
function loadToolGatewayMatrix(): ToolGatewayMatrix {
6476
const scriptDir = dirname(fileURLToPath(import.meta.url));
6577
const candidates = [
@@ -127,6 +139,44 @@ function parseToolGatewayPresets(): string[] {
127139
});
128140
}
129141

142+
function parseDiscordGuilds(): DiscordGuildConfig {
143+
const raw = process.env.NEMOCLAW_DISCORD_GUILDS_B64 || "e30=";
144+
try {
145+
const parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf-8"));
146+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
147+
return parsed as DiscordGuildConfig;
148+
} catch {
149+
return {};
150+
}
151+
}
152+
153+
function discordAllowedUsers(
154+
allowedIds: Record<string, (string | number)[]>,
155+
discordGuilds: DiscordGuildConfig,
156+
): string[] {
157+
const users = new Set<string>();
158+
for (const id of allowedIds.discord ?? []) {
159+
const value = String(id).trim();
160+
if (value) users.add(value);
161+
}
162+
for (const guild of Object.values(discordGuilds)) {
163+
if (!Array.isArray(guild.users)) continue;
164+
for (const user of guild.users) {
165+
const value = String(user).trim();
166+
if (value) users.add(value);
167+
}
168+
}
169+
return [...users];
170+
}
171+
172+
function discordRequireMention(discordGuilds: DiscordGuildConfig): boolean | null {
173+
const configured = Object.values(discordGuilds)
174+
.map((guild) => guild.requireMention)
175+
.filter((value): value is boolean => typeof value === "boolean");
176+
if (configured.length === 0) return null;
177+
return configured.every(Boolean);
178+
}
179+
130180
function main(): void {
131181
const model = process.env.NEMOCLAW_MODEL!;
132182
const baseUrl = process.env.NEMOCLAW_INFERENCE_BASE_URL!;
@@ -139,6 +189,7 @@ function main(): void {
139189
const allowedIds: Record<string, (string | number)[]> = JSON.parse(
140190
Buffer.from(allowedIdsB64, "base64").toString("utf-8"),
141191
);
192+
const discordGuilds = parseDiscordGuilds();
142193
const toolGatewayPresets = parseToolGatewayPresets();
143194
const toolGatewayBrokerEnabled =
144195
toolGatewayPresets.length > 0 || process.env.NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER === "1";
@@ -223,6 +274,18 @@ function main(): void {
223274
allowed_users: allowedIds[ch].map(String).join(","),
224275
};
225276
}
277+
if (ch === "discord") {
278+
const users = discordAllowedUsers(allowedIds, discordGuilds);
279+
const requireMention = discordRequireMention(discordGuilds);
280+
const extra = {
281+
...((pCfg.extra as Record<string, unknown> | undefined) ?? {}),
282+
...(users.length > 0 ? { allowed_users: users.join(",") } : {}),
283+
...(requireMention !== null ? { require_mention: requireMention } : {}),
284+
};
285+
if (Object.keys(extra).length > 0) {
286+
pCfg.extra = extra;
287+
}
288+
}
226289
platformsConfig[ch] = pCfg;
227290
}
228291
}
@@ -281,7 +344,10 @@ function main(): void {
281344
if (ch in TOKEN_ENV) {
282345
envLines.push(`${TOKEN_ENV[ch]}=openshell:resolve:env:${TOKEN_ENV[ch]}`);
283346
}
284-
if (ch in ALLOWED_USERS_ENV && allowedIds[ch]?.length) {
347+
if (ch in APP_TOKEN_ENV) {
348+
envLines.push(`${APP_TOKEN_ENV[ch]}=openshell:resolve:env:${APP_TOKEN_ENV[ch]}`);
349+
}
350+
if (ch !== "discord" && ch in ALLOWED_USERS_ENV && allowedIds[ch]?.length) {
285351
const allowed = allowedIds[ch].map(String).join(",");
286352
envLines.push(`${ALLOWED_USERS_ENV[ch]}=${allowed}`);
287353
if (ch === "telegram") {
@@ -290,6 +356,14 @@ function main(): void {
290356
envLines.push("TELEGRAM_HOME_CHANNEL_NAME=NemoHermes DM");
291357
}
292358
}
359+
if (ch === "discord") {
360+
const allowed = discordAllowedUsers(allowedIds, discordGuilds).join(",");
361+
if (allowed) envLines.push(`DISCORD_ALLOWED_USERS=${allowed}`);
362+
const requireMention = discordRequireMention(discordGuilds);
363+
if (requireMention !== null) {
364+
envLines.push(`DISCORD_REQUIRE_MENTION=${requireMention ? "1" : "0"}`);
365+
}
366+
}
293367
}
294368

295369
const envPath = join(homedir(), ".hermes", ".env");

agents/hermes/plugin/__init__.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@
33
"""
44
NemoClaw plugin for Hermes Agent.
55
6-
Provides sandbox status tools and skill hot-reload when Hermes runs inside an
7-
OpenShell sandbox managed by NemoClaw.
6+
Provides sandbox status tools, skill hot-reload, managed-tool broker patches,
7+
and quiet runtime grounding when Hermes runs inside an OpenShell sandbox
8+
managed by NemoClaw.
89
910
Skill hot-reload: Hermes caches its skill slash-command registry in a
1011
module-global dict on first scan. New skills dropped on disk are invisible
1112
until the cache is cleared. This plugin provides a nemoclaw_reload_skills
1213
tool that clears the cache and re-scans, letting the agent pick up new
1314
skills without a gateway restart. The on_session_start hook also refreshes
1415
skills automatically at session boundaries.
16+
17+
Runtime grounding: earlier versions injected a visible startup banner, but
18+
Hermes TUI renders plugin-injected messages through the interrupt queue. This
19+
plugin now uses Hermes' pre_llm_call context hook so the model sees the
20+
NemoClaw sandbox/tool-execution topology without leaking visual noise into the
21+
chat transcript.
1522
"""
1623

1724
import atexit
@@ -41,6 +48,28 @@
4148
"modal": "MODAL_GATEWAY_URL",
4249
}
4350

51+
_NEMOCLAW_CONTEXT_KEYWORDS = (
52+
"browser",
53+
"config",
54+
"discord",
55+
"environment",
56+
"gateway",
57+
"hermes",
58+
"host",
59+
"logs",
60+
"modal",
61+
"nemoclaw",
62+
"openshell",
63+
"sandbox",
64+
"skill",
65+
"slack",
66+
"status",
67+
"telegram",
68+
"tool",
69+
"where am i",
70+
"whoami",
71+
)
72+
4473

4574
def _get_env_value(key, default=None):
4675
"""Read env from os.environ, then Hermes' dotenv-aware config loader."""
@@ -858,6 +887,81 @@ def _get_sandbox_info():
858887
}
859888

860889

890+
def _active_managed_gateway_services():
891+
"""List managed Nous services that have broker URLs configured."""
892+
services = []
893+
for service, env_key in _TOOL_GATEWAY_URL_ENV.items():
894+
if _get_env_value(env_key, ""):
895+
services.append(service)
896+
return services
897+
898+
899+
def _should_inject_nemoclaw_context(user_message=None, is_first_turn=False):
900+
"""Return whether this turn needs NemoClaw runtime grounding."""
901+
if is_first_turn:
902+
return True
903+
text = str(user_message or "").lower()
904+
return any(keyword in text for keyword in _NEMOCLAW_CONTEXT_KEYWORDS)
905+
906+
907+
def _build_nemoclaw_agent_context(platform=None):
908+
"""Build quiet, ephemeral context for Hermes' pre_llm_call hook."""
909+
info = _get_sandbox_info()
910+
hermes_home = (
911+
os.getenv("HERMES_HOME")
912+
or _get_env_value("HERMES_HOME", "")
913+
or "/sandbox/.hermes-data"
914+
)
915+
services = _active_managed_gateway_services()
916+
service_text = ", ".join(services) if services else "none detected"
917+
broker_state = "enabled" if _broker_mode_enabled() else "not enabled"
918+
platform_text = str(platform or "").strip()
919+
platform_line = (
920+
f"- Current Hermes messaging platform: {platform_text}. Messaging adapters "
921+
"run in the parent Hermes gateway sandbox; child tool-execution containers "
922+
"will not show their host/gateway config."
923+
if platform_text
924+
else "- Messaging adapters run in the parent Hermes gateway sandbox; child "
925+
"tool-execution containers will not show their host/gateway config."
926+
)
927+
928+
return "\n".join(
929+
[
930+
"NemoClaw runtime context:",
931+
"- You are Hermes Agent running in a NemoClaw-managed OpenShell sandbox, "
932+
"not a host-only assistant.",
933+
"- Some tools, especially managed code/terminal tools, execute in child "
934+
"tool sandboxes such as Modal. Seeing /__modal, MODAL_SANDBOX_ID, a "
935+
"missing hermes binary, or missing ~/.hermes-data inside a tool shell "
936+
"means that shell is a child tool sandbox, not proof that Hermes is "
937+
"running on the host.",
938+
f"- Parent Hermes sandbox config lives under {hermes_home} and "
939+
"/sandbox/.hermes when available. Use nemoclaw_status or "
940+
"nemoclaw_info for NemoClaw environment questions.",
941+
f"- NemoClaw provider state: model={info['model']}, "
942+
f"provider={info['provider']}, endpoint={info['base_url']}, "
943+
f"gateway={info['gateway']}.",
944+
"- NemoClaw tools available: nemoclaw_status, nemoclaw_info, "
945+
"nemoclaw_reload_skills, transcribe_audio.",
946+
f"- Managed Nous tool broker: {broker_state}; configured services: "
947+
f"{service_text}. Raw Nous OAuth tokens are host-managed by NemoClaw "
948+
"and should not be expected inside the sandbox.",
949+
platform_line,
950+
],
951+
)
952+
953+
954+
def _pre_llm_call(**kwargs):
955+
"""Inject non-visible NemoClaw runtime context into relevant Hermes turns."""
956+
if not _should_inject_nemoclaw_context(
957+
user_message=kwargs.get("user_message"),
958+
is_first_turn=bool(kwargs.get("is_first_turn")),
959+
):
960+
return None
961+
_install_nous_tool_broker_patch()
962+
return {"context": _build_nemoclaw_agent_context(platform=kwargs.get("platform"))}
963+
964+
861965
def _handle_status(tool_input=None, context=None, **_kwargs):
862966
"""Handle the nemoclaw_status tool call."""
863967
info = _get_sandbox_info()
@@ -1043,6 +1147,10 @@ def register(ctx):
10431147
description="Reload skills from disk without gateway restart",
10441148
)
10451149

1150+
# Ground the model quietly through Hermes' context hook. This replaces the
1151+
# old visible startup banner without reintroducing TUI interrupt noise.
1152+
ctx.register_hook("pre_llm_call", _pre_llm_call)
1153+
10461154
# Refresh skills silently on session start. Earlier versions injected a
10471155
# system banner here, but that can interrupt the user's first prompt in the
10481156
# Hermes TUI because plugin-injected messages travel through Hermes's

nemoclaw-blueprint/policies/presets/discord.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,10 @@ network_policies:
4444
rules:
4545
- allow: { method: GET, path: "/**" }
4646
binaries:
47+
- { path: /usr/local/bin/hermes }
48+
- { path: /usr/bin/python3 }
49+
- { path: /usr/bin/python3.11 }
50+
- { path: /usr/local/bin/python3 }
51+
- { path: /usr/local/bin/python3.11 }
4752
- { path: /usr/local/bin/node }
4853
- { path: /usr/bin/node }

nemoclaw-blueprint/policies/presets/slack.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,10 @@ network_policies:
4343
access: full
4444
tls: skip
4545
binaries:
46+
- { path: /usr/local/bin/hermes }
47+
- { path: /usr/bin/python3 }
48+
- { path: /usr/bin/python3.11 }
49+
- { path: /usr/local/bin/python3 }
50+
- { path: /usr/local/bin/python3.11 }
4651
- { path: /usr/local/bin/node }
4752
- { path: /usr/bin/node }

nemoclaw-blueprint/policies/presets/telegram.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,10 @@ network_policies:
1919
- allow: { method: POST, path: "/bot*/**" }
2020
- allow: { method: GET, path: "/file/bot*/**" }
2121
binaries:
22+
- { path: /usr/local/bin/hermes }
23+
- { path: /usr/bin/python3 }
24+
- { path: /usr/bin/python3.11 }
25+
- { path: /usr/local/bin/python3 }
26+
- { path: /usr/local/bin/python3.11 }
2227
- { path: /usr/local/bin/node }
2328
- { path: /usr/bin/node }

0 commit comments

Comments
 (0)