Use the public approval hook across Hermes versions and preserve configured mention requirements for incoming messages. Verify exact approval callbacks and trigger behavior against mainline and Hermes 0.21.1.
- commit
- 1e42b938a8831675a48bf0f911175677196522a5
- parent
- b5fc550b1e8bc4163e6e04bc730d611a5071fdd3
- author
- Dami <dami.etoile@gmail.com>
| M | tinyagent/ | 2 | + |
| M | tinyagent/ | 8 | +++-- |
| M | tinyagent/ | 23 | ++++++++++++++ |
| M | tinyagent/ | 8 | +++++ |
| M | tinyagent/ | 49 | ++++++++++++++++++++++++++++-- |
@@ -31,6 +31,8 @@ hermes plugins enable tinyagent
3131 hermes gateway run3232 ```3333 34+By default, Hermes responds when an allowed user mentions the agent or replies to one of its messages. Set `TINY_REQUIRE_MENTION=false` to respond to all messages from allowed users in the selected rooms. This controls when Hermes responds; notifications still require an explicit mention by the agent.35+3436 `TINY_PRIVATE_KEY` is read from the environment by the helper and is never a command-line argument. Give that key membership and an agent grant for the selected rooms on Tiny, including attachment access. Use a relay with the [native interaction extension](../docs/extensions/tinyagent.md) for interactive cards. Hermes loads the adapter through its normal plugin system. Attachments use authenticated Tinyrelay endpoints, with size and hash checks.3537 3638 The helper exposes a JSON-lines RPC mode for the adapter. Its public commands are:@@ -18,7 +18,7 @@ from pathlib import Path
1818 from typing import Any1919 2020 from gateway.config import Platform, PlatformConfig21-from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult21+from gateway.platforms.base import BasePlatformAdapter, SendResult2222 from gateway.platforms.event import MessageEvent, MessageType2323 2424 try:@@ -71,6 +71,8 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
7171 home = extra.get("home_channel") or os.environ.get("TINY_HOME_CHANNEL", "")7272 self.home_room = str(home).strip() or (self.rooms[0] if self.rooms else "")7373 self.allowed = {x.strip().lower() for x in str(extra.get("allowed_users") or os.environ.get("TINY_ALLOWED_USERS", "")).split(",") if x.strip()}74+ require_mention = extra.get("require_mention", os.environ.get("TINY_REQUIRE_MENTION", "true"))75+ self.require_mention = str(require_mention).strip().lower() not in {"false", "0", "no", "off"}7476 self.rpc: TinyRPC | None = None7577 self.pubkey = ""7678 self._subscription = "tinyagent"@@ -138,7 +140,7 @@ class TinyAdapter(InteractionMixin, BasePlatformAdapter):
138140 rows = await self.rpc.query({"ids": [parent_id], "#h": [room], "limit": 1})139141 parent = rows[0] if rows else None140142 own_reply = bool(parent and parent.get("pubkey") == self.pubkey)141- if not mentioned and not own_reply:143+ if self.require_mention and not mentioned and not own_reply:142144 return143145 event_id = str(event.get("id", ""))144146 # Only thread kinds create a separate thread session. Kind 9 replies stay in the room.@@ -290,7 +292,7 @@ def _env_enablement() -> dict | None:
290292 return None291293 result = {"relay_url": relay}292294 for env, key_name in (("TINY_HOME_CHANNEL", "home_channel"), ("TINY_ALLOWED_USERS", "allowed_users"),293- ("TINY_CLI_PATH", "cli_path")):295+ ("TINY_CLI_PATH", "cli_path"), ("TINY_REQUIRE_MENTION", "require_mention")):294296 if os.environ.get(env):295297 result[key_name] = os.environ[env]296298 return result@@ -5,6 +5,7 @@ from __future__ import annotations
55 from dataclasses import dataclass66 import json77 import time8+from types import SimpleNamespace89 910 from gateway.platforms.base import SendResult1011 @@ -27,6 +28,28 @@ class Pending:
2728 2829 2930 class InteractionMixin:31+ async def send_exec_approval(self, chat_id, command, session_key, description="dangerous command",32+ metadata=None, allow_permanent=True, allow_session=True, smart_denied=False):33+ # Current Hermes centralizes prompt formatting; older releases call the same34+ # public adapter hook without providing that base implementation.35+ parent = getattr(super(), "send_exec_approval", None)36+ if parent is not None:37+ return await parent(chat_id=chat_id, command=command, session_key=session_key,38+ description=description, metadata=metadata, allow_permanent=allow_permanent,39+ allow_session=allow_session, smart_denied=smart_denied)40+ actions = [("Approve once", "once", "primary")]41+ if not smart_denied and allow_session:42+ actions.append(("Approve for session", "session", "secondary"))43+ if allow_permanent:44+ actions.append(("Always approve", "always", "secondary"))45+ actions.append(("Deny", "deny", "danger"))46+ text = f"Command approval required\n\n```\n{command}\n```\n\nReason: {description}"47+ if smart_denied:48+ text += "\n\nAn override applies to this operation only."49+ return await self._send_exec_approval_prompt(SimpleNamespace(50+ chat_id=chat_id, command=command, session_key=session_key, text=text,51+ metadata=metadata, actions=actions))52+3053 def _assignee(self, session_key, metadata):3154 return self._session_assignees.get(session_key) or (metadata or {}).get("user_id", "")3255 @@ -14,6 +14,14 @@ requires_env:
1414 prompt: Tiny private key1515 password: true1616 optional_env:17+ - name: TINY_CHANNELS18+ description: Comma-separated rooms to monitor19+ prompt: Rooms20+ password: false21+ - name: TINY_REQUIRE_MENTION22+ description: Require an agent mention or reply to start a turn (true by default)23+ prompt: Require a mention24+ password: false1725 - name: TINY_HOME_CHANNEL1826 description: Room used for standalone and cron delivery1927 prompt: Home room@@ -12,7 +12,6 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
1212 from adapter import TinyAdapter1313 from gateway.config import PlatformConfig1414 from gateway.platform_registry import PlatformEntry, platform_registry15-from gateway.platforms.base import ExecApprovalPrompt1615 from tools import approval, clarify_gateway, slash_confirm1716 from tools.approval_gateway_wait import _ApprovalEntry1817 @@ -123,9 +122,8 @@ def test_approval_uses_exact_request_and_offered_scope(adapter):
123122 first = _ApprovalEntry({"command": "first", "request_id": "first-id"})124123 second = _ApprovalEntry({"command": "second", "request_id": "second-id"})125124 approval._gateway_queues["session"] = [first, second]126- prompt = ExecApprovalPrompt("room", "session", "Allow second?", [("Once", "once", "primary"),127- ("Deny", "deny", "danger")], "second", "description", False)128- assert (await adapter._send_exec_approval_prompt(prompt)).success125+ assert (await adapter.send_exec_approval("room", "second", "session",126+ allow_permanent=False, allow_session=False)).success129127 request = adapter.rpc.events[-1]130128 await adapter._on_answer(response(request, "always"))131129 assert not first.event.is_set() and not second.event.is_set()@@ -243,3 +241,46 @@ def test_duplicate_callbacks_cannot_race_before_media_intake(adapter, monkeypatc
243241 async def run(): await asyncio.gather(adapter._on_event(event), adapter._on_event(event))244242 asyncio.run(run())245243 assert len(received) == 1244+245+246+247+def test_room_message_trigger_respects_require_mention(adapter, setting, expected):248+ instance = TinyAdapter(PlatformConfig(enabled=True, extra={249+ 'rooms': 'room', 'allowed_users': OWNER, 'require_mention': setting}))250+ instance.pubkey = BOT251+ instance.rpc = adapter.rpc252+ received = []253+ async def handle(message): received.append(message)254+ instance.handle_message = handle255+ async def run():256+ for author in (OWNER, OTHER):257+ await instance._on_event({'kind': 9, 'id': author, 'pubkey': author,258+ 'created_at': int(time.time()), 'content': 'hello', 'tags': [['h', 'room']]})259+ asyncio.run(run())260+ assert len(received) == expected261+ assert all(message.source.user_id == OWNER for message in received)262+263+264+265+266+267+268+269+270+271+def test_public_approval_hook_preserves_scopes_on_older_hermes(adapter, monkeypatch, legacy, session, permanent, smart, expected):272+ from gateway.platforms.base import BasePlatformAdapter273+ if legacy:274+ monkeypatch.delattr(BasePlatformAdapter, 'send_exec_approval', raising=False)275+ async def run():276+ entry = _ApprovalEntry({'command': 'safe preview', 'request_id': 'scoped-id'})277+ approval._gateway_queues['session'] = [entry]278+ result = await adapter.send_exec_approval('room', 'safe preview', 'session',279+ allow_session=session, allow_permanent=permanent, smart_denied=smart)280+ assert result.success281+ request = adapter.rpc.events[-1]282+ assert {t[1] for t in request['tags'] if t[0] == 'option'} == expected283+ assert not any(t[0] == 'mention' for t in request['tags'])284+ await adapter._on_answer(response(request, 'once'))285+ assert entry.event.is_set() and entry.result == 'once'286+ asyncio.run(run())