From 69ec5bc23b4784a1d93bb98f054531d38bab43d2 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 17 Jul 2026 15:48:53 -0500 Subject: [PATCH] feat: add typed media delivery contract --- AGENTS.md | 3 + README.md | 40 +++--- hermes_plugin_kit/__init__.py | 221 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_hermes_contract.py | 30 ++++- tests/test_kit.py | 131 ++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 408 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2069e61..06c68de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,9 @@ plugin. - Keep host invocation grounded in the real Hermes contract suite. For media delivery, exercise target parsing and platform formatting and mock only the final network client rather than replacing the host handler. +- Plugins must use `MediaPayload` + `deliver_media` for attachments. The kit + owns Hermes media directives, task-local `origin` resolution, route redaction, + and the typed result; consumers must not recreate those contracts. - Use `tool_name(namespace, verb, noun)` for new tools and prefer explicit verbs such as `read`, `write`, and `patch`. Do not use Hermes agent-loop names (`memory`, `todo`, `session_search`, `delegate_task`) as plugin tools. diff --git a/README.md b/README.md index b282b8d..cc6c2e1 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ the same boilerplate. `hermes-plugin-kit` makes them structurally impossible: handler. - **Host invocation** — call non-registry Hermes capabilities such as `send_message` without bypassing plugin guard and audit hooks. +- **Typed media delivery** — declare `MediaPayload` as `auto`, `voice`, or + `document`; resolve task-local `origin` inside the kit; and receive a + privacy-safe `MediaDeliveryResult` after the real Hermes-agent host send. + These types encode Hermes-agent's `send_message` contract; they are not an + OpenClaw compatibility layer. ## Who it's for @@ -201,33 +206,38 @@ accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as Not every Hermes capability lives in `tools.registry`. In particular, `send_message` is a host-managed runtime service, so calling `registry.dispatch("send_message", ...)` from inside a plugin returns an unknown-tool -error. Use the kit's host invocation seam instead: +error. Use the kit's typed media seam instead: ```python -from hermes_plugin_kit import invoke_host_tool - -def deliver_generated_image(path: str, target: str, **runtime_context): - return invoke_host_tool( - "send_message", - { - "action": "send", - "target": target, - "message": f"MEDIA:{path}", - }, +from hermes_plugin_kit import MediaPayload, MediaType, deliver_media + +def deliver_voice_memo(path: str, **runtime_context): + return deliver_media( + MediaPayload(path, MediaType.VOICE), + target="origin", **runtime_context, ) ``` +`MediaType.VOICE` accepts only `.ogg`/`.opus` and emits Hermes' +`[[audio_as_voice]]` directive. `MediaType.DOCUMENT` emits `[[as_document]]`; +`MediaType.AUTO` lets Hermes choose from the extension. `origin` resolves +through Hermes' task-local platform/chat/thread context inside the kit, so a +plugin never imports gateway internals or exposes raw group IDs to the model. +The returned `MediaDeliveryResult` carries success, media type, path, requested +route, a privacy-safe display route, and a redacted host result. + +For non-media host calls, `invoke_host_tool` remains the lower-level seam. `invoke_host_tool` resolves the supported direct host handler and wraps the nested operation with Hermes `pre_tool_call` and `post_tool_call` hooks. A blocking hook prevents the handler from running. If the guard API is unavailable, invocation is refused rather than sending without policy checks. `send_message` is the currently supported host tool; unknown names fail explicitly. -The upstream Hermes contract suite runs this exact generated-image payload through -the real `send_message` target parser, media extractor, and Telegram formatter. It -mocks only the final Bot API client and asserts that Hermes calls `send_photo` with -the numeric chat ID and generated file, without emitting a separate text message. +The upstream Hermes contract suite runs image and typed voice payloads through +the real `send_message` target parser, media extractor, and Telegram formatter. +It mocks only the final Bot API client and asserts that Hermes calls `send_photo` +and `send_voice` with the expected files, without separate text messages. ## Logging contract diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 777f93d..f44d465 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -54,6 +54,7 @@ def register(ctx): import sys import time from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import Any, Callable @@ -63,6 +64,12 @@ def register(ctx): "plugin_skill", "register_plugin", "invoke_host_tool", + "deliver_media", + "resolve_delivery_target", + "MediaType", + "MediaPayload", + "ResolvedDeliveryTarget", + "MediaDeliveryResult", "PluginSkill", "RegistrationSummary", "register_all", @@ -115,6 +122,81 @@ class RegistrationSummary: skipped_optional_skills: tuple[str, ...] = () +class MediaType(str, Enum): + """Hermes-agent ``send_message`` media directive modes.""" + + AUTO = "auto" + VOICE = "voice" + DOCUMENT = "document" + + +@dataclass(frozen=True) +class MediaPayload: + """A local attachment encoded in Hermes-agent's native message shape.""" + + path: Path | str + media_type: MediaType | str = MediaType.AUTO + caption: str = "" + + def __post_init__(self) -> None: + path = Path(self.path).expanduser() + if not path.is_absolute(): + raise ValueError("media path must be absolute") + try: + media_type = ( + self.media_type + if isinstance(self.media_type, MediaType) + else MediaType(self.media_type) + ) + except ValueError as exc: + raise ValueError("media_type must be auto, voice, or document") from exc + if media_type is MediaType.VOICE and path.suffix.lower() not in {".ogg", ".opus"}: + raise ValueError("voice media must use an ogg or opus container") + object.__setattr__(self, "path", path) + object.__setattr__(self, "media_type", media_type) + object.__setattr__(self, "caption", str(self.caption or "").strip()) + + def to_message(self) -> str: + directive = "" + if self.media_type is MediaType.VOICE: + directive = "[[audio_as_voice]]\n" + elif self.media_type is MediaType.DOCUMENT: + directive = "[[as_document]]\n" + caption = f"{self.caption}\n" if self.caption else "" + return f"{caption}{directive}MEDIA:{self.path}" + + +@dataclass(frozen=True) +class ResolvedDeliveryTarget: + """Requested route plus the host-only route and privacy-safe display form.""" + + requested: str + host_target: str + display: str + + +@dataclass(frozen=True) +class MediaDeliveryResult: + """Typed result from Hermes host media delivery.""" + + success: bool + requested_target: str + display_target: str + media_type: MediaType + path: Path + host_result: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + return { + "success": self.success, + "requested_target": self.requested_target, + "display_target": self.display_target, + "media_type": self.media_type.value, + "path": str(self.path), + "host_result": self.host_result, + } + + # --------------------------------------------------------------------------- # Tool naming # --------------------------------------------------------------------------- @@ -498,6 +580,145 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str return result +def _display_delivery_identifier(value: str) -> str: + raw = str(value or "") + sign = "-" if raw.startswith("-") else "" + digits = raw.lstrip("-") + if digits.isdigit() and len(digits) > 4: + return f"{sign}…{digits[-4:]}" + return raw + + +def _display_delivery_target(target: str) -> str: + parts = str(target or "").split(":") + if len(parts) >= 2: + parts[1] = _display_delivery_identifier(parts[1]) + return ":".join(parts) + + +def resolve_delivery_target(target: str) -> ResolvedDeliveryTarget: + """Resolve ``origin`` through Hermes task-local context. + + Consumers never need to import ``gateway.session_context``. Explicit host + targets pass through unchanged; ``origin`` binds to the current platform, + chat, and optional thread without exposing the raw route to the model. + """ + requested = str(target or "").strip() + if not requested: + raise ValueError("delivery target is required") + if requested != "origin": + return ResolvedDeliveryTarget( + requested=requested, + host_target=requested, + display=_display_delivery_target(requested), + ) + try: + from gateway.session_context import get_session_env + + platform = get_session_env("HERMES_SESSION_PLATFORM", "").strip().lower() + chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "").strip() + thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "").strip() + except Exception as exc: + raise RuntimeError( + f"current Hermes delivery origin is unavailable: {type(exc).__name__}" + ) from exc + if not platform or not chat_id: + raise RuntimeError("current Hermes delivery origin has no platform/chat route") + host_target = f"{platform}:{chat_id}" + if thread_id: + host_target = f"{host_target}:{thread_id}" + return ResolvedDeliveryTarget( + requested=requested, + host_target=host_target, + display=_display_delivery_target(host_target), + ) + + +def _safe_media_host_result( + payload: Any, + resolved: ResolvedDeliveryTarget, +) -> dict[str, Any]: + parts = resolved.host_target.split(":") + raw_chat_id = parts[1] if len(parts) >= 2 else "" + display_chat_id = _display_delivery_identifier(raw_chat_id) + + def redact(value: Any) -> Any: + if isinstance(value, dict): + return {key: redact(item) for key, item in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, tuple): + return [redact(item) for item in value] + if isinstance(value, str): + safe = value.replace(resolved.host_target, resolved.display) + if raw_chat_id and raw_chat_id != display_chat_id: + safe = safe.replace(raw_chat_id, display_chat_id) + return safe + return value + + safe = redact(payload) + return safe if isinstance(safe, dict) else {"result": safe} + + +def deliver_media( + media: MediaPayload, + *, + target: str | ResolvedDeliveryTarget = "origin", + **context: Any, +) -> MediaDeliveryResult: + """Deliver typed local media through Hermes' guarded host messaging seam.""" + if not isinstance(media, MediaPayload): + raise TypeError("media must be a MediaPayload") + if not media.path.is_file() or media.path.stat().st_size <= 0: + raise ValueError(f"media file is missing or empty: {media.path}") + resolved = ( + target + if isinstance(target, ResolvedDeliveryTarget) + else resolve_delivery_target(target) + ) + log = logging.getLogger("hermes_plugin_kit") + log.info( + "event=media_delivery_started target=%s media_type=%s path=%s", + resolved.display, + media.media_type.value, + media.path, + ) + raw = invoke_host_tool( + "send_message", + { + "action": "send", + "target": resolved.host_target, + "message": media.to_message(), + }, + **context, + ) + try: + host_payload = json.loads(raw) + except (TypeError, json.JSONDecodeError): + host_payload = {"error": "send_message returned invalid JSON"} + success = _host_result_fields(raw)[0] == "success" + safe_result = _safe_media_host_result(host_payload, resolved) + log_method = log.info if success else log.warning + log_method( + "event=media_delivery_completed target=%s media_type=%s success=%s", + resolved.display, + media.media_type.value, + success, + ) + return MediaDeliveryResult( + success=success, + requested_target=( + resolved.requested + if resolved.requested == "origin" + else _display_delivery_target(resolved.requested) + ), + display_target=resolved.display, + media_type=media.media_type, + path=media.path, + host_result=safe_result, + ) + + def tool( *, toolset: str, diff --git a/pyproject.toml b/pyproject.toml index 6d7d6c4..2ec95bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-plugin-kit" -version = "0.1.0" +version = "0.2.0" description = "Convention-correct lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index f903f98..6898bff 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -211,8 +211,8 @@ def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None: description="Probe", ) - def test_host_tool_invocation_reaches_real_telegram_photo_contract(self) -> None: - """Exercise Hermes parsing and Telegram formatting without network I/O.""" + def test_host_tool_invocation_reaches_real_telegram_media_contract(self) -> None: + """Exercise Hermes media parsing and Telegram formatting without network I/O.""" import asyncio from tempfile import TemporaryDirectory @@ -236,7 +236,9 @@ def test_host_tool_invocation_reaches_real_telegram_photo_contract(self) -> None return_value=types.SimpleNamespace(message_id=42) ), send_video=AsyncMock(), - send_voice=AsyncMock(), + send_voice=AsyncMock( + return_value=types.SimpleNamespace(message_id=43) + ), send_audio=AsyncMock(), send_document=AsyncMock(), ) @@ -273,6 +275,9 @@ def format_message(message: str) -> str: image_path = Path(tmp) / "avatars" / "generated" / "contract-probe.png" image_path.parent.mkdir(parents=True) image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) + voice_path = Path(tmp) / "voice-staging" / "contract-probe.ogg" + voice_path.parent.mkdir(parents=True) + voice_path.write_bytes(b"OggS" + b"\x00" * 32) args = { "action": "send", "target": "telegram:8670382527", @@ -311,20 +316,37 @@ def format_message(message: str) -> str: ), ): result = json.loads(hpk.invoke_host_tool("send_message", args)) + voice_result = hpk.deliver_media( + hpk.MediaPayload(voice_path, hpk.MediaType.VOICE), + target="telegram:8670382527", + ) self.assertTrue(result.get("success"), result) self.assertEqual(result["platform"], "telegram") self.assertEqual(result["chat_id"], "8670382527") self.assertEqual(result["message_id"], "42") - bot_factory.assert_called_once_with(token="contract-token") + self.assertTrue(voice_result.success, voice_result) + self.assertEqual(voice_result.media_type, hpk.MediaType.VOICE) + self.assertEqual(voice_result.host_result["message_id"], "43") + self.assertEqual(bot_factory.call_count, 2) + self.assertTrue( + all(item.kwargs == {"token": "contract-token"} for item in bot_factory.call_args_list) + ) bot.send_message.assert_not_awaited() bot.send_photo.assert_awaited_once() + bot.send_voice.assert_awaited_once() photo_call = bot.send_photo.await_args self.assertEqual(photo_call.kwargs["chat_id"], 8670382527) self.assertEqual( photo_call.kwargs["photo"].name, str(image_path.resolve()), ) + voice_call = bot.send_voice.await_args + self.assertEqual(voice_call.kwargs["chat_id"], 8670382527) + self.assertEqual( + voice_call.kwargs["voice"].name, + str(voice_path.resolve()), + ) if __name__ == "__main__": diff --git a/tests/test_kit.py b/tests/test_kit.py index 8447be0..879dc1c 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -319,6 +319,137 @@ def test_rejects_unknown_host_tool(self) -> None: hpk.invoke_host_tool("not_a_host_tool", {}) +class MediaDeliveryContractTests(unittest.TestCase): + def test_voice_payload_has_typed_hermes_directive(self) -> None: + payload = hpk.MediaPayload("/opt/data/voice-staging/memo.ogg", hpk.MediaType.VOICE) + self.assertEqual( + payload.to_message(), + "[[audio_as_voice]]\nMEDIA:/opt/data/voice-staging/memo.ogg", + ) + + def test_voice_payload_rejects_non_voice_container(self) -> None: + with self.assertRaisesRegex(ValueError, "ogg or opus"): + hpk.MediaPayload("/opt/data/voice-staging/memo.mp3", hpk.MediaType.VOICE) + + def test_origin_target_uses_task_local_gateway_route(self) -> None: + session_context = types.ModuleType("gateway.session_context") + values = { + "HERMES_SESSION_PLATFORM": "telegram", + "HERMES_SESSION_CHAT_ID": "-5372910000", + "HERMES_SESSION_THREAD_ID": "42", + } + session_context.get_session_env = lambda name, default="": values.get(name, default) + gateway = types.ModuleType("gateway") + gateway.__path__ = [] + gateway.session_context = session_context + + with patch.dict( + sys.modules, + {"gateway": gateway, "gateway.session_context": session_context}, + ): + target = hpk.resolve_delivery_target("origin") + + self.assertEqual(target.requested, "origin") + self.assertEqual(target.host_target, "telegram:-5372910000:42") + self.assertEqual(target.display, "telegram:-…0000:42") + + def test_origin_target_preserves_telegram_dm_route(self) -> None: + session_context = types.ModuleType("gateway.session_context") + values = { + "HERMES_SESSION_PLATFORM": "telegram", + "HERMES_SESSION_CHAT_ID": "8670382527", + "HERMES_SESSION_THREAD_ID": "", + } + session_context.get_session_env = lambda name, default="": values.get(name, default) + gateway = types.ModuleType("gateway") + gateway.__path__ = [] + gateway.session_context = session_context + + with patch.dict( + sys.modules, + {"gateway": gateway, "gateway.session_context": session_context}, + ): + target = hpk.resolve_delivery_target("origin") + + self.assertEqual(target.host_target, "telegram:8670382527") + self.assertEqual(target.display, "telegram:…2527") + + def test_origin_target_preserves_telegram_group_route(self) -> None: + session_context = types.ModuleType("gateway.session_context") + values = { + "HERMES_SESSION_PLATFORM": "telegram", + "HERMES_SESSION_CHAT_ID": "-5372910000", + "HERMES_SESSION_THREAD_ID": "", + } + session_context.get_session_env = lambda name, default="": values.get(name, default) + gateway = types.ModuleType("gateway") + gateway.__path__ = [] + gateway.session_context = session_context + + with patch.dict( + sys.modules, + {"gateway": gateway, "gateway.session_context": session_context}, + ): + target = hpk.resolve_delivery_target("origin") + + self.assertEqual(target.host_target, "telegram:-5372910000") + self.assertEqual(target.display, "telegram:-…0000") + + def test_deliver_media_invokes_typed_host_contract(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "memo.ogg" + path.write_bytes(b"OggS" + b"\x00" * 16) + payload = hpk.MediaPayload(path, hpk.MediaType.VOICE) + with patch.object( + hpk, + "invoke_host_tool", + return_value=json.dumps( + {"success": True, "platform": "telegram", "chat_id": "8670382527", "message_id": "9"} + ), + ) as invoke: + result = hpk.deliver_media( + payload, + target="telegram:8670382527", + session_id="session-1", + ) + + invoke.assert_called_once_with( + "send_message", + { + "action": "send", + "target": "telegram:8670382527", + "message": f"[[audio_as_voice]]\nMEDIA:{path}", + }, + session_id="session-1", + ) + self.assertTrue(result.success) + self.assertEqual(result.requested_target, "telegram:…2527") + self.assertEqual(result.display_target, "telegram:…2527") + self.assertEqual(result.host_result["chat_id"], "…2527") + self.assertEqual(result.as_dict()["media_type"], "voice") + + def test_delivery_result_redacts_raw_route_from_host_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "memo.ogg" + path.write_bytes(b"OggS" + b"\x00" * 16) + with patch.object( + hpk, + "invoke_host_tool", + return_value=json.dumps( + {"error": "send to telegram:-5372910000 failed for -5372910000"} + ), + ): + result = hpk.deliver_media( + hpk.MediaPayload(path, hpk.MediaType.VOICE), + target="telegram:-5372910000", + ) + + self.assertFalse(result.success) + encoded = json.dumps(result.as_dict(), ensure_ascii=False) + self.assertNotIn("5372910000", encoded.replace("…0000", "")) + self.assertIn("telegram:-…0000", encoded) + + class RegisterPluginTests(unittest.TestCase): def _module(self, **attrs): module = types.ModuleType("sample_plugin") diff --git a/uv.lock b/uv.lock index f1be361..0d721e7 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "hermes-plugin-kit" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } [package.dev-dependencies]