Skip to content
Draft
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
9 changes: 8 additions & 1 deletion docs/architecture/image-vs-hsm-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ NimbleCoAI/hermes-agent is a fork of NousResearch/hermes-agent. Every feature th

- **Useful to everyone** → upstream it (PR to NousResearch/hermes-agent) or cherry-pick to the public fork
- **NimbleCo-specific** → keep it in HSM scaffolding where possible (plugins, hooks, config). If it *must* be in the image (e.g., security fix), accept the fork cost
- **Experimental** → keep it in the private fork (hermes-swarm) until validated, then decide
- **Experimental** → if it can be expressed through extension points (a plugin, a hook, a skill — no core edits), ship it as a **standalone, opt-in HSM artifact**: *not* in the image, and *not* in the default base bundle. Because it's unproven, it shouldn't ride the image everyone pulls or auto-load on every agent — install it per-agent to validate, then promote it (into the base bundle, or upstream) once proven. Only fall back to "carry it in the private fork" if it genuinely cannot be done without core edits.

**Current fork divergence:**
- Public fork removes ~25 upstream tools (platform-specific integrations not needed for NimbleCo)
Expand Down Expand Up @@ -101,6 +101,8 @@ NimbleCoAI/hermes-agent is a fork of NousResearch/hermes-agent. Every feature th
| **Security defaults** | `HERMES_DM_POLICY=approved-only`, `HERMES_APPROVAL_ADMIN_ONLY=true`, `*_REQUIRE_MENTION=true` | Env vars that activate image-level features |
| **Docker hardening** | `read_only: true`, `cap_drop: ALL`, user 10000:10000, 2GB/2CPU limits | Compose-level security |

**Default bundle vs optional artifacts.** Not everything HSM *can* install is installed on every agent. The table above is the **default base bundle** — the sane, proven defaults every agent gets (see the *opinionated HSM base package* decision). HSM also hosts **optional / experimental artifacts** in the same `infra/templates/` tree that are installed **per-agent, opt-in** (a plugin enabled via `plugins.enabled`, a skill installed explicitly) and are deliberately kept *out* of the default bundle until validated. `human_escalation` (+ the `checkout` skill) is the first such optional artifact: an experimental human-in-the-loop purchasing capability — standalone, zero image footprint, not in the base bundle.

### The Boundary in Practice

