Skip to content

Commit 036bb60

Browse files
feat: add typed media delivery contract
1 parent 0b5aba5 commit 036bb60

7 files changed

Lines changed: 406 additions & 21 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ plugin.
1515
- Keep host invocation grounded in the real Hermes contract suite. For media
1616
delivery, exercise target parsing and platform formatting and mock only the
1717
final network client rather than replacing the host handler.
18+
- Plugins must use `MediaPayload` + `deliver_media` for attachments. The kit
19+
owns Hermes media directives, task-local `origin` resolution, route redaction,
20+
and the typed result; consumers must not recreate those contracts.
1821
- Use `tool_name(namespace, verb, noun)` for new tools and prefer explicit
1922
verbs such as `read`, `write`, and `patch`. Do not use Hermes agent-loop
2023
names (`memory`, `todo`, `session_search`, `delegate_task`) as plugin tools.

README.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ the same boilerplate. `hermes-plugin-kit` makes them structurally impossible:
6060
handler.
6161
- **Host invocation** — call non-registry Hermes capabilities such as
6262
`send_message` without bypassing plugin guard and audit hooks.
63+
- **Typed media delivery** — declare `MediaPayload` as `auto`, `voice`, or
64+
`document`; resolve task-local `origin` inside the kit; and receive a
65+
privacy-safe `MediaDeliveryResult` after the real Hermes host send.
6366

6467
## Who it's for
6568

@@ -201,33 +204,38 @@ accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as
201204
Not every Hermes capability lives in `tools.registry`. In particular,
202205
`send_message` is a host-managed runtime service, so calling
203206
`registry.dispatch("send_message", ...)` from inside a plugin returns an unknown-tool
204-
error. Use the kit's host invocation seam instead:
207+
error. Use the kit's typed media seam instead:
205208

206209
```python
207-
from hermes_plugin_kit import invoke_host_tool
208-
209-
def deliver_generated_image(path: str, target: str, **runtime_context):
210-
return invoke_host_tool(
211-
"send_message",
212-
{
213-
"action": "send",
214-
"target": target,
215-
"message": f"MEDIA:{path}",
216-
},
210+
from hermes_plugin_kit import MediaPayload, MediaType, deliver_media
211+
212+
def deliver_voice_memo(path: str, **runtime_context):
213+
return deliver_media(
214+
MediaPayload(path, MediaType.VOICE),
215+
target="origin",
217216
**runtime_context,
218217
)
219218
```
220219

220+
`MediaType.VOICE` accepts only `.ogg`/`.opus` and emits Hermes'
221+
`[[audio_as_voice]]` directive. `MediaType.DOCUMENT` emits `[[as_document]]`;
222+
`MediaType.AUTO` lets Hermes choose from the extension. `origin` resolves
223+
through Hermes' task-local platform/chat/thread context inside the kit, so a
224+
plugin never imports gateway internals or exposes raw group IDs to the model.
225+
The returned `MediaDeliveryResult` carries success, media type, path, requested
226+
route, a privacy-safe display route, and a redacted host result.
227+
228+
For non-media host calls, `invoke_host_tool` remains the lower-level seam.
221229
`invoke_host_tool` resolves the supported direct host handler and wraps the nested
222230
operation with Hermes `pre_tool_call` and `post_tool_call` hooks. A blocking hook
223231
prevents the handler from running. If the guard API is unavailable, invocation is
224232
refused rather than sending without policy checks. `send_message` is the currently
225233
supported host tool; unknown names fail explicitly.
226234

227-
The upstream Hermes contract suite runs this exact generated-image payload through
228-
the real `send_message` target parser, media extractor, and Telegram formatter. It
229-
mocks only the final Bot API client and asserts that Hermes calls `send_photo` with
230-
the numeric chat ID and generated file, without emitting a separate text message.
235+
The upstream Hermes contract suite runs image and typed voice payloads through
236+
the real `send_message` target parser, media extractor, and Telegram formatter.
237+
It mocks only the final Bot API client and asserts that Hermes calls `send_photo`
238+
and `send_voice` with the expected files, without separate text messages.
231239

232240
## Logging contract
233241

hermes_plugin_kit/__init__.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def register(ctx):
5454
import sys
5555
import time
5656
from dataclasses import dataclass
57+
from enum import Enum
5758
from pathlib import Path
5859
from typing import Any, Callable
5960

