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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ touching any business-logic plugin:
customer-service group chats where the bot shouldn't react to
every message but needs the preceding context when it does.
2. **handover** — silent-ingest customer messages while the owner
handles the chat manually. Activation via phrases, optional
aux-LLM classifier, or the `trigger_handover` tool the agent
itself can call mid-conversation.
handles the chat manually. Activation is agent-driven via the
`trigger_handover` tool; the gateway-side rule only enforces an
already-active handover (silent ingest + owner `/takeback`).

Both patterns are profile-agnostic — install once, configure per
profile via `config.yaml`. Works across every gateway platform
Expand Down Expand Up @@ -122,13 +122,15 @@ plugins:
owner:
platform: whatsapp
chat_id: "60123456789@s.whatsapp.net"
triggers:
phrases: ["speak to a human", "talk to owner"]
exit_command: "/takeback"
tool:
enabled: true
```

Handover only activates when the agent calls `trigger_handover`. There
is no gateway-side phrase or LLM-classifier trigger — tell the agent
*when* to escalate via your `AGENTS.md` (see the next section).

Profile isolation is automatic — Hermes resolves
`get_hermes_home()` to the active profile, so each profile gets its
own SQLite state file and configuration.
Expand Down Expand Up @@ -265,7 +267,7 @@ hermes-plugin-gateway-policy/
├── __init__.py # plugin entry: register() + register_rule()
├── config.py # dataclass config loader
├── state.py # PolicyState + SQLite HandoverStore
├── triggers.py # phrase / LLM classifier helpers
├── triggers.py # bot-mention helpers (used by listen_only)
├── notify.py # owner notification helpers
├── transcript_utils.py # silent-ingest helpers
├── rules/
Expand All @@ -274,7 +276,7 @@ hermes-plugin-gateway-policy/
│ └── handover.py
├── tools/
│ └── trigger_handover.py # tool schema + handler
├── tests/ # pytest suite (17 tests)
├── tests/ # pytest suite
├── pyproject.toml # dev-tool config (pytest, ruff)
├── LICENSE
├── .gitignore
Expand Down
5 changes: 3 additions & 2 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
- listen_only: buffer ambient group messages, collapse into the next tagged
turn, open a follow-up window so contiguous replies don't require re-tagging.
- handover: silent-ingest customer messages while the owner handles them
manually. Activation via phrases, optional aux-LLM classifier, or the
`trigger_handover` tool the agent can call mid-conversation.
manually. Activation is agent-driven via the ``trigger_handover`` tool;
the gateway-side rule only enforces an already-active handover (silent
ingest + owner ``/takeback``).

Extension API: external plugins may call `register_rule(fn, priority=50)`
to add their own pre-dispatch rules without modifying this plugin.
Expand Down
13 changes: 7 additions & 6 deletions after-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,26 @@ plugins:
owner:
platform: whatsapp
chat_id: "60123456789@s.whatsapp.net"
triggers:
phrases: ["speak to a human", "talk to owner"]
exit_command: "/takeback"
tool:
enabled: true
```

Handover activates only when the agent calls the `trigger_handover`
tool. There are no gateway-side phrase or LLM-classifier triggers.

## 3. Restart the gateway

```bash
hermes gateway restart
```

## 4. (Optional) Tell the agent when to escalate
## 4. Tell the agent when to escalate

If you enabled `handover.tool`, the agent can call `trigger_handover`
mid-conversation. Add a short "out of scope" section to your
profile's `AGENTS.md` so it knows when — the tool itself is
intentionally generic.
mid-conversation. The tool description is intentionally generic, so
add a short "out of scope" section to your profile's `AGENTS.md`
spelling out which requests require a human owner.

