diff --git a/README.md b/README.md index f30b80e..c4d3b5d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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/ @@ -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 diff --git a/__init__.py b/__init__.py index 1254400..a48695a 100644 --- a/__init__.py +++ b/__init__.py @@ -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. diff --git a/after-install.md b/after-install.md index 6f383f9..b142cd8 100644 --- a/after-install.md +++ b/after-install.md @@ -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). diff --git a/config.example.yaml b/config.example.yaml index 6b13054..c86083b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 @@ -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). @@ -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: diff --git a/config.py b/config.py index f3f7e88..13969d8 100644 --- a/config.py +++ b/config.py @@ -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 @@ -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 @@ -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 = ( @@ -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)) diff --git a/plugin.yaml b/plugin.yaml index a24f83e..ffab5d7 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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: diff --git a/rules/handover.py b/rules/handover.py index bb48a12..d808a70 100644 --- a/rules/handover.py +++ b/rules/handover.py @@ -1,13 +1,15 @@ """handover rule. -Three trigger tiers (in order of precedence per turn): - 1. Already-active handover -> silent ingest, no reply. - 2. Exit command from owner -> deactivate handover + notify. - 3. Phrase match in customer DM -> activate + ingest + notify. - 4. Optional aux-LLM classifier -> activate + ingest + notify. - -Tier 5 (agent-driven `trigger_handover` tool) lives in -``tools/trigger_handover.py`` and runs at agent turn-time, not here. +Activation lives entirely in the agent-callable ``trigger_handover`` tool +(see ``tools/trigger_handover.py``). This rule only enforces an *already +active* handover at the gateway: + + 1. Customer message during an active handover -> silent ingest, no reply. + 2. Owner sends ``exit_command`` in the customer chat -> deactivate + notify. + +Gateway-side activation (phrase match, LLM classifier) was removed — +phrases were unreliable across languages and the classifier duplicated +the main agent's own context-aware judgement. """ from __future__ import annotations @@ -17,7 +19,6 @@ from ..notify import notify_owner from ..transcript_utils import silent_ingest -from ..triggers import llm_classifier_says_handover, matches_phrases logger = logging.getLogger("gateway-policy.rules.handover") @@ -43,48 +44,6 @@ def _is_owner_message(event: Any, owner_platform: Optional[str], owner_chat_id: return str(sender) == owner_chat_id -def _activate(state, gateway, *, source, reason: str, activated_by: str = "") -> Dict[str, Any]: - cfg = state.config.handover - platform = _platform_str(source) - chat_id = str(getattr(source, "chat_id", "") or "") - if not platform or not chat_id: - return {"action": "skip", "reason": "handover_invalid_target"} - - ttl = cfg.timeout_minutes * 60 if cfg.timeout_minutes else None - row = state.handovers.activate( - platform, - chat_id, - reason=reason, - activated_by=activated_by, - ttl_seconds=ttl, - ) - customer_name = ( - getattr(source, "user_name", None) or getattr(source, "user_id", None) or "customer" - ) - - if cfg.owner.platform and cfg.owner.chat_id: - message = cfg.notify_on_activate.format( - customer_name=customer_name, - chat_id=chat_id, - platform=platform, - reason=reason, - activated_by=activated_by or "system", - ) - ok = notify_owner( - gateway, - owner_platform=cfg.owner.platform, - owner_chat_id=cfg.owner.chat_id, - message=message, - ) - if ok: - state.handovers.mark_notified(platform, chat_id) - logger.info( - "handover activated: platform=%s chat=%s reason=%s by=%s", - platform, chat_id, reason, activated_by, - ) - return {"action": "skip", "reason": f"handover_activated:{reason}"} - - def _deactivate(state, gateway, *, platform: str, chat_id: str, source) -> None: cfg = state.config.handover row = state.handovers.deactivate(platform, chat_id) @@ -124,65 +83,21 @@ def handover_rule(*, event, gateway, session_store, state, **_kwargs) -> Optiona if not chat_id: return None + if not state.handovers.is_active(platform, chat_id): + return None + text = (event.text or "").strip() - # ---------- Tier 0: owner exit command ---------- + # Owner steps into the customer chat with the exit command -> end handover. + # Possible only if the owner is somehow present in the chat (common in + # groups, less so in DMs without a shim). if ( cfg.exit_command and text.lower() == cfg.exit_command.lower() and _is_owner_message(event, cfg.owner.platform, cfg.owner.chat_id) ): - # Owner sends `/takeback` to themselves to end the handover for the - # most recently activated chat? More useful: owner sends it as a - # reply / forward in the customer's chat. We support both: - # if owner is in a customer chat, deactivate that one; otherwise - # this rule is a no-op (owner DM is not a handover). - return None # owner DM exit-command requires more context; skip here. - - # ---------- Tier 1: handover already active ---------- - if state.handovers.is_active(platform, chat_id): - # If the *owner* steps into the customer chat with the exit command, - # that ends the handover. (Possible only if owner is somehow in the - # chat — common in groups, less so in DMs without a shim.) - if ( - cfg.exit_command - and text.lower() == cfg.exit_command.lower() - and _is_owner_message(event, cfg.owner.platform, cfg.owner.chat_id) - ): - _deactivate(state, gateway, platform=platform, chat_id=chat_id, source=source) - return {"action": "skip", "reason": "handover_exit"} - # Customer message during active handover → silent ingest. - silent_ingest(session_store, event, reason="handover_active") - return {"action": "skip", "reason": "handover_active"} - - # ---------- Tier 2: phrase match ---------- - triggers = cfg.triggers - if triggers.phrases: - matched = matches_phrases(text, triggers.phrases) - if matched: - silent_ingest(session_store, event, reason="handover_phrase_trigger") - return _activate( - state, - gateway, - source=source, - reason=f"phrase:{matched}", - activated_by="phrase_trigger", - ) - - # ---------- Tier 3: optional aux-LLM classifier ---------- - classifier = triggers.llm_classifier - if classifier.enabled and text: - if classifier.dm_only and getattr(source, "chat_type", "") != "dm": - return None - verdict = llm_classifier_says_handover(classifier.prompt, text) - if verdict is True: - silent_ingest(session_store, event, reason="handover_llm_trigger") - return _activate( - state, - gateway, - source=source, - reason="llm_classifier", - activated_by="llm_classifier", - ) - - return None + _deactivate(state, gateway, platform=platform, chat_id=chat_id, source=source) + return {"action": "skip", "reason": "handover_exit"} + + silent_ingest(session_store, event, reason="handover_active") + return {"action": "skip", "reason": "handover_active"} diff --git a/tests/conftest.py b/tests/conftest.py index c6db524..6dcf138 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,6 @@ def session_store(): def fresh_state(tmp_path, monkeypatch): from gateway_policy.config import ( HandoverConfig, - HandoverTriggers, ListenOnlyConfig, OwnerConfig, PolicyConfig, @@ -134,7 +133,6 @@ def fresh_state(tmp_path, monkeypatch): enabled=True, platforms=["whatsapp"], owner=OwnerConfig(platform="whatsapp", chat_id="60111111111@s.whatsapp.net"), - triggers=HandoverTriggers(phrases=["speak to a human", "talk to owner"]), timeout_minutes=10, tool=ToolConfig(enabled=True), ), diff --git a/tests/test_rules.py b/tests/test_rules.py index a3c0164..21be6d7 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -228,31 +228,31 @@ class TestHandover: def test_disabled_returns_none(self, fresh_state, src_dm, session_store, gateway): from gateway_policy.rules.handover import handover_rule fresh_state.config.handover.enabled = False - event = FakeEvent(text="speak to a human", source=src_dm) + # Pre-activate to prove `enabled=False` short-circuits even when state exists. + fresh_state.handovers.activate( + "whatsapp", src_dm.chat_id, reason="manual", activated_by="test" + ) + event = FakeEvent(text="hello", source=src_dm) assert handover_rule( event=event, gateway=gateway, session_store=session_store, state=fresh_state ) is None - def test_phrase_triggers_activation_and_silent_ingest( - self, fresh_state, src_dm, session_store, gateway - ): + def test_inactive_chat_passes_through(self, fresh_state, src_dm, session_store, gateway): + """No active handover -> rule is a no-op. Activation now happens + only via the trigger_handover tool, never from inbound text.""" from gateway_policy.rules.handover import handover_rule event = FakeEvent(text="please let me speak to a human now", source=src_dm) result = handover_rule( event=event, gateway=gateway, session_store=session_store, state=fresh_state ) - assert result["action"] == "skip" - assert "phrase" in result["reason"] - # Active in DB. - assert fresh_state.handovers.is_active("whatsapp", src_dm.chat_id) - # Customer message went to transcript. - assert len(session_store.appended) == 1 + assert result is None + assert not fresh_state.handovers.is_active("whatsapp", src_dm.chat_id) + assert len(session_store.appended) == 0 def test_active_handover_silent_ingests_subsequent_messages( self, fresh_state, src_dm, session_store, gateway ): from gateway_policy.rules.handover import handover_rule - # Pre-activate. fresh_state.handovers.activate( "whatsapp", src_dm.chat_id, reason="manual", activated_by="test" ) @@ -266,21 +266,35 @@ def test_active_handover_silent_ingests_subsequent_messages( def test_other_platform_skipped(self, fresh_state, session_store, gateway): from gateway_policy.rules.handover import handover_rule tg = FakeSource(platform_str="telegram", chat_type="dm", chat_id="-100") - event = FakeEvent(text="speak to a human", source=tg) + event = FakeEvent(text="hi", source=tg) # Handover platforms = ['whatsapp'] only. assert handover_rule( event=event, gateway=gateway, session_store=session_store, state=fresh_state ) is None assert not fresh_state.handovers.is_active("telegram", "-100") - def test_phrase_match_case_insensitive(self, fresh_state, src_dm, session_store, gateway): + def test_owner_exit_command_in_active_chat_deactivates( + self, fresh_state, src_dm, session_store, gateway + ): + """Owner sending /takeback in the customer's chat ends the handover.""" from gateway_policy.rules.handover import handover_rule - event = FakeEvent(text="SPEAK TO A HUMAN", source=src_dm) + fresh_state.handovers.activate( + "whatsapp", src_dm.chat_id, reason="manual", activated_by="test" + ) + # Spoof the source so the message looks like it came from the owner. + owner_src = FakeSource( + platform_str="whatsapp", + chat_type="dm", + chat_id=src_dm.chat_id, + user_id=fresh_state.config.handover.owner.chat_id, + user_name="Owner", + ) + event = FakeEvent(text="/takeback", source=owner_src) result = handover_rule( event=event, gateway=gateway, session_store=session_store, state=fresh_state ) - assert result["action"] == "skip" - assert fresh_state.handovers.is_active("whatsapp", src_dm.chat_id) + assert result == {"action": "skip", "reason": "handover_exit"} + assert not fresh_state.handovers.is_active("whatsapp", src_dm.chat_id) # --------------------------------------------------------------------------- diff --git a/triggers.py b/triggers.py index f30e23a..00f2ae0 100644 --- a/triggers.py +++ b/triggers.py @@ -1,4 +1,11 @@ -"""Trigger detection: mentions, phrase matching, optional LLM classifier.""" +"""Trigger detection helpers. + +Currently only bot-mention detection (used by ``rules/listen_only.py``). +Phrase-matching and the optional LLM classifier paths were removed when +handover activation was consolidated to the ``trigger_handover`` tool — +they were unreliable across mixed-language deployments and redundant +with the main agent's own classification pass. +""" from __future__ import annotations @@ -79,62 +86,3 @@ def _strip_id(value: Any) -> str: .split(":", 1)[0] .split("@", 1)[0] ) - - -def matches_phrases(text: str, phrases: list[str]) -> Optional[str]: - """Return the first matched phrase (case-insensitive, substring), else None.""" - if not text or not phrases: - return None - needle = text.lower() - for phrase in phrases: - if not phrase: - continue - if phrase.lower() in needle: - return phrase - return None - - -def llm_classifier_says_handover(prompt: str, message: str) -> Optional[bool]: - """Optional LLM classifier via Hermes auxiliary client. - - Returns True/False on a confident classification; None on any error - (caller should treat None as "no decision"). - """ - text = (message or "").strip() - if not text: - return False - try: - from agent.auxiliary_client import call_llm - except Exception as exc: - logger.warning("auxiliary_client import failed: %s", exc) - return None - - full_prompt = ( - f"{prompt}\n\n" - f"Customer message:\n```\n{text}\n```\n\n" - "Reply with exactly one word: 'yes' or 'no'." - ) - try: - result = call_llm( - task="gateway_policy_classifier", - prompt=full_prompt, - max_tokens=4, - ) - except Exception as exc: - logger.warning("call_llm classifier failed: %s", exc) - return None - - raw = "" - if isinstance(result, str): - raw = result - elif isinstance(result, dict): - raw = str(result.get("text") or result.get("content") or "") - else: - raw = str(result or "") - raw = raw.strip().lower().strip(".!?\"' ") - if raw.startswith("yes"): - return True - if raw.startswith("no"): - return False - logger.debug("classifier returned ambiguous response: %r", raw) - return None