2026-02-02 19:01:51 -08:00
|
|
|
"""
|
|
|
|
|
Gateway configuration management.
|
|
|
|
|
|
|
|
|
|
Handles loading and validating configuration for:
|
|
|
|
|
- Connected platforms (Telegram, Discord, WhatsApp)
|
|
|
|
|
- Home channels for each platform
|
|
|
|
|
- Session reset policies
|
|
|
|
|
- Delivery preferences
|
|
|
|
|
"""
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
import logging
|
2026-02-02 19:01:51 -08:00
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
|
from enum import Enum
|
|
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
|
|
|
|
"""Coerce bool-ish config values, preserving a caller-provided default."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
|
|
|
return bool(value)
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 04:06:08 -07:00
|
|
|
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
|
|
|
|
"""Normalize unauthorized DM behavior to a supported value."""
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
normalized = value.strip().lower()
|
|
|
|
|
if normalized in {"pair", "ignore"}:
|
|
|
|
|
return normalized
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
class Platform(Enum):
|
|
|
|
|
"""Supported messaging platforms."""
|
|
|
|
|
LOCAL = "local"
|
|
|
|
|
TELEGRAM = "telegram"
|
|
|
|
|
DISCORD = "discord"
|
|
|
|
|
WHATSAPP = "whatsapp"
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
SLACK = "slack"
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
SIGNAL = "signal"
|
2026-03-17 02:59:36 -07:00
|
|
|
MATTERMOST = "mattermost"
|
|
|
|
|
MATRIX = "matrix"
|
2026-02-28 13:32:48 +03:00
|
|
|
HOMEASSISTANT = "homeassistant"
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
EMAIL = "email"
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
SMS = "sms"
|
2026-03-17 03:04:58 -07:00
|
|
|
DINGTALK = "dingtalk"
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
API_SERVER = "api_server"
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
WEBHOOK = "webhook"
|
feat(gateway): add Feishu/Lark platform support (#3817)
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway
platform adapter with full feature parity: WebSocket + webhook transports,
message batching, dedup, rate limiting, rich post/card content parsing,
media handling (images/audio/files/video), group @mention gating,
reaction routing, and interactive card button support.
Cherry-picked from PR #1793 by penwyp with:
- Moved to current main (PR was 458 commits behind)
- Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to
_feishu_send_with_retry to avoid signature mismatch crash)
- Fixed import structure: aiohttp/websockets imported independently of
lark_oapi so they remain available when SDK is missing
- Fixed get_hermes_home import (hermes_constants, not hermes_cli.config)
- Added skip decorators for tests requiring lark_oapi SDK
- All 16 integration points added surgically to current main
New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu])
Fixes #1788
Co-authored-by: penwyp <penwyp@users.noreply.github.com>
2026-03-29 18:17:42 -07:00
|
|
|
FEISHU = "feishu"
|
2026-03-29 21:29:13 -07:00
|
|
|
WECOM = "wecom"
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class HomeChannel:
|
|
|
|
|
"""
|
|
|
|
|
Default destination for a platform.
|
|
|
|
|
|
|
|
|
|
When a cron job specifies deliver="telegram" without a specific chat ID,
|
|
|
|
|
messages are sent to this home channel.
|
|
|
|
|
"""
|
|
|
|
|
platform: Platform
|
|
|
|
|
chat_id: str
|
|
|
|
|
name: str # Human-readable name for display
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platform": self.platform.value,
|
|
|
|
|
"chat_id": self.chat_id,
|
|
|
|
|
"name": self.name,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
|
|
|
|
return cls(
|
|
|
|
|
platform=Platform(data["platform"]),
|
|
|
|
|
chat_id=str(data["chat_id"]),
|
|
|
|
|
name=data.get("name", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Controls when sessions reset (lose context).
|
|
|
|
|
|
|
|
|
|
Modes:
|
|
|
|
|
- "daily": Reset at a specific hour each day
|
|
|
|
|
- "idle": Reset after N minutes of inactivity
|
|
|
|
|
- "both": Whichever triggers first (daily boundary OR idle timeout)
|
2026-02-26 21:20:50 -08:00
|
|
|
- "none": Never auto-reset (context managed only by compression)
|
2026-02-02 19:01:51 -08:00
|
|
|
"""
|
2026-02-26 21:20:50 -08:00
|
|
|
mode: str = "both" # "daily", "idle", "both", or "none"
|
2026-02-02 19:01:51 -08:00
|
|
|
at_hour: int = 4 # Hour for daily reset (0-23, local time)
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
|
2026-03-22 09:33:39 -07:00
|
|
|
notify: bool = True # Send a notification to the user when auto-reset occurs
|
|
|
|
|
notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"mode": self.mode,
|
|
|
|
|
"at_hour": self.at_hour,
|
|
|
|
|
"idle_minutes": self.idle_minutes,
|
2026-03-22 09:33:39 -07:00
|
|
|
"notify": self.notify,
|
|
|
|
|
"notify_exclude_platforms": list(self.notify_exclude_platforms),
|
2026-02-02 19:01:51 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
# Handle both missing keys and explicit null values (YAML null → None)
|
2026-03-15 21:40:22 -07:00
|
|
|
mode = data.get("mode")
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
at_hour = data.get("at_hour")
|
|
|
|
|
idle_minutes = data.get("idle_minutes")
|
2026-03-22 09:33:39 -07:00
|
|
|
notify = data.get("notify")
|
|
|
|
|
exclude = data.get("notify_exclude_platforms")
|
2026-02-02 19:01:51 -08:00
|
|
|
return cls(
|
2026-03-15 21:40:22 -07:00
|
|
|
mode=mode if mode is not None else "both",
|
fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.
Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)
A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.
All 49 gateway HA tests + 52 HA tool tests pass.
* docs: update Home Assistant integration documentation
- homeassistant.md: Fix event filtering docs to reflect closed-by-default
behavior. Add watch_all option. Replace Python dict config example with
YAML. Fix defaults table (was incorrectly showing 'all'). Add required
configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
diagram, platform toolsets table, and Next Steps links.
* fix(terminal): strip provider env vars from background and PTY subprocesses
Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:
- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)
Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.
Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.
Gap identified by PR #1004 (@PeterFile).
* feat(delegate): add observability metadata to subagent results
Enrich delegate_task results with metadata from the child AIAgent:
- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status
Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.
Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
* feat(stt): add free local whisper transcription via faster-whisper
Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):
STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)
Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
config loading, local faster-whisper backend, and OpenAI API backend.
Auto-downloads model (~150MB for 'base') on first voice message.
Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
config examples, and fallback behavior
Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
* fix: handle YAML null values in session reset policy + configurable API timeout
Two fixes from PR #888 by @Jah-yee:
1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
when the YAML key exists with a null value. Now explicitly checks for
None and falls back to defaults. Zero remains a valid value.
2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
env var. Useful for slow local models (llama.cpp) that need longer.
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
---------
Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
|
|
|
at_hour=at_hour if at_hour is not None else 4,
|
|
|
|
|
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
|
2026-03-22 09:33:39 -07:00
|
|
|
notify=notify if notify is not None else True,
|
|
|
|
|
notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
|
2026-02-02 19:01:51 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PlatformConfig:
|
|
|
|
|
"""Configuration for a single messaging platform."""
|
|
|
|
|
enabled: bool = False
|
|
|
|
|
token: Optional[str] = None # Bot token (Telegram, Discord)
|
|
|
|
|
api_key: Optional[str] = None # API key if different from token
|
|
|
|
|
home_channel: Optional[HomeChannel] = None
|
|
|
|
|
|
2026-03-24 19:56:00 -07:00
|
|
|
# Reply threading mode (Telegram/Slack)
|
|
|
|
|
# - "off": Never thread replies to original message
|
|
|
|
|
# - "first": Only first chunk threads to user's message (default)
|
|
|
|
|
# - "all": All chunks in multi-part replies thread to user's message
|
|
|
|
|
reply_to_mode: str = "first"
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Platform-specific settings
|
|
|
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
result = {
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
"extra": self.extra,
|
2026-03-24 19:56:00 -07:00
|
|
|
"reply_to_mode": self.reply_to_mode,
|
2026-02-02 19:01:51 -08:00
|
|
|
}
|
|
|
|
|
if self.token:
|
|
|
|
|
result["token"] = self.token
|
|
|
|
|
if self.api_key:
|
|
|
|
|
result["api_key"] = self.api_key
|
|
|
|
|
if self.home_channel:
|
|
|
|
|
result["home_channel"] = self.home_channel.to_dict()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
|
|
|
|
home_channel = None
|
|
|
|
|
if "home_channel" in data:
|
|
|
|
|
home_channel = HomeChannel.from_dict(data["home_channel"])
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
enabled=data.get("enabled", False),
|
|
|
|
|
token=data.get("token"),
|
|
|
|
|
api_key=data.get("api_key"),
|
|
|
|
|
home_channel=home_channel,
|
2026-03-24 19:56:00 -07:00
|
|
|
reply_to_mode=data.get("reply_to_mode", "first"),
|
2026-02-02 19:01:51 -08:00
|
|
|
extra=data.get("extra", {}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
@dataclass
|
|
|
|
|
class StreamingConfig:
|
|
|
|
|
"""Configuration for real-time token streaming to messaging platforms."""
|
2026-03-16 07:44:42 -07:00
|
|
|
enabled: bool = False
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
transport: str = "edit" # "edit" (progressive editMessageText) or "off"
|
|
|
|
|
edit_interval: float = 0.3 # Seconds between message edits
|
|
|
|
|
buffer_threshold: int = 40 # Chars before forcing an edit
|
|
|
|
|
cursor: str = " ▉" # Cursor shown during streaming
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
"transport": self.transport,
|
|
|
|
|
"edit_interval": self.edit_interval,
|
|
|
|
|
"buffer_threshold": self.buffer_threshold,
|
|
|
|
|
"cursor": self.cursor,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
|
|
|
|
|
if not data:
|
|
|
|
|
return cls()
|
|
|
|
|
return cls(
|
2026-03-16 07:44:42 -07:00
|
|
|
enabled=data.get("enabled", False),
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
transport=data.get("transport", "edit"),
|
|
|
|
|
edit_interval=float(data.get("edit_interval", 0.3)),
|
|
|
|
|
buffer_threshold=int(data.get("buffer_threshold", 40)),
|
|
|
|
|
cursor=data.get("cursor", " ▉"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
@dataclass
|
|
|
|
|
class GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Main gateway configuration.
|
|
|
|
|
|
|
|
|
|
Manages all platform connections, session policies, and delivery settings.
|
|
|
|
|
"""
|
|
|
|
|
# Platform configurations
|
|
|
|
|
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Session reset policies by type
|
|
|
|
|
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
|
|
|
|
|
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Reset trigger commands
|
|
|
|
|
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
2026-03-11 12:46:56 -07:00
|
|
|
|
|
|
|
|
# User-defined quick commands (slash commands that bypass the agent loop)
|
|
|
|
|
quick_commands: Dict[str, Any] = field(default_factory=dict)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# Storage paths
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
# Delivery settings
|
|
|
|
|
always_log_local: bool = True # Always save cron outputs to local files
|
2026-03-14 22:09:53 -07:00
|
|
|
|
|
|
|
|
# STT settings
|
|
|
|
|
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
|
2026-03-16 00:22:23 -07:00
|
|
|
|
|
|
|
|
# Session isolation in shared chats
|
|
|
|
|
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
|
|
|
|
|
|
2026-03-18 04:06:08 -07:00
|
|
|
# Unauthorized DM policy
|
|
|
|
|
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
|
|
|
|
|
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
# Streaming configuration
|
|
|
|
|
streaming: StreamingConfig = field(default_factory=StreamingConfig)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
def get_connected_platforms(self) -> List[Platform]:
|
|
|
|
|
"""Return list of platforms that are enabled and configured."""
|
|
|
|
|
connected = []
|
|
|
|
|
for platform, config in self.platforms.items():
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
if not config.enabled:
|
|
|
|
|
continue
|
|
|
|
|
# Platforms that use token/api_key auth
|
|
|
|
|
if config.token or config.api_key:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# WhatsApp uses enabled flag only (bridge handles auth)
|
|
|
|
|
elif platform == Platform.WHATSAPP:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# Signal uses extra dict for config (http_url + account)
|
|
|
|
|
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
2026-02-02 19:01:51 -08:00
|
|
|
connected.append(platform)
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
# Email uses extra dict for config (address + imap_host + smtp_host)
|
|
|
|
|
elif platform == Platform.EMAIL and config.extra.get("address"):
|
|
|
|
|
connected.append(platform)
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
# SMS uses api_key (Twilio auth token) — SID checked via env
|
|
|
|
|
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
|
|
|
|
|
connected.append(platform)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
# API Server uses enabled flag only (no token needed)
|
|
|
|
|
elif platform == Platform.API_SERVER:
|
|
|
|
|
connected.append(platform)
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
# Webhook uses enabled flag only (secrets are per-route)
|
|
|
|
|
elif platform == Platform.WEBHOOK:
|
|
|
|
|
connected.append(platform)
|
feat(gateway): add Feishu/Lark platform support (#3817)
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway
platform adapter with full feature parity: WebSocket + webhook transports,
message batching, dedup, rate limiting, rich post/card content parsing,
media handling (images/audio/files/video), group @mention gating,
reaction routing, and interactive card button support.
Cherry-picked from PR #1793 by penwyp with:
- Moved to current main (PR was 458 commits behind)
- Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to
_feishu_send_with_retry to avoid signature mismatch crash)
- Fixed import structure: aiohttp/websockets imported independently of
lark_oapi so they remain available when SDK is missing
- Fixed get_hermes_home import (hermes_constants, not hermes_cli.config)
- Added skip decorators for tests requiring lark_oapi SDK
- All 16 integration points added surgically to current main
New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu])
Fixes #1788
Co-authored-by: penwyp <penwyp@users.noreply.github.com>
2026-03-29 18:17:42 -07:00
|
|
|
# Feishu uses extra dict for app credentials
|
|
|
|
|
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
|
|
|
|
connected.append(platform)
|
2026-03-29 21:29:13 -07:00
|
|
|
# WeCom uses extra dict for bot credentials
|
|
|
|
|
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
|
|
|
|
connected.append(platform)
|
2026-02-02 19:01:51 -08:00
|
|
|
return connected
|
|
|
|
|
|
|
|
|
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
|
|
|
|
"""Get the home channel for a platform."""
|
|
|
|
|
config = self.platforms.get(platform)
|
|
|
|
|
if config:
|
|
|
|
|
return config.home_channel
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def get_reset_policy(
|
|
|
|
|
self,
|
|
|
|
|
platform: Optional[Platform] = None,
|
|
|
|
|
session_type: Optional[str] = None
|
|
|
|
|
) -> SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Get the appropriate reset policy for a session.
|
|
|
|
|
|
|
|
|
|
Priority: platform override > type override > default
|
|
|
|
|
"""
|
|
|
|
|
# Platform-specific override takes precedence
|
|
|
|
|
if platform and platform in self.reset_by_platform:
|
|
|
|
|
return self.reset_by_platform[platform]
|
|
|
|
|
|
|
|
|
|
# Type-specific override (dm, group, thread)
|
|
|
|
|
if session_type and session_type in self.reset_by_type:
|
|
|
|
|
return self.reset_by_type[session_type]
|
|
|
|
|
|
|
|
|
|
return self.default_reset_policy
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platforms": {
|
|
|
|
|
p.value: c.to_dict() for p, c in self.platforms.items()
|
|
|
|
|
},
|
|
|
|
|
"default_reset_policy": self.default_reset_policy.to_dict(),
|
|
|
|
|
"reset_by_type": {
|
|
|
|
|
k: v.to_dict() for k, v in self.reset_by_type.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_by_platform": {
|
|
|
|
|
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_triggers": self.reset_triggers,
|
2026-03-11 12:46:56 -07:00
|
|
|
"quick_commands": self.quick_commands,
|
2026-02-02 19:01:51 -08:00
|
|
|
"sessions_dir": str(self.sessions_dir),
|
|
|
|
|
"always_log_local": self.always_log_local,
|
2026-03-14 22:09:53 -07:00
|
|
|
"stt_enabled": self.stt_enabled,
|
2026-03-16 00:22:23 -07:00
|
|
|
"group_sessions_per_user": self.group_sessions_per_user,
|
2026-03-18 04:06:08 -07:00
|
|
|
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
"streaming": self.streaming.to_dict(),
|
2026-02-02 19:01:51 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
|
|
|
|
|
platforms = {}
|
|
|
|
|
for platform_name, platform_data in data.get("platforms", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
platforms[platform] = PlatformConfig.from_dict(platform_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass # Skip unknown platforms
|
|
|
|
|
|
|
|
|
|
reset_by_type = {}
|
|
|
|
|
for type_name, policy_data in data.get("reset_by_type", {}).items():
|
|
|
|
|
reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
|
|
|
|
|
reset_by_platform = {}
|
|
|
|
|
for platform_name, policy_data in data.get("reset_by_platform", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
default_policy = SessionResetPolicy()
|
|
|
|
|
if "default_reset_policy" in data:
|
|
|
|
|
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
|
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
sessions_dir = get_hermes_home() / "sessions"
|
2026-02-02 19:01:51 -08:00
|
|
|
if "sessions_dir" in data:
|
|
|
|
|
sessions_dir = Path(data["sessions_dir"])
|
|
|
|
|
|
2026-03-11 12:46:56 -07:00
|
|
|
quick_commands = data.get("quick_commands", {})
|
|
|
|
|
if not isinstance(quick_commands, dict):
|
|
|
|
|
quick_commands = {}
|
|
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
stt_enabled = data.get("stt_enabled")
|
|
|
|
|
if stt_enabled is None:
|
|
|
|
|
stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None
|
|
|
|
|
|
2026-03-16 00:22:23 -07:00
|
|
|
group_sessions_per_user = data.get("group_sessions_per_user")
|
2026-03-18 04:06:08 -07:00
|
|
|
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
|
|
|
|
|
data.get("unauthorized_dm_behavior"),
|
|
|
|
|
"pair",
|
|
|
|
|
)
|
2026-03-16 00:22:23 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
return cls(
|
|
|
|
|
platforms=platforms,
|
|
|
|
|
default_reset_policy=default_policy,
|
|
|
|
|
reset_by_type=reset_by_type,
|
|
|
|
|
reset_by_platform=reset_by_platform,
|
|
|
|
|
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
|
2026-03-11 12:46:56 -07:00
|
|
|
quick_commands=quick_commands,
|
2026-02-02 19:01:51 -08:00
|
|
|
sessions_dir=sessions_dir,
|
|
|
|
|
always_log_local=data.get("always_log_local", True),
|
2026-03-14 22:09:53 -07:00
|
|
|
stt_enabled=_coerce_bool(stt_enabled, True),
|
2026-03-16 00:22:23 -07:00
|
|
|
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
2026-03-18 04:06:08 -07:00
|
|
|
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
feat(gateway): streaming token delivery — StreamingConfig, GatewayStreamConsumer, already_sent
Stage 3 of streaming support. Gateway now streams tokens to messaging platforms:
- StreamingConfig dataclass (enabled, transport, edit_interval, buffer_threshold, cursor)
on GatewayConfig with from_dict/to_dict serialization
- GatewayStreamConsumer: async queue-based consumer that progressively edits
a single message on the target platform (edit transport)
- on_delta() → queue → run() async task → send_or_edit() with rate limiting
- already_sent propagation: when streaming delivered the response, handler
returns None so base adapter skips duplicate send()
- stream_delta_callback wired into AIAgent constructor in _run_agent
- Consumer lifecycle: started as asyncio task, awaited with timeout in finally
Config (config.yaml):
streaming:
enabled: true
transport: edit # progressive editMessageText
edit_interval: 0.3 # seconds between edits
buffer_threshold: 40 # chars before forcing flush
cursor: ' ▉'
Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
2026-03-16 05:52:42 -07:00
|
|
|
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
2026-02-02 19:01:51 -08:00
|
|
|
)
|
|
|
|
|
|
2026-03-18 04:06:08 -07:00
|
|
|
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
|
|
|
|
|
"""Return the effective unauthorized-DM behavior for a platform."""
|
|
|
|
|
if platform:
|
|
|
|
|
platform_cfg = self.platforms.get(platform)
|
|
|
|
|
if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra:
|
|
|
|
|
return _normalize_unauthorized_dm_behavior(
|
|
|
|
|
platform_cfg.extra.get("unauthorized_dm_behavior"),
|
|
|
|
|
self.unauthorized_dm_behavior,
|
|
|
|
|
)
|
|
|
|
|
return self.unauthorized_dm_behavior
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
def load_gateway_config() -> GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Load gateway configuration from multiple sources.
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
Priority (highest to lowest):
|
|
|
|
|
1. Environment variables
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
2. ~/.hermes/config.yaml (primary user-facing config)
|
|
|
|
|
3. ~/.hermes/gateway.json (legacy — provides defaults under config.yaml)
|
|
|
|
|
4. Built-in defaults
|
2026-02-02 19:01:51 -08:00
|
|
|
"""
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
_home = get_hermes_home()
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
gw_data: dict = {}
|
|
|
|
|
|
|
|
|
|
# Legacy fallback: gateway.json provides the base layer.
|
|
|
|
|
# config.yaml keys always win when both specify the same setting.
|
|
|
|
|
gateway_json_path = _home / "gateway.json"
|
|
|
|
|
if gateway_json_path.exists():
|
2026-02-02 19:01:51 -08:00
|
|
|
try:
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
with open(gateway_json_path, "r", encoding="utf-8") as f:
|
|
|
|
|
gw_data = json.load(f) or {}
|
|
|
|
|
logger.info(
|
|
|
|
|
"Loaded legacy %s — consider moving settings to config.yaml",
|
|
|
|
|
gateway_json_path,
|
|
|
|
|
)
|
2026-02-02 19:01:51 -08:00
|
|
|
except Exception as e:
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
logger.warning("Failed to load %s: %s", gateway_json_path, e)
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
# Primary source: config.yaml
|
2026-02-26 21:20:50 -08:00
|
|
|
try:
|
|
|
|
|
import yaml
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
config_yaml_path = _home / "config.yaml"
|
2026-02-26 21:20:50 -08:00
|
|
|
if config_yaml_path.exists():
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(config_yaml_path, encoding="utf-8") as f:
|
2026-02-26 21:20:50 -08:00
|
|
|
yaml_cfg = yaml.safe_load(f) or {}
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
|
|
|
|
|
# Map config.yaml keys → GatewayConfig.from_dict() schema.
|
|
|
|
|
# Each key overwrites whatever gateway.json may have set.
|
2026-02-26 21:20:50 -08:00
|
|
|
sr = yaml_cfg.get("session_reset")
|
|
|
|
|
if sr and isinstance(sr, dict):
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
gw_data["default_reset_policy"] = sr
|
2026-03-11 09:15:31 -07:00
|
|
|
|
2026-03-14 03:57:25 -07:00
|
|
|
qc = yaml_cfg.get("quick_commands")
|
|
|
|
|
if qc is not None:
|
|
|
|
|
if isinstance(qc, dict):
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
gw_data["quick_commands"] = qc
|
2026-03-14 03:57:25 -07:00
|
|
|
else:
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"Ignoring invalid quick_commands in config.yaml "
|
|
|
|
|
"(expected mapping, got %s)",
|
|
|
|
|
type(qc).__name__,
|
|
|
|
|
)
|
2026-03-14 03:57:25 -07:00
|
|
|
|
2026-03-14 22:09:53 -07:00
|
|
|
stt_cfg = yaml_cfg.get("stt")
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
if isinstance(stt_cfg, dict):
|
|
|
|
|
gw_data["stt"] = stt_cfg
|
2026-03-14 22:09:53 -07:00
|
|
|
|
2026-03-16 00:22:23 -07:00
|
|
|
if "group_sessions_per_user" in yaml_cfg:
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"]
|
2026-03-16 00:22:23 -07:00
|
|
|
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
streaming_cfg = yaml_cfg.get("streaming")
|
|
|
|
|
if isinstance(streaming_cfg, dict):
|
|
|
|
|
gw_data["streaming"] = streaming_cfg
|
|
|
|
|
|
|
|
|
|
if "reset_triggers" in yaml_cfg:
|
|
|
|
|
gw_data["reset_triggers"] = yaml_cfg["reset_triggers"]
|
|
|
|
|
|
|
|
|
|
if "always_log_local" in yaml_cfg:
|
|
|
|
|
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
|
|
|
|
|
|
2026-03-18 04:06:08 -07:00
|
|
|
if "unauthorized_dm_behavior" in yaml_cfg:
|
|
|
|
|
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
|
|
|
|
yaml_cfg.get("unauthorized_dm_behavior"),
|
|
|
|
|
"pair",
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-21 21:05:08 +07:00
|
|
|
# Merge platforms section from config.yaml into gw_data so that
|
|
|
|
|
# nested keys like platforms.webhook.extra.routes are loaded.
|
|
|
|
|
yaml_platforms = yaml_cfg.get("platforms")
|
2026-03-18 04:06:08 -07:00
|
|
|
platforms_data = gw_data.setdefault("platforms", {})
|
|
|
|
|
if not isinstance(platforms_data, dict):
|
|
|
|
|
platforms_data = {}
|
|
|
|
|
gw_data["platforms"] = platforms_data
|
2026-03-21 21:05:08 +07:00
|
|
|
if isinstance(yaml_platforms, dict):
|
|
|
|
|
for plat_name, plat_block in yaml_platforms.items():
|
|
|
|
|
if not isinstance(plat_block, dict):
|
|
|
|
|
continue
|
|
|
|
|
existing = platforms_data.get(plat_name, {})
|
|
|
|
|
if not isinstance(existing, dict):
|
|
|
|
|
existing = {}
|
|
|
|
|
# Deep-merge extra dicts so gateway.json defaults survive
|
|
|
|
|
merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
|
|
|
|
|
merged = {**existing, **plat_block}
|
|
|
|
|
if merged_extra:
|
|
|
|
|
merged["extra"] = merged_extra
|
|
|
|
|
platforms_data[plat_name] = merged
|
|
|
|
|
gw_data["platforms"] = platforms_data
|
2026-03-18 04:06:08 -07:00
|
|
|
for plat in Platform:
|
|
|
|
|
if plat == Platform.LOCAL:
|
|
|
|
|
continue
|
|
|
|
|
platform_cfg = yaml_cfg.get(plat.value)
|
|
|
|
|
if not isinstance(platform_cfg, dict):
|
|
|
|
|
continue
|
2026-03-18 04:18:33 -07:00
|
|
|
# Collect bridgeable keys from this platform section
|
|
|
|
|
bridged = {}
|
|
|
|
|
if "unauthorized_dm_behavior" in platform_cfg:
|
|
|
|
|
bridged["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
|
|
|
|
platform_cfg.get("unauthorized_dm_behavior"),
|
|
|
|
|
gw_data.get("unauthorized_dm_behavior", "pair"),
|
|
|
|
|
)
|
|
|
|
|
if "reply_prefix" in platform_cfg:
|
|
|
|
|
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
2026-03-29 21:53:59 -07:00
|
|
|
if "require_mention" in platform_cfg:
|
|
|
|
|
bridged["require_mention"] = platform_cfg["require_mention"]
|
|
|
|
|
if "mention_patterns" in platform_cfg:
|
|
|
|
|
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
2026-03-18 04:18:33 -07:00
|
|
|
if not bridged:
|
2026-03-18 04:06:08 -07:00
|
|
|
continue
|
|
|
|
|
plat_data = platforms_data.setdefault(plat.value, {})
|
|
|
|
|
if not isinstance(plat_data, dict):
|
|
|
|
|
plat_data = {}
|
|
|
|
|
platforms_data[plat.value] = plat_data
|
|
|
|
|
extra = plat_data.setdefault("extra", {})
|
|
|
|
|
if not isinstance(extra, dict):
|
|
|
|
|
extra = {}
|
|
|
|
|
plat_data["extra"] = extra
|
2026-03-18 04:18:33 -07:00
|
|
|
extra.update(bridged)
|
2026-03-18 04:06:08 -07:00
|
|
|
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
# Discord settings → env vars (env vars take precedence)
|
2026-03-11 09:15:31 -07:00
|
|
|
discord_cfg = yaml_cfg.get("discord", {})
|
|
|
|
|
if isinstance(discord_cfg, dict):
|
|
|
|
|
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
|
|
|
|
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
|
|
|
|
frc = discord_cfg.get("free_response_channels")
|
|
|
|
|
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
|
|
|
|
if isinstance(frc, list):
|
|
|
|
|
frc = ",".join(str(v) for v in frc)
|
|
|
|
|
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
2026-03-13 08:52:54 -07:00
|
|
|
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
|
|
|
|
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
2026-03-29 21:53:59 -07:00
|
|
|
|
|
|
|
|
# Telegram settings → env vars (env vars take precedence)
|
|
|
|
|
telegram_cfg = yaml_cfg.get("telegram", {})
|
|
|
|
|
if isinstance(telegram_cfg, dict):
|
|
|
|
|
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
|
|
|
|
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
|
|
|
|
|
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
|
|
|
|
import json as _json
|
|
|
|
|
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
|
|
|
|
|
frc = telegram_cfg.get("free_response_chats")
|
|
|
|
|
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
|
|
|
|
if isinstance(frc, list):
|
|
|
|
|
frc = ",".join(str(v) for v in frc)
|
|
|
|
|
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
2026-03-23 15:54:11 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Failed to process config.yaml — falling back to .env / gateway.json values. "
|
|
|
|
|
"Check %s for syntax errors. Error: %s",
|
|
|
|
|
_home / "config.yaml",
|
|
|
|
|
e,
|
|
|
|
|
)
|
2026-02-26 21:20:50 -08:00
|
|
|
|
fix: Telegram streaming — config bridge, not-modified, flood control (#1782)
* fix: NameError in OpenCode provider setup (prompt_text -> prompt)
The OpenCode Zen and OpenCode Go setup sections used prompt_text()
which is undefined. All other providers correctly use the local
prompt() function defined in setup.py. Fixes crash during
'hermes setup' when selecting either OpenCode provider.
* fix: Telegram streaming — config bridge, not-modified, flood control
Three fixes for gateway streaming:
1. Bridge streaming config from config.yaml into gateway runtime.
load_gateway_config() now reads the 'streaming' key from config.yaml
(same pattern as session_reset, stt, etc.), matching the docs.
Previously only gateway.json was read.
2. Handle 'Message is not modified' in Telegram edit_message().
This Telegram API error fires when editing with identical content —
a no-op, not a real failure. Previously it returned success=False
which made the stream consumer disable streaming entirely.
3. Handle RetryAfter / flood control in Telegram edit_message().
Fast providers can hit Telegram rate limits during streaming.
Now waits the requested retry_after duration and retries once,
instead of treating it as a fatal edit failure.
Also fixed double-edit on stream finish: the consumer now tracks
last-sent text and skips redundant edits, preventing the not-modified
error at the source.
* refactor: make config.yaml the primary gateway config source
Eliminates the per-key bridge pattern in load_gateway_config().
Previously gateway.json was the primary source and each config.yaml
key needed an individual bridge — easy to forget (streaming was
missing, causing garl4546's bug).
Now config.yaml is read first and its keys are mapped directly into
the GatewayConfig.from_dict() schema. gateway.json is kept as a
legacy fallback layer (loaded first, then overwritten by config.yaml
keys). If gateway.json exists, a log message suggests migrating.
Also:
- Removed dead save_gateway_config() (never called anywhere)
- Updated CLI help text and send_message error to reference
config.yaml instead of gateway.json
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:51:54 -07:00
|
|
|
config = GatewayConfig.from_dict(gw_data)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Override with environment variables
|
|
|
|
|
_apply_env_overrides(config)
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
# --- Validate loaded values ---
|
|
|
|
|
policy = config.default_reset_policy
|
|
|
|
|
|
|
|
|
|
if not (0 <= policy.at_hour <= 23):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour
|
|
|
|
|
)
|
|
|
|
|
policy.at_hour = 4
|
|
|
|
|
|
|
|
|
|
if policy.idle_minutes is None or policy.idle_minutes <= 0:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid idle_minutes=%s (must be positive). Using default 1440.",
|
|
|
|
|
policy.idle_minutes,
|
|
|
|
|
)
|
|
|
|
|
policy.idle_minutes = 1440
|
|
|
|
|
|
|
|
|
|
# Warn about empty bot tokens — platforms that loaded an empty string
|
|
|
|
|
# won't connect and the cause can be confusing without a log line.
|
|
|
|
|
_token_env_names = {
|
|
|
|
|
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
|
|
|
|
|
Platform.DISCORD: "DISCORD_BOT_TOKEN",
|
|
|
|
|
Platform.SLACK: "SLACK_BOT_TOKEN",
|
2026-03-17 02:59:36 -07:00
|
|
|
Platform.MATTERMOST: "MATTERMOST_TOKEN",
|
|
|
|
|
Platform.MATRIX: "MATRIX_ACCESS_TOKEN",
|
2026-02-22 02:16:11 -08:00
|
|
|
}
|
|
|
|
|
for platform, pconfig in config.platforms.items():
|
|
|
|
|
if not pconfig.enabled:
|
|
|
|
|
continue
|
|
|
|
|
env_name = _token_env_names.get(platform)
|
|
|
|
|
if env_name and pconfig.token is not None and not pconfig.token.strip():
|
|
|
|
|
logger.warning(
|
|
|
|
|
"%s is enabled but %s is empty. "
|
|
|
|
|
"The adapter will likely fail to connect.",
|
|
|
|
|
platform.value, env_name,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_env_overrides(config: GatewayConfig) -> None:
|
|
|
|
|
"""Apply environment variable overrides to config."""
|
|
|
|
|
|
|
|
|
|
# Telegram
|
|
|
|
|
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
|
|
|
if telegram_token:
|
|
|
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.TELEGRAM].enabled = True
|
|
|
|
|
config.platforms[Platform.TELEGRAM].token = telegram_token
|
|
|
|
|
|
2026-03-24 19:56:00 -07:00
|
|
|
# Reply threading mode for Telegram (off/first/all)
|
|
|
|
|
telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower()
|
|
|
|
|
if telegram_reply_mode in ("off", "first", "all"):
|
|
|
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode
|
|
|
|
|
|
2026-03-27 04:03:13 -07:00
|
|
|
telegram_fallback_ips = os.getenv("TELEGRAM_FALLBACK_IPS", "")
|
|
|
|
|
if telegram_fallback_ips:
|
|
|
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.TELEGRAM].extra["fallback_ips"] = [
|
|
|
|
|
ip.strip() for ip in telegram_fallback_ips.split(",") if ip.strip()
|
|
|
|
|
]
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
|
|
|
|
|
if telegram_home and Platform.TELEGRAM in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.TELEGRAM,
|
|
|
|
|
chat_id=telegram_home,
|
|
|
|
|
name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Discord
|
|
|
|
|
discord_token = os.getenv("DISCORD_BOT_TOKEN")
|
|
|
|
|
if discord_token:
|
|
|
|
|
if Platform.DISCORD not in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.DISCORD].enabled = True
|
|
|
|
|
config.platforms[Platform.DISCORD].token = discord_token
|
|
|
|
|
|
|
|
|
|
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
|
|
|
|
|
if discord_home and Platform.DISCORD in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.DISCORD,
|
|
|
|
|
chat_id=discord_home,
|
|
|
|
|
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# WhatsApp (typically uses different auth mechanism)
|
|
|
|
|
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
if whatsapp_enabled:
|
|
|
|
|
if Platform.WHATSAPP not in config.platforms:
|
|
|
|
|
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.WHATSAPP].enabled = True
|
|
|
|
|
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
# Slack
|
|
|
|
|
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
|
|
|
|
if slack_token:
|
|
|
|
|
if Platform.SLACK not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SLACK] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SLACK].enabled = True
|
|
|
|
|
config.platforms[Platform.SLACK].token = slack_token
|
2026-03-29 15:48:51 -07:00
|
|
|
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
|
|
|
|
if slack_home and Platform.SLACK in config.platforms:
|
|
|
|
|
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SLACK,
|
|
|
|
|
chat_id=slack_home,
|
|
|
|
|
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
|
|
|
|
)
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
# Signal
|
|
|
|
|
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
|
|
|
|
signal_account = os.getenv("SIGNAL_ACCOUNT")
|
|
|
|
|
if signal_url and signal_account:
|
|
|
|
|
if Platform.SIGNAL not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SIGNAL] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SIGNAL].enabled = True
|
|
|
|
|
config.platforms[Platform.SIGNAL].extra.update({
|
|
|
|
|
"http_url": signal_url,
|
|
|
|
|
"account": signal_account,
|
|
|
|
|
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
|
|
|
|
})
|
2026-03-29 15:48:51 -07:00
|
|
|
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
|
|
|
|
if signal_home and Platform.SIGNAL in config.platforms:
|
|
|
|
|
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SIGNAL,
|
|
|
|
|
chat_id=signal_home,
|
|
|
|
|
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
|
2026-03-17 02:59:36 -07:00
|
|
|
# Mattermost
|
|
|
|
|
mattermost_token = os.getenv("MATTERMOST_TOKEN")
|
|
|
|
|
if mattermost_token:
|
|
|
|
|
mattermost_url = os.getenv("MATTERMOST_URL", "")
|
|
|
|
|
if not mattermost_url:
|
|
|
|
|
logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing")
|
|
|
|
|
if Platform.MATTERMOST not in config.platforms:
|
|
|
|
|
config.platforms[Platform.MATTERMOST] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.MATTERMOST].enabled = True
|
|
|
|
|
config.platforms[Platform.MATTERMOST].token = mattermost_token
|
|
|
|
|
config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url
|
2026-03-29 15:48:51 -07:00
|
|
|
mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
|
|
|
|
|
if mattermost_home and Platform.MATTERMOST in config.platforms:
|
|
|
|
|
config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.MATTERMOST,
|
|
|
|
|
chat_id=mattermost_home,
|
|
|
|
|
name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
2026-03-17 02:59:36 -07:00
|
|
|
|
|
|
|
|
# Matrix
|
|
|
|
|
matrix_token = os.getenv("MATRIX_ACCESS_TOKEN")
|
|
|
|
|
matrix_homeserver = os.getenv("MATRIX_HOMESERVER", "")
|
|
|
|
|
if matrix_token or os.getenv("MATRIX_PASSWORD"):
|
|
|
|
|
if not matrix_homeserver:
|
|
|
|
|
logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing")
|
|
|
|
|
if Platform.MATRIX not in config.platforms:
|
|
|
|
|
config.platforms[Platform.MATRIX] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.MATRIX].enabled = True
|
|
|
|
|
if matrix_token:
|
|
|
|
|
config.platforms[Platform.MATRIX].token = matrix_token
|
|
|
|
|
config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver
|
|
|
|
|
matrix_user = os.getenv("MATRIX_USER_ID", "")
|
|
|
|
|
if matrix_user:
|
|
|
|
|
config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user
|
|
|
|
|
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
|
|
|
|
if matrix_password:
|
|
|
|
|
config.platforms[Platform.MATRIX].extra["password"] = matrix_password
|
|
|
|
|
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
|
|
|
|
config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
|
2026-03-29 15:48:51 -07:00
|
|
|
matrix_home = os.getenv("MATRIX_HOME_ROOM")
|
|
|
|
|
if matrix_home and Platform.MATRIX in config.platforms:
|
|
|
|
|
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.MATRIX,
|
|
|
|
|
chat_id=matrix_home,
|
|
|
|
|
name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"),
|
|
|
|
|
)
|
2026-03-17 02:59:36 -07:00
|
|
|
|
2026-02-28 13:32:48 +03:00
|
|
|
# Home Assistant
|
|
|
|
|
hass_token = os.getenv("HASS_TOKEN")
|
|
|
|
|
if hass_token:
|
|
|
|
|
if Platform.HOMEASSISTANT not in config.platforms:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].enabled = True
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].token = hass_token
|
|
|
|
|
hass_url = os.getenv("HASS_URL")
|
|
|
|
|
if hass_url:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url
|
|
|
|
|
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
# Email
|
|
|
|
|
email_addr = os.getenv("EMAIL_ADDRESS")
|
|
|
|
|
email_pwd = os.getenv("EMAIL_PASSWORD")
|
|
|
|
|
email_imap = os.getenv("EMAIL_IMAP_HOST")
|
|
|
|
|
email_smtp = os.getenv("EMAIL_SMTP_HOST")
|
|
|
|
|
if all([email_addr, email_pwd, email_imap, email_smtp]):
|
|
|
|
|
if Platform.EMAIL not in config.platforms:
|
|
|
|
|
config.platforms[Platform.EMAIL] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.EMAIL].enabled = True
|
|
|
|
|
config.platforms[Platform.EMAIL].extra.update({
|
|
|
|
|
"address": email_addr,
|
|
|
|
|
"imap_host": email_imap,
|
|
|
|
|
"smtp_host": email_smtp,
|
|
|
|
|
})
|
2026-03-29 15:48:51 -07:00
|
|
|
email_home = os.getenv("EMAIL_HOME_ADDRESS")
|
|
|
|
|
if email_home and Platform.EMAIL in config.platforms:
|
|
|
|
|
config.platforms[Platform.EMAIL].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.EMAIL,
|
|
|
|
|
chat_id=email_home,
|
|
|
|
|
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
|
|
|
|
)
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
# SMS (Twilio)
|
|
|
|
|
twilio_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
|
|
|
|
if twilio_sid:
|
|
|
|
|
if Platform.SMS not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SMS] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SMS].enabled = True
|
|
|
|
|
config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "")
|
2026-03-29 15:48:51 -07:00
|
|
|
sms_home = os.getenv("SMS_HOME_CHANNEL")
|
|
|
|
|
if sms_home and Platform.SMS in config.platforms:
|
|
|
|
|
config.platforms[Platform.SMS].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SMS,
|
|
|
|
|
chat_id=sms_home,
|
|
|
|
|
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API.
Shares credentials with the existing telephony skill — same
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars.
Adapter (gateway/platforms/sms.py):
- aiohttp webhook server for inbound (Twilio form-encoded POSTs)
- Twilio REST API with Basic auth for outbound
- Markdown stripping, smart chunking at 1600 chars
- Echo loop prevention, phone number redaction in logs
Integration (13 files):
- gateway config, run, channel_directory
- agent prompt_builder (SMS platform hint)
- cron scheduler, cronjob tools
- send_message_tool (_send_sms via Twilio API)
- toolsets (hermes-sms + hermes-gateway)
- gateway setup wizard, status display
- pyproject.toml (sms optional extra)
- 21 tests
Docs:
- website/docs/user-guide/messaging/sms.md (full setup guide)
- Updated messaging index (architecture, toolsets, security, links)
- Updated environment-variables.md reference
Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
2026-03-17 03:14:53 -07:00
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
# API Server
|
|
|
|
|
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
api_server_key = os.getenv("API_SERVER_KEY", "")
|
2026-03-22 04:08:48 -07:00
|
|
|
api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "")
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
api_server_port = os.getenv("API_SERVER_PORT")
|
|
|
|
|
api_server_host = os.getenv("API_SERVER_HOST")
|
|
|
|
|
if api_server_enabled or api_server_key:
|
|
|
|
|
if Platform.API_SERVER not in config.platforms:
|
|
|
|
|
config.platforms[Platform.API_SERVER] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.API_SERVER].enabled = True
|
|
|
|
|
if api_server_key:
|
|
|
|
|
config.platforms[Platform.API_SERVER].extra["key"] = api_server_key
|
2026-03-22 04:08:48 -07:00
|
|
|
if api_server_cors_origins:
|
|
|
|
|
origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()]
|
|
|
|
|
if origins:
|
|
|
|
|
config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
if api_server_port:
|
|
|
|
|
try:
|
|
|
|
|
config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
if api_server_host:
|
|
|
|
|
config.platforms[Platform.API_SERVER].extra["host"] = api_server_host
|
|
|
|
|
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
# Webhook platform
|
|
|
|
|
webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
webhook_port = os.getenv("WEBHOOK_PORT")
|
|
|
|
|
webhook_secret = os.getenv("WEBHOOK_SECRET", "")
|
|
|
|
|
if webhook_enabled:
|
|
|
|
|
if Platform.WEBHOOK not in config.platforms:
|
|
|
|
|
config.platforms[Platform.WEBHOOK] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.WEBHOOK].enabled = True
|
|
|
|
|
if webhook_port:
|
|
|
|
|
try:
|
|
|
|
|
config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
if webhook_secret:
|
|
|
|
|
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
|
|
|
|
|
feat(gateway): add Feishu/Lark platform support (#3817)
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway
platform adapter with full feature parity: WebSocket + webhook transports,
message batching, dedup, rate limiting, rich post/card content parsing,
media handling (images/audio/files/video), group @mention gating,
reaction routing, and interactive card button support.
Cherry-picked from PR #1793 by penwyp with:
- Moved to current main (PR was 458 commits behind)
- Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to
_feishu_send_with_retry to avoid signature mismatch crash)
- Fixed import structure: aiohttp/websockets imported independently of
lark_oapi so they remain available when SDK is missing
- Fixed get_hermes_home import (hermes_constants, not hermes_cli.config)
- Added skip decorators for tests requiring lark_oapi SDK
- All 16 integration points added surgically to current main
New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu])
Fixes #1788
Co-authored-by: penwyp <penwyp@users.noreply.github.com>
2026-03-29 18:17:42 -07:00
|
|
|
# Feishu / Lark
|
|
|
|
|
feishu_app_id = os.getenv("FEISHU_APP_ID")
|
|
|
|
|
feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
|
|
|
|
|
if feishu_app_id and feishu_app_secret:
|
|
|
|
|
if Platform.FEISHU not in config.platforms:
|
|
|
|
|
config.platforms[Platform.FEISHU] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.FEISHU].enabled = True
|
|
|
|
|
config.platforms[Platform.FEISHU].extra.update({
|
|
|
|
|
"app_id": feishu_app_id,
|
|
|
|
|
"app_secret": feishu_app_secret,
|
|
|
|
|
"domain": os.getenv("FEISHU_DOMAIN", "feishu"),
|
|
|
|
|
"connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
|
|
|
|
|
})
|
|
|
|
|
feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
|
|
|
|
|
if feishu_encrypt_key:
|
|
|
|
|
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
|
|
|
|
|
feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
|
|
|
|
if feishu_verification_token:
|
|
|
|
|
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
|
|
|
|
|
feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
|
|
|
|
|
if feishu_home:
|
|
|
|
|
config.platforms[Platform.FEISHU].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.FEISHU,
|
|
|
|
|
chat_id=feishu_home,
|
|
|
|
|
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-29 21:29:13 -07:00
|
|
|
# WeCom (Enterprise WeChat)
|
|
|
|
|
wecom_bot_id = os.getenv("WECOM_BOT_ID")
|
|
|
|
|
wecom_secret = os.getenv("WECOM_SECRET")
|
|
|
|
|
if wecom_bot_id and wecom_secret:
|
|
|
|
|
if Platform.WECOM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.WECOM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.WECOM].enabled = True
|
|
|
|
|
config.platforms[Platform.WECOM].extra.update({
|
|
|
|
|
"bot_id": wecom_bot_id,
|
|
|
|
|
"secret": wecom_secret,
|
|
|
|
|
})
|
|
|
|
|
wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
|
|
|
|
|
if wecom_ws_url:
|
|
|
|
|
config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
|
|
|
|
|
wecom_home = os.getenv("WECOM_HOME_CHANNEL")
|
|
|
|
|
if wecom_home:
|
|
|
|
|
config.platforms[Platform.WECOM].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.WECOM,
|
|
|
|
|
chat_id=wecom_home,
|
|
|
|
|
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Session settings
|
|
|
|
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
|
|
|
|
if idle_minutes:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.idle_minutes = int(idle_minutes)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
reset_hour = os.getenv("SESSION_RESET_HOUR")
|
|
|
|
|
if reset_hour:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.at_hour = int(reset_hour)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|