プラットフォームアダプターを追加する
目次
- 構造の全体像
- プラグインの経路(おすすめ)
- plugin.yaml
- adapter.py
- 設定
- プラグインの仕組みが自動でやってくれること
- サービス向けのアダプター: trusted_inbound、display_tier、shared_env_prefixes
- チャット系プラットフォームの認可の項目
- プラットフォームプラグイン向けの公開ヘルパー
- 単独で送るときの拡張
- 環境変数による自動設定
- YAML から環境変数への橋渡し
- cron での配信
- ゲートウェイの外での cron 配信
- 環境変数を hermes config に出す
- プラットフォームごとの、応答が遅いときの見せ方
- 型: _keep_typing を上書きして、待っている間の見せ方を重ねる
- 型: send を上書きして、すぐ送らずに控えに回す
- この型が向いている場面
- 参考になる実装
- 参考になる実装(プラグインの経路)
- 手順のチェックリスト(組み込みの経路)
- 1. Platform の列挙
- 2. アダプターのファイル
- 3. ゲートウェイの設定(gateway/config.py)
- 4. ゲートウェイの実行役(gateway/run.py と隣の gateway/run_.py)
- 5. プラットフォームをまたぐ配信
- 6. CLI とのつなぎ込み
- 7. ツール
- 8. ツールセット
- 9. 任意: プラットフォームへの助言
- 10. テスト
- 11. ドキュメント
- 抜け漏れの点検
- よくある型
- 長めのポーリングを使うアダプター
- コールバック / Webhook のアダプター
- 受信の重複を取り除く
- トークンの取り合いを防ぐ錠
- 参考になる実装
このページでは、Hermes のゲートウェイに新しいメッセージングのプラットフォームを足す方法を説明します。プラットフォームアダプターは、Hermes を外部のメッセージングサービス(Telegram、Discord、WeCom など)につなぐもので、これがあると利用者はそのサービスからエージェントとやり取りできるようになります。
構造の全体像
User ↔ Messaging Platform ↔ Platform Adapter ↔ Gateway Runner ↔ AIAgentどのアダプターも gateway/platforms/base.py の BasePlatformAdapter を継承し、次を実装します。
connect()— 接続を確立する(WebSocket、長めのポーリング、HTTP サーバーなど)*(実装必須)*disconnect()— きれいに終了する *(実装必須)*send()— チャットにテキストのメッセージを送る *(実装必須)*send_typing()— 入力中の表示を出す(任意で上書き)get_chat_info()— チャットの付帯情報を返す(任意で上書き)
受け取ったメッセージはアダプターが受け止め、self.handle_message(event) に渡します。基底クラスがそれをゲートウェイの実行役へ流します。
プラグインの経路(おすすめ)
プラグインの仕組みを使えば、Hermes 本体のコードに手を入れずにプラットフォームアダプターを足せます。プラグインは、次の 2 つのファイルを入れたディレクトリです。
~/.hermes/plugins/my-platform/
plugin.yaml # Plugin metadata
adapter.py # Adapter class + register() entry pointplugin.yaml
プラグインの付帯情報です。requires_env と optional_env のまとまりは、hermes config の画面の項目を自動で埋めます(下の環境変数を hermes config に出すを見てください)。
name: my-platform
label: My Platform
kind: platform
version: 1.0.0
description: My custom messaging platform adapter
author: Your Name
requires_env:
- MY_PLATFORM_TOKEN # bare string works
- name: MY_PLATFORM_CHANNEL # or rich dict for better UX
description: "Channel to join"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: false外へ送るクライアント用のツール: provides_tools
kind: platform のプラグインはあとまわしで読み込まれます。アダプターのモジュール(とその SDK の import)が読み込まれるのは、ゲートウェイ・cron・send_message のどれかが、そのプラットフォームを プラットフォームの登録簿に初めて問い合わせたときです。どのセッションからでもエージェントが呼べる、 外へ送る*クライアント用のツール*も一緒に配るなら(同梱の a2a プラグインの a2a_call / a2a_discover など)、それらは専用の tools.py に register_tools(ctx) 関数とともに置き、 目録で宣言してください。
provides_tools:
- my_platform_call
- my_platform_listprovides_tools を宣言しておくと、Hermes はプラグインを見つける段階で tools.py だけを読み込み、 クライアント用のツールをすべてのプロセス(CLI と TUI を含みます)に登録します。アダプターのほうは あとまわしのままです。パッケージの __init__.py は軽いままにして、アダプターは register() の 中から引き込むようにしてください。そうすれば、先に読み込まれるほうが
あとまわしになります。
利用者は、ほかのツールセットと同じようにプラットフォームごとに有効化します。たとえば hermes tools enable my_platform --platform cli と打つか、config.yaml の platform_toolsets の下にツールセットのキーを並べます。プラグインのプラットフォーム名も --platform の対象として使えるので、そのプラットフォームから入ってきたセッションに、 外へ送るツールを持たせられます。
adapter.py
from gateway.platforms._shared import extra_or_secret, get_scoped_secret, seed_extra_from_env
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
from gateway.config import Platform, PlatformConfig
class MyPlatformAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("my_platform"))
# Explicit env (profile-scoped) → this profile's config.extra (YAML) → default. Under multiplexing a
# scoped miss falls to the profile's own YAML, never to another profile's process env.
self.token = extra_or_secret(config.extra, "token", "MY_PLATFORM_TOKEN")
async def connect(self, *, is_reconnect: bool = False) -> bool:
# Connect to the platform API, start listeners
self._mark_connected()
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# Send message via platform API
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}
def check_requirements() -> bool:
return bool(get_scoped_secret("MY_PLATFORM_TOKEN"))
def validate_config(config) -> bool:
return bool(extra_or_secret(getattr(config, "extra", None), "token", "MY_PLATFORM_TOKEN"))
def _env_enablement() -> dict | None:
if not get_scoped_secret("MY_PLATFORM_TOKEN", "").strip():
return None
return seed_extra_from_env(
(("MY_PLATFORM_TOKEN", "token", None), ("MY_PLATFORM_CHANNEL", "channel", None)),
home_env="MY_PLATFORM_HOME_CHANNEL",
)
def register(ctx):
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=MyPlatformAdapter,
# PASSIVE probe — "are deps/config present right now?". Called from
# status displays and config loading, so it must NEVER pip-install.
check_fn=check_requirements,
# ACTIVE installer (optional) — only for platforms with a
# lazy-installable SDK. create_adapter() calls it when check_fn
# returns False, right before the gateway connects the platform.
# Typically wraps pm.extras.ensure_and_bind(...). Omit it
# and a False check_fn is a hard block.
# ensure_deps_fn=ensure_requirements,
validate_config=validate_config,
required_env=["MY_PLATFORM_TOKEN"],
install_hint="Declare my-platform-sdk in this plugin's Python dependencies, then retry hermes plugins enable my-platform",
# Env-driven auto-configuration — seeds PlatformConfig.extra from
# env vars before adapter construction. See "Env-Driven Auto-
# Configuration" section below.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support. Lets deliver=my_platform cron
# jobs route without editing cron/scheduler.py. See "Cron Delivery"
# section below.
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
# Per-platform user authorization env vars
allowed_users_env="MY_PLATFORM_ALLOWED_USERS",
allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS",
# Message length limit for smart chunking (0 = no limit)
max_message_length=4000,
# LLM guidance injected into system prompt
platform_hint=(
"You are chatting via My Platform. "
"It supports markdown formatting."
),
# Display
emoji="💬",
)
# Optional: register platform-specific tools
ctx.register_tool(
name="my_platform_search",
toolset="my_platform",
schema={...},
handler=my_search_handler,
)設定
利用者は config.yaml でプラットフォームを設定します。
gateway:
platforms:
my_platform:
enabled: true
extra:
token: "..."
channel: "#general"環境変数で設定することもできます(アダプターが __init__ で読みます)。
プラグインの仕組みが自動でやってくれること
ctx.register_platform() を呼ぶと、次のつなぎ込みは自分でやらなくても済みます。本体のコードに手を入れる必要はありません。
| つなぎ込みの箇所 | どう動くか |
|---|---|
| ゲートウェイでのアダプターの生成 | 組み込みの _BUILTIN_ADAPTERS の表より先に登録簿を見る |
| 設定の読み取り | Platform._missing_() がどんなプラットフォーム名でも受け付ける |
| つながっているプラットフォームの検査 | 登録簿の validate_config() が呼ばれる |
| 利用者の認可 | allowed_users_env と allow_all_env が調べられる。group_allowed_chats_env と allowlist_suppresses_pairing(後述) |
| 環境変数だけでの自動有効化 | env_enablement_fn が PlatformConfig.extra と home_channel を用意する |
| YAML 設定の橋渡し | apply_yaml_config_fn が config.yaml のキーを環境変数や extras に置き換える |
| cron での配信 | cron_deliver_env_var によって deliver=<name> が使えるようになる |
hermes config の画面の項目 |
plugin.yaml の requires_env と optional_env が自動で反映される |
送信の実体(tools/send_message_tool.py) |
動いているゲートウェイのアダプターを通して送る |
| Webhook でのプラットフォームをまたぐ配信 | 知っているプラットフォームかどうかを登録簿で確かめる |
/update コマンドの可否 |
allow_update_command のフラグ |
| チャンネルの一覧 | プラグインのプラットフォームも数え上げに含まれる |
| システムプロンプトへの助言 | platform_hint が LLM の文脈に差し込まれる |
| メッセージの分割 | 賢く分けるための max_message_length |
| 個人情報の伏せ字 | pii_safe のフラグ |
hermes status |
プラグインのプラットフォームを組み込みのものと並べて1行ずつ表示し、判定にはゲートウェイ自身の設定済みチェックを使う |
hermes gateway setup |
セットアップのメニューにプラグインのプラットフォームが出る |
hermes tools と hermes skills |
プラットフォームごとの設定にプラグインのものも並ぶ |
| トークンの取り合いの防止(複数プロファイル) | connect() の中で acquire_scoped_lock() を使う |
| 設定だけが残っているときの注意 | プラグインが見つからないとき、内容のわかるログを出す |
| サービスからのイベントの認可 | trusted_inbound=True にすると、利用者の許可リストやペアリングを通さない(下で説明) |
| 表示の既定値 | display_tier で、ツールの進み具合やストリーミングの見せ方を組み込みの段階から選ぶ |
| プロファイルを複製するときの認証情報の削除 | shared_env_prefixes があると、アダプターを使っていないプロファイルではツールと共有しているキーが残る |
サービス向けのアダプター: trusted_inbound、display_tier、shared_env_prefixes
チャットのネットワークではなく、何かのサービスとの橋渡しをするアダプターのために、ctx.register_platform(...) と PlatformEntry には省略できる項目が3つあります。 Home Assistant のプラグインが、これを使う代表例です。
| 項目 | 型と既定値 | 効き目 |
|---|---|---|
trusted_inbound |
bool = False |
届くイベントはすべて、アダプターが自分の認証情報でログインしたサービスから来るもので、送り手の人間はいません。このプラットフォームには、ゲートウェイの利用者の許可リストも DM のペアリングも適用されません。これを使っているのは Home Assistant のイベントバスです。チャットのプラットフォームでは絶対に設定しないでください。ボットにメッセージを送れる人なら誰でもエージェントを動かせるようになってしまいます。 |
display_tier |
str = '' |
'high'、'medium'、'low'、'minimal' のどれかです。利用者が display.platforms.<name> で上書きしていないときに使う、プラットフォームごとの組み込みの表示の既定値(ツールの進み具合、ストリーミング、途中経過のメッセージ)の段階を決めます。空のままなら、プラグイン共通の既定値になります。Home Assistant は 'minimal' を使います。 |
shared_env_prefixes |
tuple[str, ...] = () |
そのプラットフォームが、チャンネル以外の機能(たとえば同じプラグインのツール)と共有している環境変数の接頭辞です(Home Assistant なら ('HASS_',))。プロファイルを複製するとき、この接頭辞のキーが消されるのは、複製元のプロファイルが実際にアダプターを動かしている場合だけです。そのため、ツールしか使っていないプロファイルでは認証情報が残ります。 |
def register(ctx):
kwargs = dict(
name="my_service",
label="My Service",
adapter_factory=MyServiceAdapter,
check_fn=check_requirements,
)
# Feature-detect: older cores reject unknown register_platform kwargs.
from gateway.platform_registry import PlatformEntry
known = {f.name for f in dataclasses.fields(PlatformEntry)}
for key, value in (
("trusted_inbound", True),
("display_tier", "minimal"),
("shared_env_prefixes", ("MY_SERVICE_",)),
):
if key in known:
kwargs[key] = value
ctx.register_platform(**kwargs)これらの項目があるかどうかは、上の例のように dataclasses.fields(PlatformEntry) で確かめてください。そうすれば、まだこれらの項目を持たない古い Hermes の本体でも、プラグインを読み込めます。
チャット系プラットフォームの認可の項目
PlatformEntry には、任意の項目がさらに2つあります。どちらも、以前は本体が同梱プラットフォーム向けに 決め打ちしていた gateway の認可の振る舞いを受け持ちます。これらを宣言すれば、プラグインのプラットフォームも同じように振る舞います。
| 項目 | 型 / 既定値 | 効果 |
|---|---|---|
group_allowed_chats_env |
str = '' |
グループやフォーラムのチャット ID をカンマ区切りで入れる環境変数です。一覧にあるチャットからのメッセージは、user_id がなくても(匿名の管理者やチャンネルの投稿など)すべて認可されます。Telegram は TELEGRAM_GROUP_ALLOWED_CHATS を、QQ Bot は QQ_GROUP_ALLOWED_USERS を宣言しています。 |
allowlist_suppresses_pairing |
bool = False |
そのプラットフォームの allowed_users_env(または group_allowed_chats_env)が設定されているとき、知らない相手からの DM にはペアリングコードを送らず、無視します。持ち主がすでにアクセスを絞っているからです。同梱のチャット系プラットフォームの多くが、これを設定しています。 |
kwargs = dict(name="my_chat", label="My Chat", adapter_factory=MyChatAdapter, check_fn=check_requirements,
allowed_users_env="MY_CHAT_ALLOWED_USERS", allow_all_env="MY_CHAT_ALLOW_ALL_USERS")
known = {f.name for f in dataclasses.fields(PlatformEntry)}
for key, value in (("group_allowed_chats_env", "MY_CHAT_GROUP_ALLOWED_CHATS"),
("allowlist_suppresses_pairing", True)):
if key in known: # older cores reject unknown kwargs
kwargs[key] = value
ctx.register_platform(**kwargs)プラットフォームプラグイン向けの公開ヘルパー
プラットフォームのアダプターは、ツールのプラグインよりも多くの本体の機能を必要とします。次の名前は公開されています。hermes plugins validate はこれらについて非公開 import の警告を出さず、名前とシグネチャはリリースをまたいで変わりません。gateway.*、tools.*、hermes_cli.* の下にあるそれ以外のものは内部用で、検証ツールはその import を1つずつ一覧に出します。
| 必要なもの | import |
|---|---|
| アダプターの基底クラス、送信結果、メディアキャッシュのヘルパー | gateway.platforms.base(公開されている名前) |
| SSRF に安全な HTTP クライアント(接続時の IP 検証、リダイレクトごとの再確認、共有の keepalive 上限) | from gateway.platforms.helpers import platform_http_client |
| 自前で組み立てるための、そのクライアントの部品 | gateway.platforms.base.ssrf_redirect_guard(httpx の response フック)、tools.url_safety.create_ssrf_safe_async_client、tools.url_safety.is_safe_url、gateway.platforms._http_client_limits.platform_httpx_limits |
独自の DM / グループの方針(dm_policy / group_policy / 許可リスト) |
from gateway.platforms.access_policy_mixin import OwnAccessPolicyMixin |
| ネイティブのボタンから承認を確定する | from tools.approval import resolve_gateway_approval |
| ネイティブの確認(clarify)ボタン | tools.clarify_gateway:get_pending(clarify_id)(保留中の項目か None を返します。session_key、choices、multi_select を読みます)、resolve_gateway_clarify(clarify_id, text)、mark_awaiting_text(clarify_id)、SKIPPED |
setup_fn のウィザードの手順 |
hermes_cli.gateway_setup_wizard:print_setup_header(title)、confirm_reconfigure(label, *env_vars)、offer_home_channel(home_var, user_id, what)、save_env_values(**values)、prompt_csv(text, default)。入力の問いかけは hermes_cli.setup(prompt、prompt_choice、prompt_yes_no)から、出力は hermes_cli.cli_output(print_info、print_success、print_warning、print_error)から、.env へのアクセスは hermes_cli.config(save_env_value、get_env_value)を通します |
from gateway.platforms.helpers import platform_http_client
async def connect(self) -> bool:
# timeout=30.0 and follow_redirects=True by default; any other httpx.AsyncClient kwarg passes through.
self._http = platform_http_client(headers={"User-Agent": "my-chat-bot/1.0"})
...確認への回答が済んだとき、時間切れになったとき、取り消されたときには、get_pending() は None を返します。そのため、クリックに応じて動く前に、戻り値がカードを描いたときの項目と同一のものかを比べてください。
単独で送るときの拡張
単独のプラットフォームでも、ctx.register_platform() が作る同じ PlatformEntry に送信の振る舞いを 宣言しておけば、hermes send --to ... の直接送信や cron の deliver=platform:... といった、 本体側から始まる送信に参加できます。 send_message は、エージェントがモデルから呼べるツールにはあえてしていません。プラグインの側で、 エージェントが自分から外へメッセージを送り始められるような同等の口を登録してはいけません。
async def _send_request(args, chat_id, platform_name, pconfig):
# `args` contains the host-driven send request fields.
message_id = await client.send(
address=chat_id,
body=args["message"],
subject=args.get("subject"),
)
return {"success": True, "platform": platform_name,
"chat_id": chat_id, "message_id": message_id}
def _parse_address(raw):
normalized = raw.strip().lower()
if normalized.startswith("@") and "@" in normalized[1:]:
return normalized, None # (chat_id, optional thread_id)
return None # continue to channel-directory resolution
def _validate_address(address):
# True accepts; False rejects; a string rejects with that diagnostic.
return True if address.endswith("@example.com") else "unsupported domain"
def register(ctx):
ctx.register_platform(
name="fmsg",
label="Fixture Message",
adapter_factory=lambda cfg: FmsgAdapter(cfg),
check_fn=check_requirements,
parse_target_ref_fn=_parse_address,
validate_target_ref_fn=_validate_address,
# May be a regular function or async def. Hermes awaits any awaitable
# result, including callable objects and functools.partial wrappers.
send_message_handler=_send_request,
# Prefer this lower-level hook when cron must send from a process
# without the live gateway.
standalone_sender_fn=_standalone_send,
)送り先の解決は、外へ送る 3 つの経路で共通です。読み取り役が返した値がまず整えられ、チャンネル一覧に ある ID はそのまま信用されます。プラグインの読み取り役は、そのプラットフォーム本来の書き方を自分で 受け付ける必要があります。解決できなかった文字列が、そのまま素通しされることはありません。知らない プラットフォームや、検査役が弾いた場合は、黙って送ろうとせずに理由を返します。プラグインの強制読み込みや プロファイルの切り替えでは、そのプラグインが持っていた登録が外されるので、読み取り役や処理役が次の プロファイルへ漏れることはありません。
環境変数による自動設定
たいていの利用者は、config.yaml を書き換えるのではなく、~/.hermes/.env に環境変数を置いてプラットフォームを用意します。env_enablement_fn のフックを使うと、アダプターが作られる前にプラグインがその環境変数を拾えるので、hermes gateway status、get_connected_platforms()、cron での配信が、プラットフォームの SDK を立ち上げずに正しい状態を見られます。
環境変数は gateway.platforms._shared.get_scoped_secret を通して読んでください。os.getenv は使いません。gateway.multiplex_profiles のもとでは、プロセスの環境変数には既定(DEFAULT)のプロファイルの値が入っています。ほかのプロファイルの .env はそのプロファイルの秘密の範囲の中にしか無いので、そのまま読むと、違うプロファイルで、違う認証情報のまま、あなたのプラットフォームを有効にしてしまいます。seed_extra_from_env は、(ENV_VAR, extra_key, conv) の表から、その読み取り口を通して用意用の dict を組み立てます。
from gateway.platforms._shared import get_scoped_secret, seed_extra_from_env
def _env_enablement() -> dict | None:
"""Seed PlatformConfig.extra from env vars.
Called by the platform registry during load_gateway_config().
Return None when the platform isn't minimally configured — the
caller then skips auto-enabling. Return a dict to seed extras.
"""
token = get_scoped_secret("MY_PLATFORM_TOKEN", "").strip()
channel = get_scoped_secret("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
# (ENV_VAR, extra_key, conv): blank values are skipped, a conv raising
# ValueError drops the key. home_env seeds the special 'home_channel'
# dict ({chat_id, name} from MY_PLATFORM_HOME_CHANNEL[_NAME], name
# defaults to "Home"), which the core hook lifts into a HomeChannel.
return {"token": token, "channel": channel, **seed_extra_from_env(
(("MY_PLATFORM_PORT", "port", int), ("MY_PLATFORM_NICK", "nick", None)),
home_env="MY_PLATFORM_HOME_CHANNEL",
)}
def register(ctx):
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=MyPlatformAdapter,
check_fn=check_requirements,
validate_config=validate_config,
env_enablement_fn=_env_enablement,
# ... other fields
)YAML から環境変数への橋渡し
環境変数よりも config.yaml のキー(my_platform.require_mention、my_platform.allowed_channels など)で設定したい利用者もいます。apply_yaml_config_fn のフックを使えば、この置き換えをプラグイン側で受け持てます。本体の gateway/config.py に、そのプラットフォームの YAML の形を覚えさせずに済みます。
from gateway.platforms._shared import apply_yaml_bridge
# (yaml key, ENV_VAR, kind). "lower" bridges whenever the key is present
# (booleans become "true"/"false"); "str" skips null/blank; "csv" comma-joins
# lists; "json" dumps the value.
_YAML_BRIDGE = (
("require_mention", "MY_PLATFORM_REQUIRE_MENTION", "lower"),
("allowed_channels", "MY_PLATFORM_ALLOWED_CHANNELS", "csv"),
)
def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None:
"""Translate config.yaml `my_platform:` keys into env vars / extras.
yaml_cfg — the full top-level parsed config.yaml dict
platform_cfg — the platform's own sub-dict (yaml_cfg.get("my_platform", {}))
apply_yaml_bridge writes each env var only when it is unset (env > YAML)
and NEVER while a multiplexed secondary profile's config is loading — a
write there would pin that profile's policy process-wide. It returns the
same values for PlatformConfig.extra, so read `extra` first in the adapter
and fall back to the env var (`_shared.extra_or_secret`).
"""
return apply_yaml_bridge(platform_cfg, _YAML_BRIDGE)
def register(ctx):
ctx.register_platform(
name="my_platform",
...,
apply_yaml_config_fn=_apply_yaml_config,
)このフックは load_gateway_config() の途中、共通のキーをまとめて処理する繰り返し(unauthorized_dm_behavior、notice_delivery、reply_prefix、require_mention などのよくあるキーを扱います)のあと、_apply_env_overrides() の前に呼ばれます。そのため、プラグイン側で橋渡しするのはそのプラットフォーム独自のキーだけで済みます。
このフックの中で起きた例外は握りつぶされ、デバッグの水準でログに残ります。行儀の悪いプラグインがあっても、ゲートウェイの設定の読み込みが止まることはありません。
cron での配信
deliver=my_platform の cron の仕事を、設定済みのホームチャンネルへ届けたいときは、既定のチャット・部屋・チャンネルの ID を持つ環境変数の名前を cron_deliver_env_var に指定します。
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
)スケジューラーは、deliver=my_platform の仕事のホームの送り先を決めるときにこの環境変数を読みます。また、_KNOWN_DELIVERY_PLATFORMS の類いの検査でも、そのプラットフォームを cron の正しい送り先として扱います。env_enablement_fn が home_channel の dict を用意している場合(上を見てください)は、そちらが優先されます。cron_deliver_env_var は、環境変数からの用意より前に走る cron の仕事のための予備です。
ゲートウェイの外での cron 配信
cron_deliver_env_var を指定すると、そのプラットフォームは deliver= の送り先として認識されます。さらに、cron の仕事がゲートウェイとは別のプロセスで走るとき(hermes gateway とは別に hermes cron run を動かす場合)にも実際に送信を成功させたいなら、standalone_sender_fn を登録します。
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Open an ephemeral connection / acquire a fresh token, send, and close."""
# ... open connection, send message, return result ...
return {"success": True, "message_id": "..."}
# or {"error": "..."}
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
)このフックが要る理由を説明します。組み込みのプラットフォーム(Telegram、Discord、Slack など)は tools/send_message_tool.py に REST を直接叩く補助を持っているので、ゲートウェイを同じプロセスで抱えなくても cron から配信できます。一方、プラグインのプラットフォームはこれまで _gateway_runner_ref() に頼っていて、これはゲートウェイのプロセスの外では None を返します。そのため standalone_sender_fn がないと、cron 側の送信は No live adapter for platform '<name>' で失敗します。
この関数には、動いているアダプターが受け取るのと同じ pconfig と chat_id に加えて、任意のキーワード引数 thread_id、media_files、force_document が渡ります。{"success": True, "message_id": ...} を返せば配信できたものとして扱われ、{"error": "..."} を返すとその文言が cron の delivery_errors に出ます。関数の中で起きた例外は振り分け役が受け止め、Plugin standalone send failed: <reason> として報告されます。参考になる実装は plugins/platforms/{irc,teams,google_chat}/adapter.py にあります。
環境変数を hermes config に出す
hermes_cli/config.py は読み込み時に plugins/platforms/*/plugin.yaml を走査し、requires_env と(任意の)optional_env のまとまりから OPTIONAL_ENV_VARS を自動で埋めます。dict の形で細かく書いておけば、説明・入力の見出し・パスワード扱いの指定・URL をそのまま渡せます。CLI のセットアップ画面がそれを拾ってくれます。
# plugins/platforms/my_platform/plugin.yaml
name: my_platform-platform
label: My Platform
kind: platform
version: 1.0.0
description: >
My Platform gateway adapter for Hermes Agent.
author: Your Name
requires_env:
- name: MY_PLATFORM_TOKEN
description: "Bot API token from the My Platform console"
prompt: "My Platform bot token"
url: "https://my-platform.example.com/bots"
password: true
- name: MY_PLATFORM_CHANNEL
description: "Channel to join (e.g. #hermes)"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery (defaults to MY_PLATFORM_CHANNEL)"
prompt: "Home channel (or empty)"
password: false
- name: MY_PLATFORM_ALLOWED_USERS
description: "Comma-separated user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false使える dict のキー: name(必須)、description、prompt、url、password(真偽値。省くと *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON の語尾から自動で判断されます)、category(既定は "messaging")。
文字列だけを並べる書き方(- MY_PLATFORM_TOKEN)も使えます。その場合、説明はプラグインの label から自動で作られます。同じ変数が OPTIONAL_ENV_VARS に直接書かれていれば、そちらが優先されます(従来の動きのままです)。plugin.yaml の書き方は、その予備として働きます。
プラットフォームごとの、応答が遅いときの見せ方
プラットフォームによっては、LLM の応答が遅いときの見せ方を変えざるを得ない制約があります。
- LINE は、受け取ったイベントからおよそ 60 秒で切れる、1 回きりの*返信用トークン*を発行します。そのトークンで返すのは無料ですが、従量課金の Push API に切り替えると費用がかかります。期限までに LLM が答え終わらないなら、「有料の Push の枠を使う」か「切れる前に返信用トークンでもっとうまいことをする」かの選択になります。
- WhatsApp は 24 時間でセッションを閉じ、それ以降は定型のメッセージしか受け付けません。
- SMS には入力中の表示も途中経過もありません。長い応答は、ただボットが落ちているように見えます。
これらは実際の制約で、基底の BasePlatformAdapter には先読みできません。プラグインの口は、引数を増やすことなく、アダプターが基底の入力中の表示の上にそのプラットフォームらしい見せ方を重ねられるよう、あえて余地を残してあります。
型: _keep_typing を上書きして、待っている間の見せ方を重ねる
BasePlatformAdapter._keep_typing は、入力中の表示を保つ心拍です。LLM が生成している間、裏のタスクとして走り、応答が届いた時点で打ち切られます。ある時間を過ぎたところで独自の振る舞いを重ねたいとき(たとえば 45 秒で「まだ考えています」の吹き出しを出したいとき)は、アダプターで _keep_typing を上書きし、super()._keep_typing() と並べて自分のタスクを走らせ、finally で片付けます。
class LineAdapter(BasePlatformAdapter):
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
if self.slow_response_threshold <= 0:
await super()._keep_typing(chat_id, *args, **kwargs)
return
async def _fire_at_threshold() -> None:
try:
await asyncio.sleep(self.slow_response_threshold)
except asyncio.CancelledError:
raise
# Platform-specific work here — for LINE, send a Template
# Buttons "Get answer" bubble using the cached reply token
# so the user can fetch the cached response later via a
# fresh (free) reply token from the postback callback.
await self._send_slow_response_button(chat_id)
side_task = asyncio.create_task(_fire_at_threshold())
try:
await super()._keep_typing(chat_id, *args, **kwargs)
finally:
if not side_task.done():
side_task.cancel()
try:
await side_task
except (asyncio.CancelledError, Exception):
pass要点は次のとおりです。
- 必ず
await super()._keep_typing(...)を呼ぶ。 入力中の表示それ自体に価値があります。置き換えるのではなく、上に重ねてください。 finallyで自分のタスクを片付ける。 LLM が答え終わると(あるいは/stopで打ち切られると)、ゲートウェイは入力中の表示のタスクを止めます。自分のタスクもその打ち切りを受け取らなければ、居残って、応答が届いたあとに動き出しかねません。interrupt_session_activityと組み合わせる。 利用者が/stopを打ったとき、宙に浮いた表示の状態を片付けるためです。LINE の場合は、あとから応答を取り出す控えの項目をPENDINGからERRORに移して、残った「Get answer」のボタンが堂々巡りせずに「実行は中断されました」と返すようにします。
型: send を上書きして、すぐ送らずに控えに回す
応答が遅いときの見せ方として、答えをいったん控えに置いてあとから取り出す形(LINE のポストバックの流れ)にするなら、上書きした send は 3 つの場合を見分ける必要があります。
- そのチャットでポストバックの待ちがある → 応答を request_id のもとに控えておき、画面には何も出さない。
- 取り込み中の返事(
⚡ Interrupting、⏳ Queued、⏩ Steered) → 控えを通さず、そのまま送って見せる。利用者が自分の入力に対するゲートウェイの反応を確かめられるようにするためです。 - ふつうの応答 → これまでどおり、返信用トークンか Push で送る。
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
if _is_system_bypass(content):
return await self._send_text_chunks(chat_id, content, force_push=False)
pending_rid = self._pending_buttons.get(chat_id)
if pending_rid:
self._cache.set_ready(pending_rid, content)
return SendResult(success=True, message_id=pending_rid)
return await self._send_text_chunks(chat_id, content, force_push=False)_SYSTEM_BYPASS_PREFIXES は、ゲートウェイ自身が取り込み中を知らせるときに付ける印です(⚡、⏳、⏩、💾)。控えの状態がどうであれ、これらは必ずそのまま見せてください。
この型が向いている場面
入力中の表示を上書きするやり方が向いているのは、次の両方に当てはまるときです。
- そのプラットフォームの送信の API に、動かしがたい時間の制約がある(1 回きりの返信用トークン、期限のあるセッションなど)
- なおかつ、待っている間に*目に見える吹き出し*を出すことが、そのプラットフォームで受け入れられる
もっと単純な slow_response_threshold = 0(常に Push)の経路が向いているのは、次のどちらかのときです。
- そのプラットフォームに、無料と有料の意味のある区別がない
- あるいは、利用者たちが途中の吹き出しよりも「読み込み中…読み込み中…完了」の、黙って待って答えが来る形を好む
LINE はどちらにも対応しています。無料のポストバックで取り出す既定の目安は 45 秒で、LINE_SLOW_RESPONSE_THRESHOLD=0 にすると「常に Push に切り替える」動きに戻ります。
参考になる実装
LINE のポストバックの実装は plugins/platforms/line/adapter.py に全部あります。RequestCache の状態遷移(PENDING → READY → DELIVERED。/stop のときの ERROR も含みます)、目安の時間で Template Buttons の吹き出しを出す _keep_typing の上書き、控えを経由させる send の上書き、宙に浮いた PENDING の項目を片付ける interrupt_session_activity の上書きが揃っています。
参考になる実装(プラグインの経路)
まるごと動く例としては、リポジトリの plugins/platforms/irc/ を見てください。外部の依存が一切ない、非同期の IRC アダプター一式です。plugins/platforms/teams/ は Bot Framework と Adaptive Cards、plugins/platforms/google_chat/ は OAuth を使う REST の API、plugins/platforms/line/ は Webhook で動く Messaging API と、そのプラットフォームらしい応答が遅いときの見せ方を扱っています。
手順のチェックリスト(組み込みの経路)
1. Platform の列挙
gateway/config.py の Platform の列挙に、そのプラットフォームを足します。
class Platform(Enum):
# ... existing platforms ...
NEWPLAT = "newplat"2. アダプターのファイル
plugins/platforms/newplat/adapter.py を作ります。
from gateway.config import Platform, PlatformConfig
from gateway.platforms._shared import extra_or_secret
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
def check_newplat_requirements() -> bool:
"""Return True if dependencies are available."""
return SOME_SDK_AVAILABLE
class NewPlatAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.NEWPLAT)
# Read config from config.extra dict
extra = config.extra or {}
self._api_key = extra_or_secret(extra, "api_key", "NEWPLAT_API_KEY")
async def connect(self, *, is_reconnect: bool = False) -> bool:
# Set up connection, start polling/webhook
self._mark_connected()
return True
async def disconnect(self) -> None:
self._running = False
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# Send message via platform API
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}受け取ったメッセージについては、MessageEvent を組み立てて self.handle_message(event) を呼びます。
source = self.build_source(
chat_id=chat_id,
chat_name=name,
chat_type="dm", # or "group"
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=content,
message_type=MessageType.TEXT,
source=source,
message_id=msg_id,
)
await self.handle_message(event)3. ゲートウェイの設定(gateway/config.py)
手を入れる箇所は 3 つです。
get_connected_platforms()— そのプラットフォームに必要な資格情報の検査を足すload_gateway_config()— トークンの環境変数の対応を足す:Platform.NEWPLAT: "NEWPLAT_TOKEN"_apply_env_overrides()—NEWPLAT_*の環境変数をすべて設定に対応づける
4. ゲートウェイの実行役(gateway/run.py と隣の gateway/run_*.py)
手を入れる箇所は 6 つです。
_BUILTIN_ADAPTERSの表(gateway/run.py) —Platform.NEWPLAT: (module, class, check_fn, error_msg)の項目を足します。_instantiate_adapter()(gateway/run_adapters.py)は、プラグインの登録簿を見てからこの表を見ます。伸ばしていくelifの連なりはありません。_create_adapter()の包み役が、作れたアダプターをそれぞれのゲートウェイの実行役に結び付けます。_is_user_authorized()の allowed_users の対応 —Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"_is_user_authorized()の allow_all の対応 —Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"- 起動時のアクセス方針の検査(
gateway/run_startup.py) —_ALLOWLIST_ENV_PLATFORMSに"NEWPLAT"を足します(これでNEWPLAT_ALLOWED_USERSとNEWPLAT_ALLOW_ALL_USERSの両方が導かれます) - 起動時の
_BUILTIN_ALLOW_ALL_VARS(gateway/run_startup.py) — 同じ_ALLOWLIST_ENV_PLATFORMSの組から導かれます。足すものはありません _UPDATE_ALLOWED_PLATFORMSの frozenset —Platform.NEWPLATを足します
5. プラットフォームをまたぐ配信
gateway/platforms/webhook.py— 配信の種類の組に"newplat"を足すcron/scheduler_delivery.py—_KNOWN_DELIVERY_PLATFORMSの frozenset と_deliver_result()の対応表に足す
6. CLI とのつなぎ込み
hermes_cli/config.py—NEWPLAT_*の変数をすべて_EXTRA_ENV_KEYSに足すhermes_cli/gateway.py— key、label、emoji、token_var、setup_instructions、vars を持つ項目を_PLATFORMSの一覧に足すhermes_cli/platforms.py— label と default_toolset を持つPlatformInfoの項目を足す(skills_configとtools_configの画面が使います)hermes_cli/setup.py—_setup_newplat()の関数を足し(gateway.pyに任せてもかまいません)、メッセージングのプラットフォームの一覧に組を足すhermes_cli/status.py— プラットフォームの検出の項目を足す:"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")hermes_cli/dump.py— プラットフォームの検出の dict に"newplat": "NEWPLAT_TOKEN"を足す
7. ツール
tools/send_message_tool.py— プラットフォームの対応表に"newplat": Platform.NEWPLATを足すtools/cronjob_tools.py— 配信先の説明文にnewplatを足す
8. ツールセット
toolsets.py—_HERMES_CORE_TOOLSを含む"hermes-newplat"のツールセットの定義を足すtoolsets.py—"hermes-gateway"の includes の一覧に"hermes-newplat"を足す
9. 任意: プラットフォームへの助言
ctx.register_platform の platform_hint= — そのプラットフォームに表示の制約がある(マークダウンが使えない、メッセージの長さに上限があるなど)なら、登録の項目に助言を宣言します(文面は adapter_hint.py のような軽い別ファイルに置きます)。助言はシステムプロンプトに差し込まれます。agent/prompt_builder.py::PLATFORM_HINTS が持つのは本体側の画面の分だけです。
PLATFORM_HINT = (
"You are chatting via NewPlat. It supports markdown formatting "
"but has a 4000-character message limit."
)助言がすべてのプラットフォームに要るわけではありません。エージェントの振る舞いを変えるべきときにだけ足してください。
10. テスト
tests/gateway/test_newplat.py を作り、次を確かめます。
- 設定からアダプターを作れること
- メッセージのイベントを組み立てられること
- 送信のメソッド(外部の API は模擬に置き換えます)
- そのプラットフォーム独自の機能(暗号化、振り分けなど)
11. ドキュメント
| ファイル | 足すもの |
|---|---|
website/docs/user-guide/messaging/newplat.md |
そのプラットフォームの設定手順のページ一式 |
website/docs/user-guide/messaging/index.md |
プラットフォームの比較表、構成図、ツールセットの表、安全に関する節、次に読むページへのリンク |
website/docs/reference/environment-variables.md |
NEWPLAT_* の環境変数すべて |
website/docs/reference/toolsets-reference.md |
hermes-newplat のツールセット |
website/docs/integrations/index.md |
プラットフォームへのリンク |
website/sidebars.ts |
そのページへの目次の項目 |
website/docs/developer-guide/architecture.md |
アダプターの数と並び |
website/docs/developer-guide/gateway-internals.md |
アダプターのファイルの並び |
抜け漏れの点検
新しいプラットフォームの PR を仕上げる前に、すでにあるプラットフォームと突き合わせて抜けを探します。
# Find every .py file mentioning the reference platform
search_files "bluebubbles" output_mode="files_only" file_glob="*.py"
# Find every .py file mentioning the new platform
search_files "newplat" output_mode="files_only" file_glob="*.py"
# Any file in the first set but not the second is a potential gap.md と .ts のファイルでも同じことをします。見つかった差はひとつずつ確かめてください。プラットフォームを数え上げている箇所(直す必要があります)なのか、そのプラットフォーム固有の記述(そのままでかまいません)なのか、という見分けです。
よくある型
長めのポーリングを使うアダプター
Telegram や Weixin のように長めのポーリングを使うなら、ポーリングの繰り返しをタスクにします。
async def connect(self):
self._poll_task = asyncio.create_task(self._poll_loop())
self._mark_connected()
async def _poll_loop(self):
while self._running:
messages = await self._fetch_updates()
for msg in messages:
await self.handle_message(self._build_event(msg))コールバック / Webhook のアダプター
WeCom のコールバックのように、プラットフォーム側からこちらのエンドポイントへメッセージが送られてくるなら、HTTP サーバーを動かします。
async def connect(self):
self._app = web.Application()
self._app.router.add_post("/callback", self._handle_callback)
# ... start aiohttp server
self._mark_connected()
async def _handle_callback(self, request):
event = self._build_event(await request.text())
await self._message_queue.put(event)
return web.Response(text="success") # Acknowledge immediately応答の期限が厳しいプラットフォーム(たとえば WeCom の 5 秒)では、必ずその場で受領だけを返し、エージェントの返事はあとから API を使ってこちらから届けます。エージェントのセッションは 3〜30 分かかるので、コールバックの応答の枠の中でそのまま返すのは無理があります。
受信の重複を取り除く
プラットフォームは同じメッセージを送り直してきます。WebSocket が再開すると直近のイベントが再送され、Webhook は失敗すると再試行し、受領を返さなかったポーリングの一括分はもう一度届きます。こうした重複は、共通のヘルパーを使い、プラットフォームのメッセージ ID をキーにして捨てます。
from gateway.platforms.helpers import MessageDeduplicator
self._dedup = MessageDeduplicator(ttl_seconds=600) # in __init__
if self._dedup.is_duplicate(msg_id): # in the inbound handler
returnゲートウェイの再接続の見張り役が、失敗したアダプターを新しいインスタンスに置き換えるとき、MessageDeduplicator の属性それぞれが持っている有効な ID を、古いインスタンスから新しいインスタンスへすべて写します。そのため、再接続の直後に再送が来ても、やはり捨てられます。別の入れ物(素の dict や set)に持たせたキャッシュは、新しいインスタンスでは空の状態から始まります。
トークンの取り合いを防ぐ錠
そのアダプターが、ひとつしかない資格情報で接続を張り続けるなら、2 つのプロファイルが同じ資格情報を使ってしまわないよう、範囲を区切った錠を足します。
from gateway.status import acquire_scoped_lock, release_scoped_lock
async def connect(self, *, is_reconnect: bool = False):
acquired, _existing = acquire_scoped_lock("newplat", self._token)
if not acquired:
logger.error("Token already in use by another profile")
return False
# ... connect
async def disconnect(self):
release_scoped_lock("newplat", self._token)参考になる実装
| アダプター | 型 | 込み入り具合 | 参考になるところ |
|---|---|---|---|
plugins/platforms/bluebubbles/adapter.py |
REST + Webhook | 中 | 単純な REST API との連携 |
plugins/platforms/weixin/adapter.py |
長めのポーリング + CDN | 高 | メディアの扱い、暗号化 |
plugins/platforms/wecom/callback_adapter.py |
コールバック / Webhook | 中 | HTTP サーバー、AES の暗号、複数アプリ |
plugins/platforms/irc/adapter.py |
長めのポーリング + IRC の規約 | 高 | 範囲を区切ったトークンの錠を持つ、機能の揃ったプラグインのアダプター |