diff --git a/gateway/config.py b/gateway/config.py index 5e292d0c56..33ca7fecd5 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -891,15 +891,6 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: if not pconfig.enabled: continue env_name = _token_env_names.get(platform) - if not env_name: - # Check plugin registry for required_env - try: - from gateway.platform_registry import platform_registry - entry = platform_registry.get(platform.value) - if entry and entry.required_env: - env_name = entry.required_env[0] # primary env var - except Exception: - pass if env_name and pconfig.token is not None and not pconfig.token.strip(): logger.warning( "%s is enabled but %s is empty. " diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 9096f76fda..54babf9107 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -89,6 +89,11 @@ class PlatformEntry: # (allows /update command from this platform). allow_update_command: bool = True + # ── LLM guidance ── + # Platform hint injected into the system prompt (e.g. "You are on IRC. + # Do not use markdown."). Empty string = no hint. + platform_hint: str = "" + class PlatformRegistry: """Central registry of platform adapters. diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index f773f8c8f8..7fd28245b1 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -1,9 +1,30 @@ # Adding a New Messaging Platform -Checklist for integrating a new messaging platform into the Hermes gateway. -Use this as a reference when building a new adapter — every item here is a -real integration point that exists in the codebase. Missing any of them will -cause broken functionality, missing features, or inconsistent behavior. +There are two ways to add a platform to the Hermes gateway: + +## Plugin Path (Recommended for Community/Third-Party) + +Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and +`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers +via `ctx.register_platform()` in the `register(ctx)` entry point. This +requires **zero changes to core Hermes code**. + +The plugin system automatically handles: adapter creation, config parsing, +user authorization, cron delivery, send_message routing, system prompt hints, +status display, gateway setup, and more. + +See `plugins/platforms/irc/` for a complete reference implementation, and +`website/docs/developer-guide/adding-platform-adapters.md` for the full +plugin guide with code examples. + +--- + +## Built-in Path (Core Contributors Only) + +Checklist for integrating a platform directly into the Hermes core. +Use this as a reference when building a built-in adapter — every item here +is a real integration point. Missing any of them will cause broken +functionality, missing features, or inconsistent behavior. --- diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index e3a736a451..34e2dfa2c5 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -202,26 +202,22 @@ class WebhookAdapter(BasePlatformAdapter): if deliver_type == "github_comment": return await self._deliver_github_comment(content, delivery) - # Cross-platform delivery — any platform with a gateway adapter - if self.gateway_runner and deliver_type in ( - "telegram", - "discord", - "slack", - "signal", - "sms", - "whatsapp", - "matrix", - "mattermost", - "homeassistant", - "email", - "dingtalk", - "feishu", - "wecom", - "wecom_callback", - "weixin", - "bluebubbles", - "qqbot", - ): + # Cross-platform delivery — any platform with a gateway adapter. + # Check both built-in names and plugin-registered platforms. + _BUILTIN_DELIVER_PLATFORMS = { + "telegram", "discord", "slack", "signal", "sms", "whatsapp", + "matrix", "mattermost", "homeassistant", "email", "dingtalk", + "feishu", "wecom", "wecom_callback", "weixin", "bluebubbles", + "qqbot", "yuanbao", + } + _is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS + if not _is_known_platform: + try: + from gateway.platform_registry import platform_registry + _is_known_platform = platform_registry.is_registered(deliver_type) + except Exception: + pass + if self.gateway_runner and _is_known_platform: return await self._deliver_cross_platform( deliver_type, content, delivery ) diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index 5b464d02fd..85b991e79b 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -151,6 +151,18 @@ class IRCAdapter(BasePlatformAdapter): ) return False + # Prevent two profiles from using the same IRC identity + try: + from gateway.status import acquire_scoped_lock, release_scoped_lock + lock_key = f"{self.server}:{self.nickname}" + if not acquire_scoped_lock("irc", lock_key): + logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server) + self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False) + return False + self._lock_key = lock_key + except ImportError: + self._lock_key = None # status module not available (e.g. tests) + try: ssl_ctx = None if self.use_tls: @@ -197,6 +209,13 @@ class IRCAdapter(BasePlatformAdapter): async def disconnect(self) -> None: """Quit and close the connection.""" + # Release the scoped lock so another profile can use this identity + if getattr(self, "_lock_key", None): + try: + from gateway.status import release_scoped_lock + release_scoped_lock("irc", self._lock_key) + except Exception: + pass self._mark_disconnected() if self._writer and not self._writer.is_closing(): try: @@ -500,4 +519,12 @@ def register(ctx): # IRC doesn't have phone numbers to redact pii_safe=False, allow_update_command=True, + # LLM guidance + platform_hint=( + "You are chatting via IRC. IRC does not support markdown formatting " + "— use plain text only. Messages are limited to ~450 characters per " + "line (long messages are automatically split). In channels, users " + "address you by prefixing your nick. Keep responses concise and " + "conversational." + ), ) diff --git a/run_agent.py b/run_agent.py index e31fb80352..846082f43d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2151,7 +2151,7 @@ class AIAgent: # Context engine reset (works for both built-in compressor and plugins) if hasattr(self, "context_compressor") and self.context_compressor: self.context_compressor.on_session_reset() - + def _ensure_lmstudio_runtime_loaded(self, config_context_length: Optional[int] = None) -> None: """ Preload the LM Studio model with at least Hermes' minimum context. @@ -2935,7 +2935,7 @@ class AIAgent: # Check if there's any non-whitespace content remaining return bool(cleaned.strip()) - + def _strip_think_blocks(self, content: str) -> str: """Remove reasoning/thinking blocks from content, returning only visible text. @@ -3153,8 +3153,8 @@ class AIAgent: marker in assistant_text for marker in workspace_markers ) return (user_targets_workspace or assistant_targets_workspace) and assistant_mentions_action - - + + def _extract_reasoning(self, assistant_message) -> Optional[str]: """ Extract reasoning/thinking content from an assistant message. @@ -3727,7 +3727,7 @@ class AIAgent: # Return everything up to (not including) the last assistant message return messages[:last_assistant_idx] - + def _format_tools_for_system_message(self) -> str: """ Format tool definitions for the system message in the trajectory format. @@ -3751,7 +3751,7 @@ class AIAgent: formatted_tools.append(formatted_tool) return json.dumps(formatted_tools, ensure_ascii=False) - + def _convert_to_trajectory_format(self, messages: List[Dict[str, Any]], user_query: str, completed: bool) -> List[Dict[str, Any]]: """ Convert internal message format to trajectory format for saving. @@ -3916,7 +3916,7 @@ class AIAgent: i += 1 return trajectory - + def _save_trajectory(self, messages: List[Dict[str, Any]], user_query: str, completed: bool): """ Save conversation trajectory to JSONL file. @@ -3931,7 +3931,7 @@ class AIAgent: trajectory = self._convert_to_trajectory_format(messages, user_query, completed) _save_trajectory_to_file(trajectory, self.model, completed) - + @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -4243,7 +4243,7 @@ class AIAgent: except Exception as e: if self.verbose_logging: logging.warning(f"Failed to save session log: {e}") - + def interrupt(self, message: str = None) -> None: """ Request the agent to interrupt its current tool-calling loop. @@ -4311,7 +4311,7 @@ class AIAgent: logger.debug("Failed to propagate interrupt to child agent: %s", e) if not self.quiet_mode: print("\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else "")) - + def clear_interrupt(self) -> None: """Clear any pending interrupt request and the per-thread tool interrupt signal.""" self._interrupt_requested = False @@ -4532,7 +4532,7 @@ class AIAgent: ) except Exception: pass - + def commit_memory_session(self, messages: list = None) -> None: """Trigger end-of-session extraction without tearing providers down. Called when session_id rotates (e.g. /new, context compression); @@ -4728,7 +4728,7 @@ class AIAgent: if not self.quiet_mode: self._vprint(f"{self.log_prefix}📋 Restored {len(last_todo_response)} todo item(s) from history") _set_interrupt(False) - + @property def is_interrupted(self) -> bool: """Check if an interrupt has been requested.""" @@ -4912,6 +4912,15 @@ class AIAgent: platform_key = (self.platform or "").lower().strip() if platform_key in PLATFORM_HINTS: prompt_parts.append(PLATFORM_HINTS[platform_key]) + elif platform_key: + # Check plugin registry for platform-specific LLM guidance + try: + from gateway.platform_registry import platform_registry + _entry = platform_registry.get(platform_key) + if _entry and _entry.platform_hint: + prompt_parts.append(_entry.platform_hint) + except Exception: + pass return "\n\n".join(p.strip() for p in prompt_parts if p.strip()) diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 0f8438895f..5bab2fc4be 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -7,7 +7,9 @@ sidebar_position: 9 This guide covers adding a new messaging platform to the Hermes gateway. A platform adapter connects Hermes to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service. :::tip -Adding a platform adapter touches 20+ files across code, config, and docs. Use this guide as a checklist — the adapter file itself is typically only 40% of the work. +There are two ways to add a platform: +- **Plugin** (recommended for community/third-party): Drop a plugin directory into `~/.hermes/plugins/` — zero core code changes needed. See [Plugin Path](#plugin-path-recommended) below. +- **Built-in**: Modify 20+ files across code, config, and docs. Use the [Built-in Checklist](#step-by-step-checklist) below. ::: ## Architecture Overview @@ -26,7 +28,152 @@ Every adapter extends `BasePlatformAdapter` from `gateway/platforms/base.py` and Inbound messages are received by the adapter and forwarded via `self.handle_message(event)`, which the base class routes to the gateway runner. -## Step-by-Step Checklist +## Plugin Path (Recommended) + +The plugin system lets you add a platform adapter without modifying any core Hermes code. Your plugin is a directory with two files: + +``` +~/.hermes/plugins/my-platform/ + PLUGIN.yaml # Plugin metadata + adapter.py # Adapter class + register() entry point +``` + +### PLUGIN.yaml + +```yaml +name: my-platform +version: 1.0.0 +description: My custom messaging platform adapter +requires_env: + - MY_PLATFORM_TOKEN + - MY_PLATFORM_CHANNEL +``` + +### adapter.py + +```python +import os +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) -> 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 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), + check_fn=check_requirements, + validate_config=validate_config, + required_env=["MY_PLATFORM_TOKEN"], + install_hint="pip install my-platform-sdk", + # 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, + ) +``` + +### Configuration + +Users configure the platform in `config.yaml`: + +```yaml +gateway: + platforms: + my_platform: + enabled: true + extra: + token: "..." + channel: "#general" +``` + +Or via environment variables (which the adapter reads in `__init__`). + +### What the Plugin System Handles Automatically + +When you call `ctx.register_platform()`, the following integration points are handled for you — no core code changes needed: + +| Integration point | How it works | +|---|---| +| Gateway adapter creation | Registry checked before built-in if/elif chain | +| Config parsing | `Platform._missing_()` accepts any platform name | +| Connected platform validation | Registry `validate_config()` called | +| User authorization | `allowed_users_env` / `allow_all_env` checked | +| Cron delivery | `Platform()` resolves any registered name | +| send_message tool | Routes through live gateway adapter | +| Webhook cross-platform delivery | Registry checked for known platforms | +| `/update` command access | `allow_update_command` flag | +| Channel directory | Plugin platforms included in enumeration | +| System prompt hints | `platform_hint` injected into LLM context | +| Message chunking | `max_message_length` for smart splitting | +| PII redaction | `pii_safe` flag | +| `hermes status` | Shows plugin platforms with `(plugin)` tag | +| `hermes gateway setup` | Plugin platforms appear in setup menu | +| `hermes tools` / `hermes skills` | Plugin platforms in per-platform config | +| Token lock (multi-profile) | Use `acquire_scoped_lock()` in your `connect()` | +| Orphaned config warning | Descriptive log when plugin is missing | + +### Reference Implementation + +See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. + +--- + +## Step-by-Step Checklist (Built-in Path) + +:::note +This checklist is for adding a platform directly to the Hermes core codebase — typically done by core contributors for officially supported platforms. Community/third-party platforms should use the [Plugin Path](#plugin-path-recommended) above. +::: ### 1. Platform Enum