From 8307f9c09aae7c338a8995b72f98ae84a2cbd655 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 14 Jul 2026 07:05:35 +0000 Subject: [PATCH 1/2] Add companion webhook and tunnel route extensions --- README.md | 15 ++++++++ __init__.py | 7 ++++ adapter.py | 33 ++++++++++++++--- http_routes.py | 41 +++++++++++++++++++++ skills/inkbox-webhook-providers/SKILL.md | 10 ++++++ tests/test_adapter_dedup.py | 9 ++++- tests/test_http_routes.py | 46 ++++++++++++++++++++++++ tests/test_webhook_providers.py | 32 +++++++++++++++++ webhook_providers/base.py | 20 ++++++++++- 9 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 http_routes.py create mode 100644 tests/test_http_routes.py diff --git a/README.md b/README.md index 38dce7d..2f148c4 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,21 @@ After the gateway starts: | `INKBOX_REALTIME_CONSULT_TIMEOUT_S` | no | plugin default | Seconds the Realtime voice agent waits for a Hermes consult before continuing. | | `INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS` | no | `true` | Fall back to Inkbox STT/TTS if OpenAI Realtime connect/auth fails before call accept. | +## Companion Plugin Extensions + +Standalone Hermes plugins can reuse the Inkbox agent tunnel for authenticated +third-party webhooks and OAuth callbacks without modifying the installed +Inkbox plugin. Import these functions from the loaded `hermes_plugins.inkbox` +module during the companion plugin's `register(ctx)` call: + +- `register_webhook_provider(ProviderClass)` registers a `WebhookProvider` + implementation. Verified events wake Hermes as `external:`. +- `register_http_route(method, path, handler)` mounts an aiohttp handler on the + existing Inkbox tunnel when the gateway starts. + +Providers may override `event_key(envelope=..., headers=...)` for retry +deduplication and set `skill` to auto-load a companion skill for verified events. + ## Channel Overrides Two optional blocks under the `inkbox:` platform config tailor the agent per diff --git a/__init__.py b/__init__.py index 0aa52aa..2db6de1 100644 --- a/__init__.py +++ b/__init__.py @@ -14,6 +14,8 @@ from .diagnostics import SETUP_HINT from .setup_wizard import interactive_setup from .tools import register_tools + from .http_routes import register_http_route + from .webhook_providers import WebhookProvider, register_provider as register_webhook_provider except ImportError: # pragma: no cover - direct local import/test fallback import importlib import sys @@ -31,6 +33,8 @@ _diagnostics = importlib.import_module(f"{_LOCAL_PACKAGE}.diagnostics") _setup_wizard = importlib.import_module(f"{_LOCAL_PACKAGE}.setup_wizard") _tools = importlib.import_module(f"{_LOCAL_PACKAGE}.tools") + _http_routes = importlib.import_module(f"{_LOCAL_PACKAGE}.http_routes") + _webhook_providers = importlib.import_module(f"{_LOCAL_PACKAGE}.webhook_providers") InkboxAdapter = _adapter.InkboxAdapter check_inkbox_requirements = _adapter.check_inkbox_requirements @@ -42,6 +46,9 @@ SETUP_HINT = _diagnostics.SETUP_HINT interactive_setup = _setup_wizard.interactive_setup register_tools = _tools.register_tools + register_http_route = _http_routes.register_http_route + WebhookProvider = _webhook_providers.WebhookProvider + register_webhook_provider = _webhook_providers.register_provider logger = logging.getLogger(__name__) _unconfigured_warning_emitted = False diff --git a/adapter.py b/adapter.py index 37a280f..607cf99 100644 --- a/adapter.py +++ b/adapter.py @@ -136,6 +136,7 @@ from .config import INKBOX_BASE_URL_DEFAULT, inkbox_client_kwargs from .diagnostics import inkbox_api_error_message, missing_config_message, is_inkbox_auth_error, is_inkbox_identity_error from .webhook_providers import match_provider + from .http_routes import registered_http_routes from .realtime import ( DEFAULT_MODEL as REALTIME_DEFAULT_MODEL, DEFAULT_VOICE as REALTIME_DEFAULT_VOICE, @@ -150,6 +151,7 @@ from config import INKBOX_BASE_URL_DEFAULT, inkbox_client_kwargs from diagnostics import inkbox_api_error_message, missing_config_message, is_inkbox_auth_error, is_inkbox_identity_error from webhook_providers import match_provider + from http_routes import registered_http_routes from realtime import ( DEFAULT_MODEL as REALTIME_DEFAULT_MODEL, DEFAULT_VOICE as REALTIME_DEFAULT_VOICE, @@ -1665,6 +1667,8 @@ async def connect(self, is_reconnect: bool = False, **kwargs) -> bool: self._app.router.add_get("/health", self._handle_health) self._app.router.add_post(self._webhook_path, self._handle_webhook) self._app.router.add_get(self._ws_path, self._handle_call_ws) + for route in registered_http_routes(): + self._app.router.add_route(route.method, route.path, route.handler) self._runner = web.AppRunner(self._app) await self._runner.setup() self._site = web.TCPSite(self._runner, self._host, self._port) @@ -2585,6 +2589,13 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": event_type = envelope.get("event_type") request_id = request.headers.get("X-Inkbox-Request-Id", "") + if not request_id and provider is not None: + event_key_fn = getattr(provider, "event_key", None) + if callable(event_key_fn): + request_id = event_key_fn( + envelope=envelope, + headers=dict(request.headers), + ) if request_id and self._dedup_begin(request_id): return web.Response(status=200, text="duplicate") @@ -2626,7 +2637,11 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": # That registration is the opt-in, so deliver regardless of the # external-events flag. response = await self._on_external_event( - envelope, request_id, verified=True + envelope, + request_id, + verified=True, + provider_name=source, + provider_skill=getattr(provider, "skill", None), ) elif self._external_events_enabled: # Everything else the operator opted into with the flag: an @@ -2635,7 +2650,11 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": # event family). ``verified`` is True only for the Inkbox-signed # case; unknown sources get the cautious directive. response = await self._on_external_event( - envelope, request_id, verified=(source is not None) + envelope, + request_id, + verified=(source is not None), + provider_name=source, + provider_skill=getattr(provider, "skill", None) if provider else None, ) else: # Not opted in (flag off) and no handler — drop without waking @@ -2928,6 +2947,8 @@ async def _on_external_event( envelope: Dict[str, Any], request_id: str = "", verified: bool = False, + provider_name: Optional[str] = None, + provider_skill: "str | list[str] | None" = None, ) -> "web.Response": """Wake the agent on a fresh thread for an externally-injected event. @@ -2973,10 +2994,11 @@ def _field(*names: str) -> str: return "" # Event name + where it came from (repo for GitHub, else any "source"). - event_name = _field("event_type", "event") or "external" + event_name = _field("event_type", "event", "type") or "external" source_name = ( _field("source") or str(github.get("repository") or repo.get("full_name") or "").strip() + or str(provider_name or "").strip() or "external" ) title = _field("title") @@ -3006,7 +3028,8 @@ def _field(*names: str) -> str: # explicit id (payload id or GitHub run id), fall back to the webhook # request id, and finally hash the payload so events never collide. event_key = ( - _field("id") + _field("trace_id") + or _field("id") or str(github.get("run_id") or workflow_run.get("id") or "").strip() or request_id ) @@ -3058,7 +3081,7 @@ def _field(*names: str) -> str: # Per-source operator overrides (system prompt and/or skills) — this is # the seam where the "what to do on this event" playbook is attached. channel_prompt, auto_skill = self._resolve_channel_overrides( - "external", chat_id, None + "external", chat_id, provider_skill ) # Prepend a directive: no human reads this thread and the agent's reply # is not delivered, so it must reason and act via tools. A VERIFIED diff --git a/http_routes.py b/http_routes.py new file mode 100644 index 0000000..22e5a21 --- /dev/null +++ b/http_routes.py @@ -0,0 +1,41 @@ +"""Extension registry for routes served on the Inkbox agent tunnel.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, List + + +@dataclass(frozen=True) +class HttpRoute: + method: str + path: str + handler: Callable[[Any], Any] + + +_ROUTES: List[HttpRoute] = [] + + +def register_http_route(method: str, path: str, handler: Callable[[Any], Any]) -> None: + """Register a callback route on the Inkbox adapter's aiohttp application.""" + normalized_method = str(method or "").strip().upper() + normalized_path = str(path or "").strip() + if not normalized_method: + raise ValueError("HTTP route method is required") + if not normalized_path.startswith("/"): + raise ValueError("HTTP route path must start with '/'") + if not callable(handler): + raise TypeError("HTTP route handler must be callable") + for route in _ROUTES: + if route.method == normalized_method and route.path == normalized_path: + if route.handler is handler: + return + raise ValueError( + f"HTTP route collision for {normalized_method} {normalized_path}" + ) + _ROUTES.append(HttpRoute(normalized_method, normalized_path, handler)) + + +def registered_http_routes() -> tuple[HttpRoute, ...]: + """Return an immutable snapshot of companion-plugin routes.""" + return tuple(_ROUTES) diff --git a/skills/inkbox-webhook-providers/SKILL.md b/skills/inkbox-webhook-providers/SKILL.md index 2485a98..c0c9936 100644 --- a/skills/inkbox-webhook-providers/SKILL.md +++ b/skills/inkbox-webhook-providers/SKILL.md @@ -40,6 +40,12 @@ its events reach the agent regardless of the pass-through flag). ## Steps to onboard a source +For a provider shipped inside a separate Hermes plugin, import +`hermes_plugins.inkbox` in that plugin's `register(ctx)` function and call its +public `register_webhook_provider(ProviderClass)` export. Do not copy a module +into the installed Inkbox plugin. Providers bundled with this repository may +continue using the auto-discovered module pattern below. + 1. **Drop a new file** `webhook_providers/.py` with a `WebhookProvider` subclass decorated with `@register_provider`. That's the whole registration step — the package auto-imports it at startup, no other file changes: @@ -83,6 +89,10 @@ its events reach the agent regardless of the pass-through flag). 4. **Test it** — add a case to `tests/test_webhook_providers.py` covering a valid and an invalid signature. +5. **Deduplicate provider retries** — if the source has its own delivery id, + override `event_key(envelope=..., headers=...)`. The adapter will use that + key when no `X-Inkbox-Request-Id` exists. + ## Getting `verify` right (the common mistakes) - **Sign the raw body, not a re-serialized copy.** `body` is the exact bytes diff --git a/tests/test_adapter_dedup.py b/tests/test_adapter_dedup.py index e81954e..5071a5b 100644 --- a/tests/test_adapter_dedup.py +++ b/tests/test_adapter_dedup.py @@ -51,7 +51,14 @@ def test_request_id_commits_after_success(monkeypatch): adapter = _adapter() # Unknown event types now fall through to the external-event path; stub it # so this test stays focused on the dedup commit/duplicate behavior. - async def _ok(_envelope, _request_id="", verified=False): + async def _ok( + _envelope, + _request_id="", + verified=False, + provider_name=None, + provider_skill=None, + ): + del verified, provider_name, provider_skill return types.SimpleNamespace(text="ok") monkeypatch.setattr(adapter, "_on_external_event", _ok) diff --git a/tests/test_http_routes.py b/tests/test_http_routes.py new file mode 100644 index 0000000..a7a0247 --- /dev/null +++ b/tests/test_http_routes.py @@ -0,0 +1,46 @@ +import sys +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +pkg = types.ModuleType("inkbox_plugin") +pkg.__path__ = [str(ROOT)] +sys.modules.setdefault("inkbox_plugin", pkg) + +from inkbox_plugin import http_routes + + +@pytest.fixture(autouse=True) +def clear_routes(): + previous = list(http_routes._ROUTES) + http_routes._ROUTES.clear() + yield + http_routes._ROUTES[:] = previous + + +def test_register_http_route_normalizes_and_lists(): + async def handler(request): + return request + + http_routes.register_http_route("get", "/oauth/callback", handler) + route = http_routes.registered_http_routes()[0] + assert (route.method, route.path, route.handler) == ("GET", "/oauth/callback", handler) + + +def test_register_http_route_rejects_collisions(): + async def first(request): + return request + + async def second(request): + return request + + http_routes.register_http_route("GET", "/oauth/callback", first) + with pytest.raises(ValueError, match="route collision"): + http_routes.register_http_route("GET", "/oauth/callback", second) + + +def test_register_http_route_requires_absolute_path(): + with pytest.raises(ValueError, match="start with"): + http_routes.register_http_route("GET", "oauth/callback", lambda request: request) diff --git a/tests/test_webhook_providers.py b/tests/test_webhook_providers.py index f4f9c58..36830b3 100644 --- a/tests/test_webhook_providers.py +++ b/tests/test_webhook_providers.py @@ -239,6 +239,38 @@ def _verify(**kwargs): assert captured["url"] == "https://agent.example/webhook" +def test_provider_event_key_deduplicates_labels_source_and_loads_skill(monkeypatch): + fake = types.SimpleNamespace( + name="whoop", + skill="whoop:whoop-coach", + verify=lambda **kwargs: True, + event_key=lambda **kwargs: kwargs["envelope"]["trace_id"], + ) + monkeypatch.setattr(adapter_mod, "match_provider", lambda headers: fake) + monkeypatch.setenv("INKBOX_WEBHOOK_SECRET_WHOOP", "whoop-secret") + adapter = _adapter(require_signature=True, external_events_enabled=False) + adapter._resolve_channel_overrides = lambda _modality, _chat_id, default: (None, default) + + def request(): + return _FakeRequest( + b'{"id":"sleep-1","type":"recovery.updated","trace_id":"trace-1"}', + headers={"X-WHOOP-Signature": "good"}, + request_id="", + ) + + first = asyncio.run(adapter._handle_webhook(request())) + second = asyncio.run(adapter._handle_webhook(request())) + + assert first.status == 200 and first.text == "ok" + assert second.status == 200 and second.text == "duplicate" + assert len(adapter._enqueued) == 1 + event = adapter._enqueued[0] + assert event.source.chat_id == "external:whoop" + assert event.source.thread_id == "external:whoop:trace-1" + assert event.auto_skill == "whoop:whoop-coach" + assert "event=recovery.updated" in event.text + + def test_inkbox_signed_external_shaped_event_routes_external(monkeypatch): # An Inkbox *signature* only means Inkbox vouched for delivery — a forwarded # external event (e.g. a CI escalation) is Inkbox-signed but is NOT a known diff --git a/webhook_providers/base.py b/webhook_providers/base.py index f11bcc3..c606d02 100644 --- a/webhook_providers/base.py +++ b/webhook_providers/base.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import List, Mapping, Optional, Type +from typing import Any, List, Mapping, Optional, Type class WebhookProvider: @@ -23,6 +23,8 @@ class WebhookProvider: #: Signature header that fingerprints this source. Sources that need more #: than one header to identify should override :meth:`matches` instead. provider_header: str = "" + #: Optional skill auto-loaded whenever this verified provider wakes Hermes. + skill: str | list[str] | None = None def matches(self, headers: Mapping[str, str]) -> bool: """Return whether an inbound request came from this source. @@ -63,6 +65,22 @@ def verify( """ raise NotImplementedError + def event_key( + self, + *, + envelope: Mapping[str, Any], + headers: Mapping[str, str], + ) -> str: + """Return a stable delivery id for providers without a request-id header. + + The adapter uses ``X-Inkbox-Request-Id`` for native Inkbox deliveries. + Third-party providers can override this hook to expose their equivalent + idempotency key. Returning an empty string leaves deduplication to the + event handler. + """ + del envelope, headers + return "" + # Registered providers, checked in registration order by ``match_provider``. _REGISTRY: List[WebhookProvider] = [] From 1f007f71d72dca83ab75ae3fde8c1d26f0927f34 Mon Sep 17 00:00:00 2001 From: dimavrem22 Date: Tue, 14 Jul 2026 19:51:35 +0000 Subject: [PATCH 2/2] fix: suppress duplicate same-thread iMessage replies --- README.md | 2 +- __init__.py | 12 ++ adapter.py | 5 +- reply_guard.py | 184 ++++++++++++++++++++++ skills/inkbox-imessage-responder/SKILL.md | 2 +- tests/test_registration.py | 9 ++ tests/test_reply_guard.py | 119 ++++++++++++++ tools.py | 2 +- 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 reply_guard.py create mode 100644 tests/test_reply_guard.py diff --git a/README.md b/README.md index caffffa..1b524d0 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ iMessage works differently from SMS: the agent does not get its own iMessage num If a person disconnects the agent, outbound sends to that conversation fail until they reconnect through the router and message the agent again. Conversation rows expose `assignment_status` (`active`/`released`) so the agent can see this, and `inkbox_list_imessage_assignments` lists who is currently connected. Outbound delivery transitions (`imessage.sent`, `imessage.delivered`) arrive as webhooks and are logged by the gateway without waking the agent; `imessage.delivery_failed` wakes the agent to fix and resend, matching the SMS lifecycle handling — where `text.delivery_unconfirmed` (carrier uncertainty, not a failure) is likewise logged without a wake. -Native attachments work in both outbound paths. In a normal channel reply, Hermes `MEDIA:/absolute/path` directives are securely validated, uploaded with the Inkbox SDK, and sent as iMessage media. For explicit `inkbox_send_imessage` calls, use `mediaPaths` for local files; use `mediaUrls` only for already-hosted public HTTP(S) URLs. iMessage supports one attachment of up to 10 MiB per message. +Native attachments work in both outbound paths. In a normal channel reply, Hermes `MEDIA:/absolute/path` directives are securely validated, uploaded with the Inkbox SDK, and sent as iMessage media. Do not also call `inkbox_send_imessage` for that current thread: the final reply is already delivered automatically, and the plugin suppresses a second final bubble if a same-thread explicit send slips through. For explicit sends to a different conversation, use `mediaPaths` for local files; use `mediaUrls` only for already-hosted public HTTP(S) URLs. iMessage supports one attachment of up to 10 MiB per message. Once someone is connected over iMessage, the agent can also place and receive **voice calls** with them over that same shared line — see [Two calling lines](#two-calling-lines). This works even for an agent that has no dedicated phone number. diff --git a/__init__.py b/__init__.py index 2db6de1..0d85b72 100644 --- a/__init__.py +++ b/__init__.py @@ -15,6 +15,11 @@ from .setup_wizard import interactive_setup from .tools import register_tools from .http_routes import register_http_route + from .reply_guard import ( + note_imessage_tool_delivery, + record_inbound_route, + suppress_duplicate_final, + ) from .webhook_providers import WebhookProvider, register_provider as register_webhook_provider except ImportError: # pragma: no cover - direct local import/test fallback import importlib @@ -34,6 +39,7 @@ _setup_wizard = importlib.import_module(f"{_LOCAL_PACKAGE}.setup_wizard") _tools = importlib.import_module(f"{_LOCAL_PACKAGE}.tools") _http_routes = importlib.import_module(f"{_LOCAL_PACKAGE}.http_routes") + _reply_guard = importlib.import_module(f"{_LOCAL_PACKAGE}.reply_guard") _webhook_providers = importlib.import_module(f"{_LOCAL_PACKAGE}.webhook_providers") InkboxAdapter = _adapter.InkboxAdapter @@ -47,6 +53,9 @@ interactive_setup = _setup_wizard.interactive_setup register_tools = _tools.register_tools register_http_route = _http_routes.register_http_route + note_imessage_tool_delivery = _reply_guard.note_imessage_tool_delivery + record_inbound_route = _reply_guard.record_inbound_route + suppress_duplicate_final = _reply_guard.suppress_duplicate_final WebhookProvider = _webhook_providers.WebhookProvider register_webhook_provider = _webhook_providers.register_provider @@ -201,6 +210,9 @@ def register(ctx) -> None: ), ) register_tools(ctx) + ctx.register_hook("pre_gateway_dispatch", record_inbound_route) + ctx.register_hook("post_tool_call", note_imessage_tool_delivery) + ctx.register_hook("transform_llm_output", suppress_duplicate_final) ctx.register_cli_command( name="inkbox", help="Inkbox plugin commands", diff --git a/adapter.py b/adapter.py index 4b0ed92..cda2d52 100644 --- a/adapter.py +++ b/adapter.py @@ -235,8 +235,9 @@ def _install_tunnel_log_filter() -> None: "Only call inkbox_send_sms to text a DIFFERENT conversation or number, never " "to reply here (that sends your message twice).", "imessage": "Your reply in this iMessage thread is sent automatically — just " - "write it. Only call inkbox_send_imessage to reach a DIFFERENT conversation or " - "person, never to reply here (that sends your message twice).", + "write it. For an attachment in this thread, include MEDIA:/absolute/path in " + "that one reply. Only call inkbox_send_imessage to reach a DIFFERENT conversation " + "or person, never to reply here (that sends your message twice).", "email": "Your reply to this email is sent automatically as a threaded reply — " "just write it. Only call inkbox_send_email to email a DIFFERENT thread or " "recipient, never to reply here (that sends your message twice).", diff --git a/reply_guard.py b/reply_guard.py new file mode 100644 index 0000000..bc90d68 --- /dev/null +++ b/reply_guard.py @@ -0,0 +1,184 @@ +"""Prevent explicit Inkbox replies from being auto-delivered twice. + +Hermes automatically delivers the final model response back to the inbound +thread. If an agent also calls ``inkbox_send_imessage`` for that same thread, +the tool delivery is already complete and the final response must be silent. +This module correlates those three lifecycle points without changing Hermes +core or suppressing confirmations for messages sent to a different recipient. +""" + +from __future__ import annotations + +import json +import threading +import time +from dataclasses import dataclass +from typing import Any, Optional + + +_ROUTE_TTL_SECONDS = 15 * 60 +_SUPPRESSION_TTL_SECONDS = 5 * 60 +_lock = threading.Lock() + + +@dataclass +class _InboundRoute: + session_key: str + conversation_id: str + remote_number: str + session_store: Any + expires_at: float + + +_routes: dict[str, _InboundRoute] = {} +_suppress_final_for_session: dict[str, float] = {} + + +def _platform_value(platform: Any) -> str: + return str(getattr(platform, "value", platform) or "").strip().lower() + + +def _conversation_from_thread(thread_id: Any) -> str: + value = str(thread_id or "").strip() + if value.startswith("imessage:conversation:"): + return value.split(":", 2)[2].strip() + if value.startswith("imessage:"): + return value.split(":", 1)[1].strip() + return "" + + +def _prune(now: float) -> None: + for key, route in list(_routes.items()): + if route.expires_at <= now: + _routes.pop(key, None) + for session_id, expires_at in list(_suppress_final_for_session.items()): + if expires_at <= now: + _suppress_final_for_session.pop(session_id, None) + + +def record_inbound_route(*, event: Any, gateway: Any, session_store: Any, **_kwargs: Any) -> None: + """Remember the current authorized iMessage route before agent dispatch.""" + source = getattr(event, "source", None) + if source is None or _platform_value(getattr(source, "platform", None)) != "inkbox": + return None + conversation_id = _conversation_from_thread(getattr(source, "thread_id", None)) + if not conversation_id: + return None + try: + session_key = str(gateway._session_key_for_source(source) or "").strip() + except Exception: + return None + if not session_key: + return None + + now = time.monotonic() + route = _InboundRoute( + session_key=session_key, + conversation_id=conversation_id, + remote_number=str(getattr(source, "user_id_alt", None) or "").strip(), + session_store=session_store, + expires_at=now + _ROUTE_TTL_SECONDS, + ) + with _lock: + _prune(now) + _routes[session_key] = route + return None + + +def _route_session_id(route: _InboundRoute) -> str: + store = route.session_store + try: + store._ensure_loaded() + with store._lock: + entry = store._entries.get(route.session_key) + return str(getattr(entry, "session_id", None) or "").strip() + except Exception: + return "" + + +def _successful_tool_result(result: Any) -> Optional[dict[str, Any]]: + try: + parsed = json.loads(result) if isinstance(result, str) else result + except Exception: + return None + if not isinstance(parsed, dict) or not parsed.get("ok") or parsed.get("error"): + return None + return parsed + + +def note_imessage_tool_delivery( + *, + tool_name: str, + args: Any, + result: Any, + session_id: str = "", + status: str = "", + **_kwargs: Any, +) -> None: + """Arm one final-response suppression after a same-thread tool send.""" + if tool_name != "inkbox_send_imessage" or status not in {"", "ok"}: + return None + parsed = _successful_tool_result(result) + if parsed is None or not isinstance(args, dict): + return None + session_id = str(session_id or "").strip() + if not session_id: + return None + + target_conversation = str( + args.get("conversationId") + or args.get("conversation_id") + or parsed.get("conversation_id") + or "" + ).strip() + target_number = str(args.get("to") or "").strip() + + now = time.monotonic() + with _lock: + _prune(now) + routes = list(_routes.values()) + + same_thread = False + for route in routes: + if _route_session_id(route) != session_id: + continue + same_thread = bool( + (target_conversation and target_conversation == route.conversation_id) + or (target_number and route.remote_number and target_number == route.remote_number) + ) + break + + if same_thread: + with _lock: + _prune(now) + _suppress_final_for_session[session_id] = now + _SUPPRESSION_TTL_SECONDS + return None + + +def suppress_duplicate_final( + *, + response_text: str, + session_id: str = "", + platform: Any = "", + **_kwargs: Any, +) -> Optional[str]: + """Replace only the armed same-thread final response with ``[SILENT]``.""" + del response_text + if _platform_value(platform) != "inkbox": + return None + session_id = str(session_id or "").strip() + if not session_id: + return None + now = time.monotonic() + with _lock: + _prune(now) + expires_at = _suppress_final_for_session.pop(session_id, None) + if expires_at is not None and expires_at > now: + return "[SILENT]" + return None + + +def _reset_for_tests() -> None: + with _lock: + _routes.clear() + _suppress_final_for_session.clear() diff --git a/skills/inkbox-imessage-responder/SKILL.md b/skills/inkbox-imessage-responder/SKILL.md index d3ec434..4f9bb4c 100644 --- a/skills/inkbox-imessage-responder/SKILL.md +++ b/skills/inkbox-imessage-responder/SKILL.md @@ -64,4 +64,4 @@ When someone puts a tapback on one of **your** messages, you receive a turn pref - `emphasize` may invite a brief acknowledgement or follow-up. - `love` / `like` / `laugh` / `dislike` are usually just acknowledgements that need no response. -Decide based on the reaction and the conversation. **If no visible reply is warranted, return exactly `[SILENT]`** — the Inkbox bridge drops it and nothing is sent. Reply normally (via `inkbox_send_imessage`) only when a response genuinely adds value. +Decide based on the reaction and the conversation. **If no visible reply is warranted, return exactly `[SILENT]`** — the Inkbox bridge drops it and nothing is sent. If a response genuinely adds value, write the normal reply once and let the current iMessage thread deliver it automatically; use `inkbox_send_imessage` only for a different conversation. diff --git a/tests/test_registration.py b/tests/test_registration.py index c2a9785..98b2f3c 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -30,6 +30,7 @@ def __init__(self): self.cli_commands = [] self.commands = [] self.skills = [] + self.hooks = [] def register_platform(self, **kwargs): self.platforms.append(kwargs) @@ -46,6 +47,9 @@ def register_command(self, *args, **kwargs): def register_skill(self, *args, **kwargs): self.skills.append((args, kwargs)) + def register_hook(self, *args, **kwargs): + self.hooks.append((args, kwargs)) + def _manifest_provides_tools() -> set[str]: tools: set[str] = set() @@ -108,6 +112,11 @@ def test_registers_inkbox_platform_tools_commands_and_skills(): assert ctx.cli_commands[0]["name"] == "inkbox" assert ctx.commands[0][0][0] == "inkbox" assert {args[0] for args, _kwargs in ctx.skills} + assert {args[0] for args, _kwargs in ctx.hooks} == { + "pre_gateway_dispatch", + "post_tool_call", + "transform_llm_output", + } def test_env_enablement_warns_once_when_plugin_is_unconfigured(monkeypatch, caplog): diff --git a/tests/test_reply_guard.py b/tests/test_reply_guard.py new file mode 100644 index 0000000..609c7ab --- /dev/null +++ b/tests/test_reply_guard.py @@ -0,0 +1,119 @@ +import json +import sys +import threading +import types +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +pkg = types.ModuleType("inkbox_plugin") +pkg.__path__ = [str(ROOT)] +sys.modules.setdefault("inkbox_plugin", pkg) + +from inkbox_plugin import reply_guard + + +class _Entry: + session_id = "session-123" + + +class _Store: + def __init__(self): + self._lock = threading.Lock() + self._entries = {"inkbox:contact-123": _Entry()} + + def _ensure_loaded(self): + return None + + +class _Gateway: + @staticmethod + def _session_key_for_source(_source): + return "inkbox:contact-123" + + +@pytest.fixture(autouse=True) +def _reset_guard(): + reply_guard._reset_for_tests() + yield + reply_guard._reset_for_tests() + + +def _record_current_route(): + source = types.SimpleNamespace( + platform=types.SimpleNamespace(value="inkbox"), + thread_id="imessage:imconv-123", + user_id_alt="+15555550101", + ) + event = types.SimpleNamespace(source=source) + reply_guard.record_inbound_route( + event=event, + gateway=_Gateway(), + session_store=_Store(), + ) + + +def test_same_thread_explicit_send_suppresses_one_final_response(): + _record_current_route() + reply_guard.note_imessage_tool_delivery( + tool_name="inkbox_send_imessage", + args={"conversationId": "imconv-123", "text": "chart", "mediaPaths": ["/tmp/chart.png"]}, + result=json.dumps({"ok": True, "conversation_id": "imconv-123"}), + session_id="session-123", + status="ok", + ) + + assert reply_guard.suppress_duplicate_final( + response_text="Sent it!", + session_id="session-123", + platform="inkbox", + ) == "[SILENT]" + assert reply_guard.suppress_duplicate_final( + response_text="A later reply", + session_id="session-123", + platform="inkbox", + ) is None + + +def test_different_conversation_keeps_confirmation(): + _record_current_route() + reply_guard.note_imessage_tool_delivery( + tool_name="inkbox_send_imessage", + args={"conversationId": "someone-else", "text": "hello"}, + result=json.dumps({"ok": True, "conversation_id": "someone-else"}), + session_id="session-123", + status="ok", + ) + + assert reply_guard.suppress_duplicate_final( + response_text="I messaged them.", + session_id="session-123", + platform="inkbox", + ) is None + + +def test_failed_send_and_other_platform_do_not_suppress(): + _record_current_route() + reply_guard.note_imessage_tool_delivery( + tool_name="inkbox_send_imessage", + args={"conversationId": "imconv-123", "text": "hello"}, + result=json.dumps({"error": "not connected"}), + session_id="session-123", + status="error", + ) + assert reply_guard.suppress_duplicate_final( + response_text="It failed.", session_id="session-123", platform="inkbox" + ) is None + + reply_guard.note_imessage_tool_delivery( + tool_name="inkbox_send_imessage", + args={"conversationId": "imconv-123", "text": "hello"}, + result=json.dumps({"ok": True, "conversation_id": "imconv-123"}), + session_id="session-123", + status="ok", + ) + assert reply_guard.suppress_duplicate_final( + response_text="CLI output", session_id="session-123", platform="local" + ) is None diff --git a/tools.py b/tools.py index f8672c7..b0146bf 100644 --- a/tools.py +++ b/tools.py @@ -1199,7 +1199,7 @@ def _place(): SEND_IMESSAGE_SCHEMA = { "name": "inkbox_send_imessage", - "description": "Send an iMessage from the configured Inkbox identity. Recipient-first channel: a person must have connected via the iMessage router and messaged this agent before outbound sends work, so prefer conversationId from an inbound message or inkbox_list_imessage_conversations.", + "description": "Send an iMessage to a different conversation or recipient from the configured Inkbox identity. Do not use this tool to reply to the iMessage that triggered the current turn; Hermes sends the final reply automatically, including MEDIA:/absolute/path attachments. Recipient-first channel: a person must have connected via the iMessage router and messaged this agent before outbound sends work.", "parameters": { "type": "object", "properties": {