```
Expand Down Expand Up @@ -156,3 +158,8 @@ NimbleCoAI/hermes-agent is a fork of NousResearch/hermes-agent. Every feature th

**"I want agents to auto-solve CAPTCHAs during browser automation"**
→ HSM. The browser tool already returns `bot_detection_warning` (image). CAPTCHA solving depends on deployment-specific services (Camofox URL, CapSolver API key). Ship as a plugin (`captcha_cascade`) that registers a `captcha_solve` tool. The agent's skill teaches it when to call the tool. No fork changes needed — the image provides the detection, the plugin provides the resolution.

**"I want agents to complete purchases with a human in the loop (relay a 2FA code, confirm the charge)"**
→ HSM, standalone + experimental. Ship as a plugin (`human_escalation` — registers `escalate_to_human` + `check_pending_escalation` tools and a `pre_gateway_dispatch` resume hook) plus a `checkout` file skill, both under `infra/templates/`. It rides only existing extension points (tool registration, the dispatch hook, the `send_message` tool, session env vars) — **zero fork changes, no recurring rebase cost**. Because it's unproven *and* spends real money, it stays an **opt-in** artifact (not the base bundle) until validated on a real agent. Design note: it deliberately avoids a gateway patch — the escalation tool posts to chat and returns, and the hook rewrites the user's reply to resume — so the whole feature lives in `infra/templates/` with nothing in the image.

> **Skill packaging caveat.** A skill that should *auto-trigger* (appear in the agent's `<available_skills>` index from its `description`) must ship as a **file skill** at `infra/templates/skills/<name>/SKILL.md` (like `checkout`, `captcha-escalation`, `ocr-and-documents`). A plugin can also expose a skill via `ctx.register_skill(...)`, but those are **opt-in explicit loads only** — *not* indexed and *not* auto-triggered (see `hermes_cli/plugins.py` `register_skill`). Use `register_skill` for reference docs the agent loads on demand; use a file skill when user intent should trigger it.
99 changes: 99 additions & 0 deletions infra/templates/plugins/human_escalation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""human_escalation plugin -- registers the escalation-bus tools and the resume hook.

Standalone HSM artifact. Installed by HSM into a plugin directory (e.g.
``/opt/data/.../plugins/human_escalation/`` or ``~/.hermes/plugins/human_escalation/``)
where there is NO ``plugins.human_escalation`` parent package. The real loader
(hermes-agent's ``PluginManager._load_directory_module``) imports this file as
``hermes_plugins.human_escalation`` with ``submodule_search_locations=[plugin_dir]``
and does NOT put plugin_dir on ``sys.path`` -- so ``register()`` resolves its
siblings with a loader-agnostic importlib-by-__file__ fallback (the same pattern
escalate.py / dispatch_hook.py already use to import store.py).
"""

import logging

logger = logging.getLogger(__name__)

_ESCALATE_SCHEMA = {
"name": "escalate_to_human",
"description": (
"Ask the human (over the current chat) for something the agent cannot do alone, "
"then END THE TURN and wait for their reply. Use kind='code_request' to relay an "
"SMS/2FA/email code, kind='confirmation' to get explicit go-ahead before charging "
"(payload: {line_items:[...], total:'$X'}), kind='link_handoff' to send a VNC/QR/link "
"to finish a step (payload: {url, media_path?}), kind='freeform' for an open question. "
"Returns immediately with status='awaiting'; the reply is retrieved next turn via "
"check_pending_escalation."
),
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["code_request", "confirmation", "link_handoff", "freeform"]},
"prompt": {"type": "string", "description": "The message to show the human."},
"payload": {"type": "object", "description": "kind-specific: confirmation={line_items,total}; link_handoff={url,media_path?}."},
"timeout_s": {"type": "integer", "description": "Seconds the escalation stays valid (default 300)."},
},
"required": ["kind", "prompt"],
},
}

_CHECK_SCHEMA = {
"name": "check_pending_escalation",
"description": (
"Call this at the START of a turn when you may be resuming a checkout. Returns the "
"pending escalation (kind + original prompt) for this chat and clears it; the user's "
"most recent message is the answer. Returns status='none' if nothing is pending."
),
"parameters": {"type": "object", "properties": {}, "required": []},
}


def register(ctx):
# Loader-agnostic sibling imports. The real HSM loader imports this package as
# ``hermes_plugins.human_escalation`` with submodule_search_locations set but
# does NOT add the plugin dir to sys.path, so a bare ``from escalate import``
# works only when the dir happens to be importable. When it is not (the
# standalone install location), fall back to loading escalate.py /
# dispatch_hook.py directly by __file__ -- the same self-contained importlib
# pattern escalate.py and dispatch_hook.py use for store.py. This makes the
# plugin load regardless of the package name the loader assigns and with NO
# dependency on a ``plugins.human_escalation`` parent package.
try:
from escalate import escalate_to_human, check_pending_escalation
from dispatch_hook import pre_gateway_dispatch
except ImportError:
import importlib.util
from pathlib import Path

def _load(modname):
spec = importlib.util.spec_from_file_location(
modname, Path(__file__).with_name(modname + ".py")
)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m

_esc = _load("escalate")
_dh = _load("dispatch_hook")
escalate_to_human = _esc.escalate_to_human
check_pending_escalation = _esc.check_pending_escalation
pre_gateway_dispatch = _dh.pre_gateway_dispatch

ctx.register_tool(
name="escalate_to_human",
toolset="escalation",
schema=_ESCALATE_SCHEMA,
handler=escalate_to_human,
description="Escalate to the human over chat and wait for their reply.",
emoji="🙋",
)
ctx.register_tool(
name="check_pending_escalation",
toolset="escalation",
schema=_CHECK_SCHEMA,
handler=check_pending_escalation,
description="Retrieve and clear a pending escalation when resuming.",
emoji="📥",
)
ctx.register_hook("pre_gateway_dispatch", pre_gateway_dispatch)
logger.info("human_escalation: registered escalate_to_human + check_pending_escalation + resume hook")
91 changes: 91 additions & 0 deletions infra/templates/plugins/human_escalation/dispatch_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""pre_gateway_dispatch hook: when a reply arrives for a conversation with a pending
escalation, rewrite the inbound text to nudge the agent to resume the checkout.

Zero-divergence resume mechanism -- lives entirely in the plugin, patches no gateway
source. Registered against the ``pre_gateway_dispatch`` hook (hermes_cli/plugins.py),
which fires once per inbound MessageEvent before auth/dispatch with kwargs
``event``, ``gateway``, ``session_store``. Returning ``{"action":"rewrite","text":...}``
replaces ``event.text``; returning ``None`` means normal flow.

Runtime event shape (gateway/platforms/base.py::MessageEvent +
gateway/session.py::SessionSource):

event.text -> str (message body)
event.source -> SessionSource
event.source.platform -> Platform enum; ``.value`` is the platform string
event.source.chat_id -> str

The EscalationStore is keyed ``platform:chat_id`` where ``platform`` is the
enum's ``.value`` string (gateway/run.py::_set_session_env stores
``context.source.platform.value``), so we read ``source.platform.value`` here to
match the key written by escalate_to_human.
"""

import os

try:
# Preferred: package-style import when the plugin dir is on sys.path
# (runtime) or registered as a package in sys.modules (some harnesses).
from store import EscalationStore # type: ignore[assignment]
except ModuleNotFoundError:
# Fallback: load store.py relative to this file via importlib. Handles the
# importlib.util.spec_from_file_location + submodule_search_locations test
# pattern where the plugin dir is not on sys.path but __file__ is set.
import importlib.util as _ilu
_store_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "store.py")
_store_spec = _ilu.spec_from_file_location("_he_store", _store_path)
_store_mod = _ilu.module_from_spec(_store_spec)
_store_spec.loader.exec_module(_store_mod)
EscalationStore = _store_mod.EscalationStore


def _platform_value(source) -> str:
"""Return the platform string for a SessionSource.

``source.platform`` is a ``Platform`` enum at runtime; its ``.value`` is the
string used as the EscalationStore key. Tolerates a plain-string platform
(synthetic/future events) by falling back to ``str``.
"""
platform = getattr(source, "platform", None)
if platform is None:
return ""
value = getattr(platform, "value", None)
if value:
return str(value)
return str(platform) if platform else ""


def _conversation(event):
"""Resolve (platform, chat_id) from the inbound event.

The real MessageEvent carries these on a nested ``source`` (SessionSource).
Returns ("", "") safely when the shape is unexpected so the hook no-ops
rather than raising inside the gateway dispatch path.
"""
source = getattr(event, "source", None)
if source is None:
return "", ""
platform = _platform_value(source)
chat_id = getattr(source, "chat_id", None) or ""
return platform, str(chat_id) if chat_id else ""


def pre_gateway_dispatch(event=None, gateway=None, session_store=None, **_kw):
if event is None:
return None

platform, chat_id = _conversation(event)
if not platform or not chat_id:
return None

rec = EscalationStore().get_active(platform, chat_id)
if rec is None:
return None

text = getattr(event, "text", "") or ""
prefix = (
f"[RESUME CHECKOUT] You previously asked the user: \"{rec['prompt']}\". "
f"Their reply follows. First call check_pending_escalation to clear the pending "
f"state, then continue the checkout skill using their answer below.\n\n"
)
return {"action": "rewrite", "text": prefix + text}
180 changes: 180 additions & 0 deletions infra/templates/plugins/human_escalation/escalate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Chat-send helpers for escalate_to_human + check_pending_escalation handlers.

Approach A' (no-patch async): escalate_to_human posts a prompt to the user's chat
and RETURNS -- the turn ends. The reply resolves via check_pending_escalation on the
next turn, nudged by the pre_gateway_dispatch hook (dispatch_hook.py).

This module intentionally contains ONLY the three primitive helpers. The tool
handlers (escalate_to_human, check_pending_escalation) live in Tasks 3 and 4.
"""

import json
import os
from typing import Optional

try:
from gateway.session_context import get_session_env
except ImportError: # outside the hermes runtime (unit tests)
def get_session_env(name: str, default: str = "") -> str:
return os.environ.get(name, default)

try:
# Preferred: package-style import when the plugin dir is a proper package
# on sys.path (runtime) or when the module is registered in sys.modules
# as a package (some test harnesses do this).
from store import EscalationStore # type: ignore[assignment]
except ModuleNotFoundError:
# Fallback: load store.py relative to this file via importlib. This handles
# the importlib.util.spec_from_file_location + submodule_search_locations
# test pattern where the plugin dir is not on sys.path but __file__ is set.
import importlib.util as _ilu
import sys as _sys
_store_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "store.py")
_store_spec = _ilu.spec_from_file_location("_he_store", _store_path)
_store_mod = _ilu.module_from_spec(_store_spec)
_store_spec.loader.exec_module(_store_mod)
EscalationStore = _store_mod.EscalationStore


def _session_target() -> str:
"""Return the send_message target string for the active session.

Format: ``platform:chat_id`` or ``platform:chat_id:thread_id`` when a
thread is present. Returns an empty string when platform or chat_id are
not available in the session environment (e.g. CLI context).
"""
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "")
if not platform or not chat_id:
return ""
target = f"{platform}:{chat_id}"
if thread_id:
target = f"{target}:{thread_id}"
return target


def _dispatch_send_message(target: str, message: str) -> str:
"""Dispatch the built-in send_message tool directly.

Implementation note: ``send_message_tool`` (tools/send_message_tool.py,
line 150) is a plain synchronous function with signature
``send_message_tool(args, **kw) -> str``. It accepts ``action``, ``target``,
and ``message`` in the args dict and returns a JSON string. Calling it
directly avoids needing the tool to be registered in the registry (which
requires full agent startup) while remaining fully correct at runtime.

This function is isolated so tests can monkeypatch it without importing any
gateway code.
"""
try:
from tools.send_message_tool import send_message_tool
except ImportError as exc:
raise RuntimeError(
"_dispatch_send_message: tools.send_message_tool is unavailable in this runtime context"
) from exc
return send_message_tool({"action": "send", "target": target, "message": message})


def _send_to_chat(target: str, message: str) -> str:
"""Send *message* to *target* via the built-in send_message tool."""
return _dispatch_send_message(target, message)


_DEFAULT_TIMEOUT_S = 300

_RESUME_INSTRUCTION = (
"I have sent your request to the user and recorded a pending escalation. "
"END YOUR TURN NOW and wait for their reply -- do not poll or loop. When they "
"respond, call check_pending_escalation to retrieve and clear it, then continue "
"from where you left off."
)


def _render_message(kind: str, prompt: str, payload: Optional[dict]) -> str:
payload = payload or {}
parts = [prompt]
if kind == "confirmation":
items = payload.get("line_items") or []
if items:
parts.append("")
parts.extend(f" • {it}" for it in items)
total = payload.get("total")
if total:
parts.append("")
parts.append(f"TOTAL: {total}")
parts.append("")
parts.append("Reply YES to confirm and charge, or anything else to cancel.")
elif kind == "link_handoff":
url = payload.get("url")
if url:
parts.append("")
parts.append(url)
media = payload.get("media_path")
if media:
parts.append(f"MEDIA:{media}")
parts.append("")
parts.append("Reply DONE when finished.")
elif kind == "code_request":
parts.append("")
parts.append("Reply with the code.")
return "\n".join(parts)


def escalate_to_human(args: dict, **_kw) -> str:
kind = (args.get("kind") or "freeform").strip()
prompt = args.get("prompt") or ""
payload = args.get("payload") or {}
try:
timeout_s = int(args.get("timeout_s") or _DEFAULT_TIMEOUT_S)
except (ValueError, TypeError):
timeout_s = _DEFAULT_TIMEOUT_S

if kind not in {"code_request", "confirmation", "link_handoff", "freeform"}:
return json.dumps({"error": f"unknown kind: {kind}"})
if not prompt:
return json.dumps({"error": "prompt is required"})

target = _session_target()
if not target:
return json.dumps({"error": "no active chat session (HERMES_SESSION_PLATFORM/CHAT_ID unset)"})

platform = get_session_env("HERMES_SESSION_PLATFORM", "")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
user_id = get_session_env("HERMES_SESSION_USER_ID", "")

store = EscalationStore()
escal_id = store.create(platform, chat_id, user_id, kind, prompt, timeout_s=timeout_s)

message = _render_message(kind, prompt, payload)
send_result = _send_to_chat(target, message)

return json.dumps({
"status": "awaiting",
"escal_id": escal_id,
"kind": kind,
"instruction": _RESUME_INSTRUCTION,
"send_result": send_result,
})


def check_pending_escalation(args: dict, **_kw) -> str:
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
if not platform or not chat_id:
return json.dumps({"status": "none"})

store = EscalationStore()
rec = store.get_active(platform, chat_id)
if rec is None:
return json.dumps({"status": "none"})
store.resolve(platform, chat_id) # clear it; the user's latest message is the answer
return json.dumps({
"status": "found",
"kind": rec["kind"],
"prompt": rec["prompt"],
"escal_id": rec["escal_id"],
"note": ("The user's most recent message is the answer to this escalation. "
"For kind=confirmation, proceed to submit/charge ONLY if the reply is "
"affirmative (yes/confirm/y); otherwise abort and report nothing was charged."),
})
Loading