プラットフォームアダプターを追加する
目次
- アーキテクチャの概観
- プラグインの道筋(推奨)
- plugin.yaml
- adapter.py
- 設定
- プラグインの仕組みが自動でやってくれること
- 単体で動く送信経路の拡張
- 環境変数からの自動設定
- YAML から環境変数への橋渡し
- cron での配信
- 別プロセスからの cron 配信
- 環境変数を hermes config に出す
- LLM が遅いときのプラットフォーム別の見せ方
- 型: _keep_typing を継承して途中の見せ方を重ねる
- 型: send を継承して、すぐ送らずキャッシュに回す
- この型が向いている場面
- 参考実装
- 参考実装(プラグインの道筋)
- 手順ごとの確認項目(組み込みの道筋)
- 1. Platform の列挙
- 2. アダプターのファイル
- 3. ゲートウェイの設定(gateway/config.py)
- 4. ゲートウェイのランナー(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 の UI 項目を自動で埋めます(下の 環境変数を出す を参照)。
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 だけを import し、 クライアントツールをすべてのプロセス — CLI と TUI を含みます — に登録します。その間も アダプターは遅延読み込みのままです。パッケージの __init__.py は import を軽く保ち、 アダプターは register() の中から引き込んでください。そうすれば eager
遅延読み込み。
利用者は他のツールセットと同じ要領で、プラットフォームごとに有効にします。たとえば hermes tools enable my_platform --platform cli のようにするか、config.yaml の platform_toolsets の下にそのツールセットのキーを並べます。プラグインの プラットフォーム名も --platform の対象として有効なので、そのプラットフォームで受けた セッションに、そこ自身の送信側ツールを与えることもできます。
adapter.py
from gateway.platforms.base import (
BasePlatformAdapter, SendResult, MessageEvent, MessageType,
)
from gateway.config import Platform, PlatformConfig
class MyPlatformAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("my_platform"))
extra = config.extra or {}
self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("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(os.getenv("MY_PLATFORM_TOKEN"))
def validate_config(config) -> bool:
extra = getattr(config, "extra", {}) or {}
return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token"))
def _env_enablement() -> dict | None:
token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
seed = {"token": token, "channel": channel}
home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
if home:
seed["home_channel"] = {"chat_id": home, "name": "Home"}
return seed
def register(ctx):
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
# 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 tools.lazy_deps.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="pip install my-platform-sdk",
# 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() を呼ぶと、次の連携部分が自動で面倒を見られます。中核のコードに変更は要りません。
| 連携する部分 | どう動くか |
|---|---|
| ゲートウェイのアダプター生成 | 組み込みの if/elif の連なりより先にレジストリが参照されます |
| 設定の読み取り | Platform._missing_() がどんなプラットフォーム名も受け付けます |
| 接続済みプラットフォームの検証 | レジストリの validate_config() が呼ばれます |
| 利用者の認可 | allowed_users_env / allow_all_env が参照されます |
| 環境変数だけでの自動有効化 | env_enablement_fn が PlatformConfig.extra と home_channel を埋めます |
| YAML 設定の橋渡し | apply_yaml_config_fn が config.yaml のキーを環境変数や extras に翻訳します |
| cron での配信 | cron_deliver_env_var によって deliver=<name> が使えます |
hermes config の UI 項目 |
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 |
プラグインのプラットフォームを (plugin) の印付きで表示します |
hermes gateway setup |
セットアップのメニューにプラグインのプラットフォームが並びます |
hermes tools / hermes skills |
プラットフォームごとの設定にプラグインのプラットフォームが入ります |
| トークンのロック(複数プロフィール) | connect() の中で acquire_scoped_lock() を使ってください |
| 設定だけ残ったときの警告 | プラグインが見つからないとき、事情のわかるログが出ます |
単体で動く送信経路の拡張
単体で動くプラットフォームも、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 を読み込まずに正しい状態を見られます。
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.
The special 'home_channel' key is extracted and becomes a proper
HomeChannel dataclass on the PlatformConfig; every other key is
merged into PlatformConfig.extra.
"""
token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
seed = {"token": token, "channel": channel}
home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("MY_PLATFORM_HOME_CHANNEL_NAME", "Home"),
}
return seed
def register(ctx):
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
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 の構造を覚えさせる代わりに、プラグイン自身がこの翻訳を担えます。
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", {}))
May mutate os.environ directly (use `not os.getenv(...)` guards to
preserve env > YAML precedence) and/or return a dict to merge into
PlatformConfig.extra. Return None or {} for no extras.
"""
if "require_mention" in platform_cfg and not os.getenv("MY_PLATFORM_REQUIRE_MENTION"):
os.environ["MY_PLATFORM_REQUIRE_MENTION"] = str(platform_cfg["require_mention"]).lower()
allowed = platform_cfg.get("allowed_channels")
if allowed is not None and not os.getenv("MY_PLATFORM_ALLOWED_CHANNELS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
os.environ["MY_PLATFORM_ALLOWED_CHANNELS"] = str(allowed)
return None # nothing extra to merge into PlatformConfig.extra
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 の辞書を埋めているなら(上を参照)そちらが優先され、cron_deliver_env_var は環境変数からの設定より前に走る cron ジョブのためのフォールバックになります。
別プロセスからの cron 配信
cron_deliver_env_var は、そのプラットフォームを deliver= の宛先として認識させます。cron ジョブがゲートウェイとは別のプロセスで走るとき(つまり hermes cron run を hermes gateway と別に動かすとき)に実際の送信まで成功させるには、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 は import の時点で plugins/platforms/*/plugin.yaml を走査し、requires_env と(任意の)optional_env のブロックから OPTIONAL_ENV_VARS を自動で埋めます。辞書の詳しい書き方を使えば、説明文、入力の促し、パスワード扱いのフラグ、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使える辞書のキー: name(必須)、description、prompt、url、password(真偽値。省略すると *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON の語尾から自動で判定します)、category(既定は "messaging")。
素の文字列で書く形(- MY_PLATFORM_TOKEN)も引き続き使えます。その場合はプラグインの label から一般的な説明文が自動で作られます。同じ変数がすでに OPTIONAL_ENV_VARS に直接書かれているなら、そちらが勝ちます(従来の動きを保つため)。plugin.yaml の形はフォールバックとして働きます。
LLM が遅いときのプラットフォーム別の見せ方
プラットフォームによっては、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 と、LLM が遅いときのプラットフォーム固有の見せ方を扱っています。
手順ごとの確認項目(組み込みの道筋)
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.base import (
BasePlatformAdapter, MessageEvent, MessageType, SendResult,
)
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.get("api_key") or os.getenv("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)
触る場所は6つです。
_create_adapter()—elif platform == Platform.NEWPLAT:の分岐を足します_is_user_authorized()の allowed_users の対応 —Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"_is_user_authorized()の allow_all の対応 —Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"- 起動時の環境変数の確認
_any_allowlistのタプル —"NEWPLAT_ALLOWED_USERS"を足します - 起動時の環境変数の確認
_allow_allのタプル —"NEWPLAT_ALLOW_ALL_USERS"を足します _UPDATE_ALLOWED_PLATFORMSの frozenset —Platform.NEWPLATを足します
5. プラットフォームをまたぐ配信
gateway/platforms/webhook.py— 配信の種類のタプルに"newplat"を足しますcron/scheduler.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の TUI が使います)hermes_cli/setup.py—_setup_newplat()関数を足し(gateway.pyに任せても構いません)、メッセージングのプラットフォームの一覧にタプルを足しますhermes_cli/status.py— プラットフォームの検出の項目を足します:"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")hermes_cli/dump.py— プラットフォーム検出の辞書に"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. 任意: プラットフォームへの補足
agent/prompt_builder.py — そのプラットフォームに表示上の制約(markdown が使えない、メッセージ長の上限があるなど)があるなら、PLATFORM_HINTS の辞書に項目を足します。これでプラットフォーム固有の案内がシステムプロンプトに差し込まれます。
PLATFORM_HINTS = {
# ...
"newplat": (
"You are chatting via NewPlat. It supports markdown formatting "
"but has a 4000-character message limit."
),
}すべてのプラットフォームに補足が要るわけではありません。エージェントの振る舞いを変えるべきときだけ足してください。
10. テスト
tests/gateway/test_newplat.py を作り、次を覆います。
- 設定からのアダプターの生成
- メッセージイベントの組み立て
- send メソッド(外部の 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分走ります。コールバックの応答の枠内で返事まで済ませるのは現実的ではありません。
トークンのロック
アダプターが固有の資格情報で接続を張り続けるなら、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)参考実装
| アダプター | 型 | 難しさ | 参考になる点 |
|---|---|---|---|
bluebubbles.py |
REST + Webhook | 中 | 素直な REST API との連携 |
weixin.py |
ロングポーリング + CDN | 高 | メディアの扱い、暗号化 |
plugins/platforms/wecom/callback_adapter.py |
コールバック / Webhook | 中 | HTTP サーバー、AES の暗号化、複数アプリ |
plugins/platforms/irc/adapter.py |
ロングポーリング + IRC のプロトコル | 高 | 範囲を限ったトークンのロックを備えた、機能の揃ったプラグインのアダプター |