@@ -63,6 +64,12 @@ def register(ctx):
6364
"plugin_skill",
6465
"register_plugin",
6566
"invoke_host_tool",
67+
"deliver_media",
68+
"resolve_delivery_target",
69+
"MediaType",
70+
"MediaPayload",
71+
"ResolvedDeliveryTarget",
72+
"MediaDeliveryResult",
6673
"PluginSkill",
6774
"RegistrationSummary",
6875
"register_all",
@@ -115,6 +122,81 @@ class RegistrationSummary:
115122
skipped_optional_skills: tuple[str, ...] = ()
116123

117124

125+
class MediaType(str, Enum):
126+
"""Hermes media delivery modes with explicit platform semantics."""
127+
128+
AUTO = "auto"
129+
VOICE = "voice"
130+
DOCUMENT = "document"
131+
132+
133+
@dataclass(frozen=True)
134+
class MediaPayload:
135+
"""A validated local attachment and its intended Hermes delivery mode."""
136+
137+
path: Path | str
138+
media_type: MediaType | str = MediaType.AUTO
139+
caption: str = ""
140+
141+
def __post_init__(self) -> None:
142+
path = Path(self.path).expanduser()
143+
if not path.is_absolute():
144+
raise ValueError("media path must be absolute")
145+
try:
146+
media_type = (
147+
self.media_type
148+
if isinstance(self.media_type, MediaType)
149+
else MediaType(self.media_type)
150+
)
151+
except ValueError as exc:
152+
raise ValueError("media_type must be auto, voice, or document") from exc
153+
if media_type is MediaType.VOICE and path.suffix.lower() not in {".ogg", ".opus"}:
154+
raise ValueError("voice media must use an ogg or opus container")
155+
object.__setattr__(self, "path", path)
156+
object.__setattr__(self, "media_type", media_type)
157+
object.__setattr__(self, "caption", str(self.caption or "").strip())
158+
159+
def to_message(self) -> str:
160+
directive = ""
161+
if self.media_type is MediaType.VOICE:
162+
directive = "[[audio_as_voice]]\n"
163+
elif self.media_type is MediaType.DOCUMENT:
164+
directive = "[[as_document]]\n"
165+
caption = f"{self.caption}\n" if self.caption else ""
166+
return f"{caption}{directive}MEDIA:{self.path}"
167+
168+
169+
@dataclass(frozen=True)
170+
class ResolvedDeliveryTarget:
171+
"""Requested route plus the host-only route and privacy-safe display form."""
172+
173+
requested: str
174+
host_target: str
175+
display: str
176+
177+
178+
@dataclass(frozen=True)
179+
class MediaDeliveryResult:
180+
"""Typed result from Hermes host media delivery."""
181+
182+
success: bool
183+
requested_target: str
184+
display_target: str
185+
media_type: MediaType
186+
path: Path
187+
host_result: dict[str, Any]
188+
189+
def as_dict(self) -> dict[str, Any]:
190+
return {
191+
"success": self.success,
192+
"requested_target": self.requested_target,
193+
"display_target": self.display_target,
194+
"media_type": self.media_type.value,
195+
"path": str(self.path),
196+
"host_result": self.host_result,
197+
}
198+
199+
118200
# ---------------------------------------------------------------------------
119201
# Tool naming
120202
# ---------------------------------------------------------------------------
@@ -498,6 +580,145 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str
498580
return result
499581

500582