Full docs: see `README.md` in this plugin directory, or
[github.com/pebble-tech/hermes-plugin-gateway-policy](https://github.com/pebble-tech/hermes-plugin-gateway-policy).
34 changes: 11 additions & 23 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ plugins:

# -----------------------------------------------------------------
# handover: silent-ingest while the owner handles the chat.
#
# Activation is agent-driven only — the bot calls the
# ``trigger_handover`` tool when the conversation needs a human.
# Gateway-side phrase / LLM classifier triggers were removed
# (unreliable across languages, redundant with the agent's own
# judgement). Tell the agent *when* to escalate via your AGENTS.md.
# -----------------------------------------------------------------
handover:
enabled: false # explicit opt-in
Expand All @@ -50,29 +56,6 @@ plugins:
owner:
platform: whatsapp
chat_id: "60123456789@s.whatsapp.net"
# Optional: include chat name / customer id in the notification.
notify_template: |
Handover requested in {chat_id}
Reason: {reason}
Last message: {last_message}

triggers:
# Phrase triggers are naive but cheap. Prefer short, unambiguous
# phrases. Matching is case-insensitive substring.
phrases:
- "speak to a human"
- "talk to the owner"
- "real person please"

# Optional LLM classifier (uses Hermes's auxiliary_client).
# Useful when phrase matching is too noisy (e.g. "design" triggering
# on every jersey design chat). Adds ~1 aux-LLM call per DM.
llm_classifier:
enabled: false
dm_only: true # skip in groups to save tokens
# prompt: |
# Decide if this customer message warrants escalating to a
# human. Reply "yes" or "no" only.

# Auto-expire active handovers after this many minutes.
# 0 = never expire (owner must /takeback manually).
Expand All @@ -82,6 +65,11 @@ plugins:
# to end the handover and return control to the bot.
exit_command: "/takeback"

# Notification templates. Available placeholders:
# {customer_name} {chat_id} {platform} {reason} {activated_by}
notify_on_activate: "Handover: {customer_name} in {chat_id}. Reason: {reason}"
notify_on_exit: "Handover ended for {customer_name}."

# Register trigger_handover as a tool the agent can call.
# Guidance for *when* to call belongs in your AGENTS.md.
tool:
Expand Down
49 changes: 8 additions & 41 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,17 @@
owner:
platform: whatsapp
chat_id: "60123456789@s.whatsapp.net"
triggers:
phrases:
- "speak to a human"
- "talk to the owner"
llm_classifier:
enabled: false
dm_only: true
prompt: "..."
timeout_minutes: 60
exit_command: "/takeback"
notify_on_activate: "Handover: {customer_name} in {chat_id}. Reason: {reason}"
notify_on_exit: "Handover ended for {customer_name}."
tool:
enabled: true

Handover activation is agent-driven only: the `trigger_handover` tool is
the sole entry point. Phrase / LLM-classifier gateway-side triggers were
removed — they were unreliable in multi-language deployments and
duplicated the main agent's own context-aware judgement.
"""

from __future__ import annotations
Expand All @@ -67,24 +64,6 @@ def key(self) -> Tuple[str, str]:
return (self.platform, self.chat_id)


@dataclass
class LLMClassifierConfig:
enabled: bool = False
dm_only: bool = True
prompt: str = (
"Classify whether this customer message requires human handover. "
"Respond with exactly 'yes' or 'no'. A message requires handover if "
"the customer explicitly asks to speak to a human, or if they are "
"asking for something clearly outside normal self-serve requests."
)


@dataclass
class HandoverTriggers:
phrases: List[str] = field(default_factory=list)
llm_classifier: LLMClassifierConfig = field(default_factory=LLMClassifierConfig)


@dataclass
class OwnerConfig:
platform: Optional[str] = None
Expand All @@ -101,7 +80,6 @@ class HandoverConfig:
enabled: bool = False
platforms: List[str] = field(default_factory=lambda: ["whatsapp"])
owner: OwnerConfig = field(default_factory=OwnerConfig)
triggers: HandoverTriggers = field(default_factory=HandoverTriggers)
timeout_minutes: int = 60
exit_command: str = "/takeback"
notify_on_activate: str = (
Expand Down Expand Up @@ -205,20 +183,9 @@ def _parse_handover(raw: Dict[str, Any]) -> HandoverConfig:
chat_id=(str(owner_raw.get("chat_id") or "").strip() or None),
)

triggers_raw = raw.get("triggers") or {}
triggers = HandoverTriggers()
if isinstance(triggers_raw, dict):
phrases = triggers_raw.get("phrases") or []
if isinstance(phrases, list):
triggers.phrases = [str(p).strip() for p in phrases if str(p).strip()]
llm_raw = triggers_raw.get("llm_classifier") or {}
if isinstance(llm_raw, dict):
triggers.llm_classifier = LLMClassifierConfig(
enabled=bool(llm_raw.get("enabled", False)),
dm_only=bool(llm_raw.get("dm_only", True)),
prompt=str(llm_raw.get("prompt") or LLMClassifierConfig().prompt),
)
cfg.triggers = triggers
# `triggers:` (phrases / llm_classifier) used to live here. Removed —
# handover is now activated only via the `trigger_handover` agent tool.
# Any leftover `triggers:` block in profile config.yaml is ignored.

cfg.timeout_minutes = int(raw.get("timeout_minutes", cfg.timeout_minutes) or 0)
cfg.exit_command = str(raw.get("exit_command", cfg.exit_command))
Expand Down
7 changes: 3 additions & 4 deletions plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
manifest_version: 1
name: gateway-policy
version: 0.1.0
version: 0.2.0
description: >
Gateway-level message-flow patterns for Hermes: listen-only windows (buffer
ambient group messages and collapse on tag) and human handover (silent
ingest + owner notification). Profile-agnostic; configured per profile via
ingest + owner notification, activated via the agent-callable
`trigger_handover` tool). Profile-agnostic; configured per profile via
config.yaml. Requires the `pre_gateway_dispatch` core hook.
author: pebble-tech
homepage: https://github.com/pebble-tech/hermes-plugin-gateway-policy
license: MIT

# No required env vars at the plugin level; the optional LLM classifier uses
# Hermes's auxiliary_client and consults config.yaml `auxiliary.*` settings.
requires_env: []

provides_hooks:
Expand Down
Loading