diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 194f3796b3c..cf9c534decd 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -259,6 +259,114 @@ def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: return json.dumps(shrunken, ensure_ascii=False) +_IMAGE_PART_TYPES = frozenset({"image_url", "input_image", "image"}) + + +def _is_image_part(part: Any) -> bool: + """True if ``part`` is a multimodal image content block. + + Recognizes all three shapes the agent handles: + - OpenAI chat.completions: ``{"type": "image_url", "image_url": ...}`` + - OpenAI Responses API: ``{"type": "input_image", "image_url": "..."}`` + - Anthropic native: ``{"type": "image", "source": {...}}`` + """ + if not isinstance(part, dict): + return False + return part.get("type") in _IMAGE_PART_TYPES + + +def _content_has_images(content: Any) -> bool: + """True if a message's ``content`` is a multimodal list with image parts.""" + if not isinstance(content, list): + return False + return any(_is_image_part(p) for p in content) + + +def _strip_images_from_content(content: Any) -> Any: + """Return a copy of ``content`` with every image part replaced by a + short text placeholder. + + - String content is returned unchanged. + - Non-list, non-string content is returned unchanged. + - List content: image parts become ``{"type": "text", "text": "[Attached + image — stripped after compression]"}``; other parts are preserved as-is. + + Input is never mutated. + """ + if not isinstance(content, list): + return content + if not any(_is_image_part(p) for p in content): + return content + + new_parts: List[Any] = [] + for p in content: + if _is_image_part(p): + new_parts.append({ + "type": "text", + "text": "[Attached image — stripped after compression]", + }) + else: + new_parts.append(p) + return new_parts + + +def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Replace image parts in older messages with placeholder text. + + The anchor is the *last* user message that has any image content. Every + message before that anchor gets its image parts replaced with a short + placeholder so the outgoing request stops re-shipping the same multi-MB + base-64 image blobs on every turn. + + If no user message carries images, the list is returned unchanged. + If the only user message with images is the very first one (nothing + earlier to strip), the list is returned unchanged. + + Shallow copies of touched messages only; input is never mutated. + Port of Kilo-Org/kilocode#9434 (adapted for the OpenAI-style message + shape the hermes compressor emits). + """ + if not messages: + return messages + + # Find the newest user message that carries at least one image part. + # We anchor on image-bearing user messages (not all user messages) so + # a plain text follow-up after a big-image turn still strips the old + # image — matching the problem kilocode#9434 set out to solve. + anchor = -1 + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if not isinstance(msg, dict): + continue + if msg.get("role") != "user": + continue + if _content_has_images(msg.get("content")): + anchor = i + break + + if anchor <= 0: + # No image-bearing user message, or it's the very first message — + # nothing before it to strip. + return messages + + changed = False + result: List[Dict[str, Any]] = [] + for i, msg in enumerate(messages): + if i >= anchor or not isinstance(msg, dict): + result.append(msg) + continue + content = msg.get("content") + if not _content_has_images(content): + result.append(msg) + continue + new_msg = msg.copy() + new_msg["content"] = _strip_images_from_content(content) + result.append(new_msg) + changed = True + + return result if changed else messages + + def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str: """Create an informative 1-line summary of a tool call + result. @@ -410,6 +518,10 @@ class ContextCompressor(ContextEngine): self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session + self.last_real_prompt_tokens = 0 + self.last_compression_rough_tokens = 0 + self.last_rough_tokens_when_real_prompt_fit = 0 + self.awaiting_real_usage_after_compression = False def update_model( self, @@ -507,6 +619,10 @@ class ContextCompressor(ContextEngine): self.last_prompt_tokens = 0 self.last_completion_tokens = 0 + self.last_real_prompt_tokens = 0 + self.last_compression_rough_tokens = 0 + self.last_rough_tokens_when_real_prompt_fit = 0 + self.awaiting_real_usage_after_compression = False self.summary_model = summary_model_override or "" @@ -540,6 +656,44 @@ class ContextCompressor(ContextEngine): self.last_prompt_tokens = usage.get("prompt_tokens", 0) self.last_completion_tokens = usage.get("completion_tokens", 0) self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens) + if self.last_prompt_tokens > 0: + self.last_real_prompt_tokens = self.last_prompt_tokens + if self.last_prompt_tokens < self.threshold_tokens: + if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0: + self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens + else: + self.last_rough_tokens_when_real_prompt_fit = 0 + self.awaiting_real_usage_after_compression = False + + def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool: + """Return True when a high rough preflight estimate is known-noisy. + + ``estimate_request_tokens_rough(..., tools=...)`` intentionally + overestimates schema-heavy requests so Hermes compresses before a + provider rejects the payload. After a successful compressed API call, + though, provider ``prompt_tokens`` are a better signal than repeating + compaction from the same rough schema overhead. Defer only while the + rough estimate has grown modestly since a request the provider proved + fit under the threshold. + """ + if rough_tokens < self.threshold_tokens: + return False + if self.last_real_prompt_tokens <= 0: + return False + if self.last_real_prompt_tokens >= self.threshold_tokens: + return False + + baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens + if baseline <= 0: + return False + + growth = max(0, rough_tokens - baseline) + tolerated_growth = max(4096, int(self.threshold_tokens * 0.05)) + if growth > tolerated_growth: + return False + + self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens) + return True def should_compress(self, prompt_tokens: int = None) -> bool: """Check if context exceeds the compression threshold. @@ -1837,6 +1991,14 @@ The user has requested that this compaction PRIORITISE preserving all informatio compressed = self._sanitize_tool_pairs(compressed) + # Replace image parts in all compressed messages before the newest + # image-bearing user turn with a short text placeholder. Without + # this, tail messages keep their original multi-MB base-64 image + # payloads forever, which can push every subsequent API request + # past the provider's body-size limit and wedge the session. + # Port of Kilo-Org/kilocode#9434. + compressed = _strip_historical_media(compressed) + new_estimate = estimate_messages_tokens_rough(compressed) saved_estimate = display_tokens - new_estimate diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b3d98ad9a80..a2db37be20c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -218,8 +218,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"), # Exit - CommandDef("quit", "Exit the CLI", "Exit", - cli_only=True, aliases=("exit",)), + CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit", + cli_only=True, aliases=("exit",), args_hint="[--delete]"), ] diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bed5f7043d2..40dd9e2efff 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10,6 +10,8 @@ Usage: """ import asyncio +import base64 +import binascii import hmac import importlib.util import json @@ -19,6 +21,7 @@ import secrets import stat import subprocess import sys +import tempfile import threading import time import urllib.parse @@ -500,6 +503,55 @@ class EnvVarReveal(BaseModel): key: str +class MessagingPlatformUpdate(BaseModel): + enabled: Optional[bool] = None + env: Dict[str, str] = {} + clear_env: List[str] = [] + + +class AudioTranscriptionRequest(BaseModel): + data_url: str + mime_type: Optional[str] = None + + +class ModelAssignment(BaseModel): + """Payload for POST /api/model/set — assign a provider/model to a slot. + + scope="main" → writes model.provider + model.default + scope="auxiliary" → writes auxiliary..provider + auxiliary..model + scope="auxiliary" with task="" → applied to every auxiliary.* slot + scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" + """ + + scope: str + provider: str + model: str + task: str = "" + + +_AUDIO_MIME_EXTENSIONS: Dict[str, str] = { + "audio/aac": ".aac", + "audio/flac": ".flac", + "audio/m4a": ".m4a", + "audio/mp3": ".mp3", + "audio/mp4": ".mp4", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/wave": ".wav", + "audio/webm": ".webm", + "audio/x-m4a": ".m4a", + "audio/x-wav": ".wav", + "video/webm": ".webm", +} +_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 + + +def _audio_extension_for_mime(mime_type: str) -> str: + normalized = (mime_type or "").split(";", 1)[0].strip().lower() + return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm") + + class ModelAssignment(BaseModel): """Payload for POST /api/model/set — assign a provider/model to a slot. @@ -798,6 +850,80 @@ async def update_hermes(): } +@app.post("/api/audio/transcribe") +async def transcribe_audio_upload(payload: AudioTranscriptionRequest): + data_url = (payload.data_url or "").strip() + if not data_url.startswith("data:") or "," not in data_url: + raise HTTPException(status_code=400, detail="Invalid audio payload") + + header, encoded = data_url.split(",", 1) + if ";base64" not in header: + raise HTTPException( + status_code=400, detail="Audio payload must be base64 encoded" + ) + + mime_type = ( + payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm" + ).strip() + normalized_mime_type = mime_type.split(";", 1)[0].lower() + if not ( + normalized_mime_type.startswith("audio/") + or normalized_mime_type == "video/webm" + ): + raise HTTPException( + status_code=400, detail="Payload must be an audio recording" + ) + + try: + audio_bytes = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="Audio payload is not valid base64") + + if not audio_bytes: + raise HTTPException(status_code=400, detail="Audio recording is empty") + if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Audio recording is too large") + + temp_path = "" + try: + suffix = _audio_extension_for_mime(mime_type) + with tempfile.NamedTemporaryFile( + prefix="hermes-desktop-voice-", + suffix=suffix, + delete=False, + ) as tmp: + tmp.write(audio_bytes) + temp_path = tmp.name + + from tools.transcription_tools import transcribe_audio + + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, transcribe_audio, temp_path) + except HTTPException: + raise + except Exception as exc: + _log.exception("Desktop voice transcription failed") + raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}") + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + pass + + if not result.get("success"): + raise HTTPException( + status_code=400, + detail=result.get("error") or "Transcription failed", + ) + + return { + "ok": True, + "transcript": str(result.get("transcript") or "").strip(), + "provider": result.get("provider"), + } + + @app.get("/api/actions/{name}/status") async def get_action_status(name: str, lines: int = 200): """Tail an action log and report whether the process is still running.""" @@ -1332,6 +1458,667 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): return {"key": body.key, "value": value} +# Entries omit fields they don't need to override; the catalog builder fills +# in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified, +# and pulls required_env from a plugin's PlatformEntry when available. +_PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { + "telegram": { + "name": "Telegram", + "description": "Run Hermes from Telegram DMs, groups, and topics.", + "docs_url": "https://core.telegram.org/bots/features#botfather", + "env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"), + "required_env": ("TELEGRAM_BOT_TOKEN",), + }, + "discord": { + "name": "Discord", + "description": "Connect Hermes to Discord DMs, channels, and threads.", + "docs_url": "https://discord.com/developers/applications", + "env_vars": ( + "DISCORD_BOT_TOKEN", + "DISCORD_ALLOWED_USERS", + "DISCORD_REPLY_TO_MODE", + ), + "required_env": ("DISCORD_BOT_TOKEN",), + }, + "slack": { + "name": "Slack", + "description": "Use Hermes from Slack via Socket Mode.", + "docs_url": "https://api.slack.com/apps", + "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + }, + "mattermost": { + "name": "Mattermost", + "description": "Connect Hermes to Mattermost channels and direct messages.", + "docs_url": "https://mattermost.com/deploy/", + "env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"), + "required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"), + }, + "matrix": { + "name": "Matrix", + "description": "Use Hermes in Matrix rooms and direct messages.", + "docs_url": "https://matrix.org/ecosystem/servers/", + "env_vars": ( + "MATRIX_HOMESERVER", + "MATRIX_ACCESS_TOKEN", + "MATRIX_USER_ID", + "MATRIX_ALLOWED_USERS", + ), + "required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"), + }, + "signal": { + "name": "Signal", + "description": "Connect through a signal-cli REST bridge.", + "docs_url": "https://github.com/bbernhard/signal-cli-rest-api", + "env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"), + "required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"), + }, + "whatsapp": { + "name": "WhatsApp", + "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", + "docs_url": "https://github.com/tulir/whatsmeow", + "env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"), + "required_env": (), + }, + "homeassistant": { + "name": "Home Assistant", + "description": "Control your smart home from Hermes via Home Assistant.", + "docs_url": "https://www.home-assistant.io/docs/authentication/", + "env_vars": ("HASS_URL", "HASS_TOKEN"), + "required_env": ("HASS_URL", "HASS_TOKEN"), + }, + "email": { + "name": "Email", + "description": "Talk to Hermes through an IMAP/SMTP mailbox.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", + "env_vars": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), + "required_env": ( + "EMAIL_ADDRESS", + "EMAIL_PASSWORD", + "EMAIL_IMAP_HOST", + "EMAIL_SMTP_HOST", + ), + }, + "sms": { + "name": "SMS (Twilio)", + "description": "Send and receive text messages via Twilio.", + "docs_url": "https://www.twilio.com/console", + "env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), + "required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), + }, + "dingtalk": { + "name": "DingTalk", + "description": "Connect Hermes to DingTalk groups (钉钉).", + "docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process", + "env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + "required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + }, + "feishu": { + "name": "Feishu / Lark", + "description": "Use Hermes inside Feishu / Lark.", + "docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro", + "env_vars": ( + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_ENCRYPT_KEY", + "FEISHU_VERIFICATION_TOKEN", + ), + "required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), + }, + "wecom": { + "name": "WeCom (group bot)", + "description": "Send-only WeCom group bot via webhook.", + "docs_url": "https://developer.work.weixin.qq.com/document/path/91770", + "env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"), + "required_env": ("WECOM_BOT_ID",), + }, + "wecom_callback": { + "name": "WeCom (app)", + "description": "Two-way WeCom integration via callback app.", + "docs_url": "https://developer.work.weixin.qq.com/document/path/90930", + "env_vars": ( + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + "WECOM_CALLBACK_TOKEN", + "WECOM_CALLBACK_ENCODING_AES_KEY", + ), + "required_env": ( + "WECOM_CALLBACK_CORP_ID", + "WECOM_CALLBACK_CORP_SECRET", + "WECOM_CALLBACK_AGENT_ID", + ), + }, + "weixin": { + "name": "WeChat (Official Account)", + "description": "Connect a WeChat Official Account.", + "docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html", + "env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"), + "required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"), + }, + "bluebubbles": { + "name": "BlueBubbles (iMessage)", + "description": "Use Hermes through iMessage via a BlueBubbles server.", + "docs_url": "https://bluebubbles.app/", + "env_vars": ( + "BLUEBUBBLES_SERVER_URL", + "BLUEBUBBLES_PASSWORD", + "BLUEBUBBLES_ALLOWED_USERS", + ), + "required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), + }, + "qqbot": { + "name": "QQ Bot", + "description": "Connect Hermes to a QQ Bot from the QQ Open Platform.", + "docs_url": "https://q.qq.com", + "env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"), + "required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"), + }, + "yuanbao": { + "name": "Yuanbao (元宝)", + "description": "Connect Hermes to Tencent Yuanbao.", + "docs_url": "", + "required_env": (), + }, + "api_server": { + "name": "API server", + "description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", + "env_vars": ( + "API_SERVER_ENABLED", + "API_SERVER_KEY", + "API_SERVER_PORT", + "API_SERVER_HOST", + "API_SERVER_MODEL_NAME", + ), + "required_env": (), + }, + "webhook": { + "name": "Webhooks", + "description": "Receive events from GitHub, GitLab, and other webhook sources.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/", + "env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), + "required_env": (), + }, +} + +# Display order: well-known platforms surface first; unknown plugins fall to +# the end alphabetically. +_PLATFORM_ORDER: tuple[str, ...] = ( + "telegram", + "discord", + "slack", + "mattermost", + "matrix", + "whatsapp", + "signal", + "bluebubbles", + "homeassistant", + "email", + "sms", + "dingtalk", + "feishu", + "wecom", + "wecom_callback", + "weixin", + "qqbot", + "yuanbao", + "api_server", + "webhook", +) + +# Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge +# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS +# falls back here so the UI can still render a friendly label. +_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { + "SIGNAL_HTTP_URL": { + "description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080", + "prompt": "Signal bridge URL", + "url": "https://github.com/bbernhard/signal-cli-rest-api", + }, + "SIGNAL_ACCOUNT": { + "description": "Signal account phone number registered with the bridge", + "prompt": "Signal account", + }, + "SIGNAL_ALLOWED_USERS": { + "description": "Comma-separated Signal users allowed to use the bot", + "prompt": "Allowed Signal users", + }, + "WHATSAPP_ENABLED": { + "description": "Enable the WhatsApp gateway adapter", + "prompt": "Enable WhatsApp", + "advanced": True, + }, + "WHATSAPP_MODE": { + "description": "WhatsApp bridge mode", + "prompt": "WhatsApp mode", + "advanced": True, + }, + "WHATSAPP_ALLOWED_USERS": { + "description": "Comma-separated WhatsApp users allowed to use the bot", + "prompt": "Allowed WhatsApp users", + }, + "HASS_URL": { + "description": "Home Assistant base URL, e.g. https://homeassistant.local:8123", + "prompt": "Home Assistant URL", + }, + "HASS_TOKEN": { + "description": "Long-lived access token from Home Assistant (Profile → Security)", + "prompt": "Home Assistant access token", + "password": True, + }, + "EMAIL_ADDRESS": { + "description": "Email address to send and receive from", + "prompt": "Email address", + }, + "EMAIL_PASSWORD": { + "description": "Email account password or app password", + "prompt": "Email password", + "password": True, + }, + "EMAIL_IMAP_HOST": { + "description": "IMAP server host (e.g. imap.gmail.com)", + "prompt": "IMAP host", + }, + "EMAIL_SMTP_HOST": { + "description": "SMTP server host (e.g. smtp.gmail.com)", + "prompt": "SMTP host", + }, + "TWILIO_ACCOUNT_SID": { + "description": "Twilio Account SID", + "prompt": "Twilio Account SID", + "url": "https://www.twilio.com/console", + }, + "TWILIO_AUTH_TOKEN": { + "description": "Twilio Auth Token", + "prompt": "Twilio Auth Token", + "password": True, + }, + "WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"}, + "WECOM_SECRET": { + "description": "WeCom group bot secret", + "prompt": "WeCom Secret", + "password": True, + }, + "WECOM_CALLBACK_CORP_ID": { + "description": "WeCom corp ID", + "prompt": "WeCom Corp ID", + }, + "WECOM_CALLBACK_CORP_SECRET": { + "description": "WeCom app corp secret", + "prompt": "WeCom Corp Secret", + "password": True, + }, + "WECOM_CALLBACK_AGENT_ID": { + "description": "WeCom app agent ID", + "prompt": "WeCom Agent ID", + }, + "WECOM_CALLBACK_TOKEN": { + "description": "WeCom callback verification token", + "prompt": "WeCom Token", + }, + "WECOM_CALLBACK_ENCODING_AES_KEY": { + "description": "WeCom callback AES encoding key", + "prompt": "WeCom AES Key", + "password": True, + }, + "WEIXIN_ACCOUNT_ID": { + "description": "WeChat Official Account ID", + "prompt": "Account ID", + }, + "WEIXIN_TOKEN": { + "description": "WeChat callback token", + "prompt": "Token", + "password": True, + }, + "WEIXIN_BASE_URL": { + "description": "WeChat platform base URL", + "prompt": "Base URL", + }, + "FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"}, + "FEISHU_APP_SECRET": { + "description": "Feishu / Lark app secret", + "prompt": "App secret", + "password": True, + }, + "FEISHU_ENCRYPT_KEY": { + "description": "Feishu / Lark encrypt key", + "prompt": "Encrypt key", + "password": True, + }, + "FEISHU_VERIFICATION_TOKEN": { + "description": "Feishu / Lark verification token", + "prompt": "Verification token", + "password": True, + }, + "DINGTALK_CLIENT_ID": { + "description": "DingTalk client ID (App key)", + "prompt": "Client ID", + }, + "DINGTALK_CLIENT_SECRET": { + "description": "DingTalk client secret (App secret)", + "prompt": "Client secret", + "password": True, + }, +} + + +def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: + """Build the messaging catalog from the gateway's Platform enum + plugin registry. + + Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded). + Plugin platforms come from ``gateway.platform_registry.plugin_entries()``, + which lets newly installed adapters (e.g. IRC) appear without a code change + here. Per-platform UI metadata (description, docs URL, env-var picks) lives + in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable + defaults derived from the platform id and required_env. + """ + from gateway.config import Platform + + seen: set[str] = set() + entries: list[dict[str, Any]] = [] + + for member in Platform.__members__.values(): + if member.value == "local": + continue + if member.value in seen: + continue + seen.add(member.value) + entries.append(_build_catalog_entry(member.value)) + + try: + from gateway.platform_registry import platform_registry + + for plugin_entry in platform_registry.plugin_entries(): + if plugin_entry.name in seen: + continue + seen.add(plugin_entry.name) + entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry)) + except Exception: + _log.debug("plugin platform registry unavailable", exc_info=True) + + order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} + entries.sort( + key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()) + ) + return tuple(entries) + + +def _build_catalog_entry( + platform_id: str, plugin_entry: Any | None = None +) -> dict[str, Any]: + override = _PLATFORM_OVERRIDES.get(platform_id, {}) + + if "env_vars" in override: + env_vars: tuple[str, ...] = tuple(override["env_vars"]) + elif plugin_entry is not None and plugin_entry.required_env: + env_vars = tuple(plugin_entry.required_env) + else: + prefix = platform_id.upper() + "_" + env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) + + if "required_env" in override: + required_env = tuple(override["required_env"]) + elif plugin_entry is not None: + required_env = tuple(plugin_entry.required_env or ()) + else: + required_env = () + + if override.get("name"): + name = override["name"] + elif plugin_entry is not None and plugin_entry.label: + name = plugin_entry.label + else: + name = platform_id.replace("_", " ").title() + + description = override.get("description") + if not description and plugin_entry is not None: + description = plugin_entry.install_hint or "" + + return { + "id": platform_id, + "name": name, + "description": description or "", + "docs_url": override.get("docs_url", ""), + "env_vars": env_vars, + "required_env": required_env, + } + + +def _catalog_lookup(platform_id: str) -> dict[str, Any] | None: + for entry in _messaging_platform_catalog(): + if entry["id"] == platform_id: + return entry + return None + + +def _messaging_env_info(key: str) -> dict[str, Any]: + info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {} + return { + "description": info.get("description", ""), + "prompt": info.get("prompt", key), + "url": info.get("url"), + "is_password": info.get("password", False), + "advanced": info.get("advanced", False), + } + + +def _gateway_platform_config(platform_id: str): + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + platform = Platform(platform_id) + platform_config = config.platforms.get(platform) + return config, platform, platform_config + + +def _messaging_platform_payload( + entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None +) -> dict[str, Any]: + platform_id = entry["id"] + gateway_running = get_running_pid() is not None + runtime_platforms = runtime.get("platforms") if runtime else {} + runtime_platform = ( + runtime_platforms.get(platform_id, {}) + if isinstance(runtime_platforms, dict) + else {} + ) + env_vars = [] + + for key in entry["env_vars"]: + value = env_on_disk.get(key) or os.getenv(key, "") + env_vars.append( + { + "key": key, + "required": key in entry["required_env"], + "is_set": bool(value), + "redacted_value": redact_key(value) if value else None, + **_messaging_env_info(key), + } + ) + + try: + gateway_config, platform, platform_config = _gateway_platform_config( + platform_id + ) + enabled = bool(platform_config and platform_config.enabled) + configured = bool( + platform_config + and gateway_config._is_platform_connected(platform, platform_config) + ) + home_channel = ( + platform_config.home_channel.to_dict() + if platform_config and platform_config.home_channel + else None + ) + except Exception: + enabled = False + configured = all( + env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"] + ) + home_channel = None + + state = ( + runtime_platform.get("state") if isinstance(runtime_platform, dict) else None + ) + if not enabled: + state = "disabled" + elif not configured: + state = "not_configured" + elif gateway_running and not state: + state = "pending_restart" + elif not gateway_running and not state: + state = "gateway_stopped" + + return { + "id": platform_id, + "name": entry["name"], + "description": entry["description"], + "docs_url": entry["docs_url"], + "enabled": enabled, + "configured": configured, + "gateway_running": gateway_running, + "state": state, + "error_code": ( + runtime_platform.get("error_code") + if isinstance(runtime_platform, dict) + else None + ), + "error_message": ( + runtime_platform.get("error_message") + if isinstance(runtime_platform, dict) + else None + ), + "updated_at": ( + runtime_platform.get("updated_at") + if isinstance(runtime_platform, dict) + else None + ), + "home_channel": home_channel, + "env_vars": env_vars, + } + + +def _write_platform_enabled(platform_id: str, enabled: bool) -> None: + config = load_config() + platforms = config.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + config["platforms"] = platforms + platform_config = platforms.setdefault(platform_id, {}) + if not isinstance(platform_config, dict): + platform_config = {} + platforms[platform_id] = platform_config + platform_config["enabled"] = enabled + save_config(config) + + +@app.get("/api/messaging/platforms") +async def get_messaging_platforms(): + env_on_disk = load_env() + runtime = read_runtime_status() + return { + "platforms": [ + _messaging_platform_payload(entry, env_on_disk, runtime) + for entry in _messaging_platform_catalog() + ] + } + + +@app.put("/api/messaging/platforms/{platform_id}") +async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate): + entry = _catalog_lookup(platform_id) + if not entry: + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) + + allowed_env = set(entry["env_vars"]) + try: + for key in body.clear_env: + if key not in allowed_env: + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) + remove_env_value(key) + + for key, value in body.env.items(): + if key not in allowed_env: + raise HTTPException( + status_code=400, + detail=f"{key} is not configurable for {entry['name']}", + ) + trimmed = value.strip() + if trimmed: + save_env_value(key, trimmed) + + if body.enabled is not None: + _write_platform_enabled(platform_id, body.enabled) + + return {"ok": True, "platform": platform_id} + except HTTPException: + raise + except Exception: + _log.exception("PUT /api/messaging/platforms/%s failed", platform_id) + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.post("/api/messaging/platforms/{platform_id}/test") +async def test_messaging_platform(platform_id: str): + entry = _catalog_lookup(platform_id) + if not entry: + raise HTTPException( + status_code=404, detail=f"Unknown messaging platform: {platform_id}" + ) + + env_on_disk = load_env() + payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status()) + if not payload["enabled"]: + message = f"{entry['name']} is disabled. Enable it, then restart the gateway." + return {"ok": False, "state": payload["state"], "message": message} + if not payload["configured"]: + missing = [ + field["key"] + for field in payload["env_vars"] + if field["required"] and not field["is_set"] + ] + message = ( + f"Missing required setup: {', '.join(missing)}" + if missing + else "Platform setup is incomplete." + ) + return {"ok": False, "state": payload["state"], "message": message} + if not payload["gateway_running"]: + return { + "ok": False, + "state": payload["state"], + "message": "Gateway is not running. Restart the gateway to connect this platform.", + } + if payload["state"] == "connected": + return { + "ok": True, + "state": payload["state"], + "message": f"{entry['name']} is connected.", + } + if payload.get("error_message"): + return { + "ok": False, + "state": payload["state"], + "message": payload["error_message"], + } + return { + "ok": False, + "state": payload["state"], + "message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.", + } + + # --------------------------------------------------------------------------- # OAuth provider endpoints — status + disconnect (Phase 1) # --------------------------------------------------------------------------- @@ -3497,6 +4284,10 @@ def _resolve_chat_argv( Appending ``--resume `` to argv doesn't work because ``ui-tui`` does not parse its argv. + ``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to + this process's in-memory ``tui_gateway`` instance instead of spawning + its own Python gateway subprocess. + `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). @@ -3524,9 +4315,30 @@ def _resolve_chat_argv( if sidecar_url: env["HERMES_TUI_SIDECAR_URL"] = sidecar_url + if gateway_ws_url := _build_gateway_ws_url(): + env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url + return list(argv), str(cwd) if cwd else None, env +def _build_gateway_ws_url() -> Optional[str]: + """ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.""" + host = getattr(app.state, "bound_host", None) + port = getattr(app.state, "bound_port", None) + + if not host or not port: + return None + + netloc = ( + f"[{host}]:{port}" + if ":" in host and not host.startswith("[") + else f"{host}:{port}" + ) + qs = urllib.parse.urlencode({"token": _SESSION_TOKEN}) + + return f"ws://{netloc}/api/ws?{qs}" + + def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. diff --git a/package-lock.json b/package-lock.json index ab77b0af50a..093d6353a23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "apps/*" ], "dependencies": { - "@askjo/camofox-browser": "^1.5.2", "@streamdown/math": "^1.0.2", "agent-browser": "^0.26.0" }, @@ -414,25 +413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@askjo/camofox-browser": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@askjo/camofox-browser/-/camofox-browser-1.5.2.tgz", - "integrity": "sha512-SvRCzhWnJaplxHkRVF9l1OWako6pp2eUw2mZKHOERUfLWDO2Xe/IKI+5bB+UT1TNvO45P6XdhgfAtihcTEARCg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "camoufox-js": "^0.8.5", - "express": "^4.18.2", - "playwright": "^1.50.0", - "playwright-core": "^1.58.0", - "playwright-extra": "^4.3.6", - "prom-client": "^15.1.3", - "puppeteer-extra-plugin-stealth": "^2.11.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@assistant-ui/core": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/@assistant-ui/core/-/core-0.1.17.tgz", @@ -10334,34 +10314,6 @@ "node": ">=6" } }, - "node_modules/camoufox-js": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/camoufox-js/-/camoufox-js-0.8.5.tgz", - "integrity": "sha512-20ihPbspAcOVSUTX9Drxxp0C116DON1n8OVA1eUDglWZiHwiHwFVFOMrIEBwAHMZpU11mIEH/kawJtstRIrDPA==", - "license": "MPL-2.0", - "dependencies": { - "adm-zip": "^0.5.16", - "better-sqlite3": "^12.2.0", - "commander": "^14.0.0", - "fingerprint-generator": "^2.1.66", - "glob": "^13.0.0", - "impit": "^0.7.0", - "language-tags": "^2.0.1", - "maxmind": "^5.0.0", - "progress": "^2.0.3", - "ua-parser-js": "^2.0.2", - "xml2js": "^0.6.2" - }, - "bin": { - "camoufox-js": "dist/__main__.js" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "playwright-core": "*" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001787", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", diff --git a/package.json b/package.json index f2145f14233..6d519553f69 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "dependencies": { - "@askjo/camofox-browser": "^1.5.2", "@streamdown/math": "^1.0.2", "agent-browser": "^0.26.0" }, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 43f32e990ef..04774113c63 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2,6 +2,7 @@ import os import json +from pathlib import Path from unittest.mock import patch, MagicMock import pytest diff --git a/tests/tui_gateway/test_review_summary_callback.py b/tests/tui_gateway/test_review_summary_callback.py index 56ca2d4944f..2c6d3cbeb7c 100644 --- a/tests/tui_gateway/test_review_summary_callback.py +++ b/tests/tui_gateway/test_review_summary_callback.py @@ -54,7 +54,7 @@ def test_init_session_attaches_background_review_callback(server, monkeypatch): monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object()) monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None) monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"}) + monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"}) monkeypatch.setattr(server, "_load_show_reasoning", lambda: False) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all") @@ -106,7 +106,7 @@ def test_review_summary_callback_survives_agent_without_attribute(server, monkey monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object()) monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None) monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None) - monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"}) + monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"}) monkeypatch.setattr(server, "_load_show_reasoning", lambda: False) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all") monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)