583+
def _display_delivery_identifier(value: str) -> str:
584+
raw = str(value or "")
585+
sign = "-" if raw.startswith("-") else ""
586+
digits = raw.lstrip("-")
587+
if digits.isdigit() and len(digits) > 4:
588+
return f"{sign}{digits[-4:]}"
589+
return raw
590+
591+
592+
def _display_delivery_target(target: str) -> str:
593+
parts = str(target or "").split(":")
594+
if len(parts) >= 2:
595+
parts[1] = _display_delivery_identifier(parts[1])
596+
return ":".join(parts)
597+
598+
599+
def resolve_delivery_target(target: str) -> ResolvedDeliveryTarget:
600+
"""Resolve ``origin`` through Hermes task-local context.
601+
602+
Consumers never need to import ``gateway.session_context``. Explicit host
603+
targets pass through unchanged; ``origin`` binds to the current platform,
604+
chat, and optional thread without exposing the raw route to the model.
605+
"""
606+
requested = str(target or "").strip()
607+
if not requested:
608+
raise ValueError("delivery target is required")
609+
if requested != "origin":
610+
return ResolvedDeliveryTarget(
611+
requested=requested,
612+
host_target=requested,
613+
display=_display_delivery_target(requested),
614+
)
615+
try:
616+
from gateway.session_context import get_session_env
617+
618+
platform = get_session_env("HERMES_SESSION_PLATFORM", "").strip().lower()
619+
chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "").strip()
620+
thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "").strip()
621+
except Exception as exc:
622+
raise RuntimeError(
623+
f"current Hermes delivery origin is unavailable: {type(exc).__name__}"
624+
) from exc
625+
if not platform or not chat_id:
626+
raise RuntimeError("current Hermes delivery origin has no platform/chat route")
627+
host_target = f"{platform}:{chat_id}"
628+
if thread_id:
629+
host_target = f"{host_target}:{thread_id}"
630+
return ResolvedDeliveryTarget(
631+
requested=requested,
632+
host_target=host_target,
633+
display=_display_delivery_target(host_target),
634+
)
635+
636+
637+
def _safe_media_host_result(
638+
payload: Any,
639+
resolved: ResolvedDeliveryTarget,
640+
) -> dict[str, Any]:
641+
parts = resolved.host_target.split(":")
642+
raw_chat_id = parts[1] if len(parts) >= 2 else ""
643+
display_chat_id = _display_delivery_identifier(raw_chat_id)
644+
645+
def redact(value: Any) -> Any:
646+
if isinstance(value, dict):
647+
return {key: redact(item) for key, item in value.items()}
648+
if isinstance(value, list):
649+
return [redact(item) for item in value]
650+
if isinstance(value, tuple):
651+
return [redact(item) for item in value]
652+
if isinstance(value, str):
653+
safe = value.replace(resolved.host_target, resolved.display)
654+
if raw_chat_id and raw_chat_id != display_chat_id:
655+
safe = safe.replace(raw_chat_id, display_chat_id)
656+
return safe
657+
return value
658+
659+
safe = redact(payload)
660+
return safe if isinstance(safe, dict) else {"result": safe}
661+
662+
663+
def deliver_media(
664+
media: MediaPayload,
665+
*,
666+
target: str | ResolvedDeliveryTarget = "origin",
667+
**context: Any,
668+
) -> MediaDeliveryResult:
669+
"""Deliver typed local media through Hermes' guarded host messaging seam."""
670+
if not isinstance(media, MediaPayload):
671+
raise TypeError("media must be a MediaPayload")
672+
if not media.path.is_file() or media.path.stat().st_size <= 0:
673+
raise ValueError(f"media file is missing or empty: {media.path}")
674+
resolved = (
675+
target
676+
if isinstance(target, ResolvedDeliveryTarget)
677+
else resolve_delivery_target(target)
678+
)
679+
log = logging.getLogger("hermes_plugin_kit")
680+
log.info(
681+
"event=media_delivery_started target=%s media_type=%s path=%s",
682+
resolved.display,
683+
media.media_type.value,
684+
media.path,
685+
)
686+
raw = invoke_host_tool(
687+
"send_message",
688+
{
689+
"action": "send",
690+
"target": resolved.host_target,
691+
"message": media.to_message(),
692+
},
693+
**context,
694+
)
695+
try:
696+
host_payload = json.loads(raw)
697+
except (TypeError, json.JSONDecodeError):
698+
host_payload = {"error": "send_message returned invalid JSON"}
699+
success = _host_result_fields(raw)[0] == "success"
700+
safe_result = _safe_media_host_result(host_payload, resolved)
701+
log_method = log.info if success else log.warning
702+
log_method(
703+
"event=media_delivery_completed target=%s media_type=%s success=%s",
704+
resolved.display,
705+
media.media_type.value,
706+
success,
707+
)
708+
return MediaDeliveryResult(
709+
success=success,
710+
requested_target=(
711+
resolved.requested
712+
if resolved.requested == "origin"
713+
else _display_delivery_target(resolved.requested)
714+
),
715+
display_target=resolved.display,
716+
media_type=media.media_type,
717+
path=media.path,
718+
host_result=safe_result,
719+
)
720+
721+
501722
def tool(
502723
*,
503724
toolset: str,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
88

99
[project]
1010
name = "hermes-plugin-kit"
11-
version = "0.1.0"
11+
version = "0.2.0"
1212
description = "Convention-correct lifecycle registration for hermes-agent plugins."
1313
readme = "README.md"
1414
requires-python = ">=3.11"

0 commit comments

Comments
 (0)