From 65be657a791364140edfeed3229cedd9ece42660 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:48:21 -0700 Subject: [PATCH 1/9] feat(skills): add Sherlock OSINT username search skill Add optional skill for username enumeration across 400+ social networks using the Sherlock Project CLI (https://github.com/sherlock-project/sherlock). Features: - Smart username extraction from user messages - Installation verification before execution - Categorized output with clickable links - Ethical use guidelines - Docker, pipx, and pip installation paths Co-authored-by: unmodeled-tyler --- optional-skills/security/sherlock/SKILL.md | 192 +++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 optional-skills/security/sherlock/SKILL.md diff --git a/optional-skills/security/sherlock/SKILL.md b/optional-skills/security/sherlock/SKILL.md new file mode 100644 index 00000000000..7250246aa3a --- /dev/null +++ b/optional-skills/security/sherlock/SKILL.md @@ -0,0 +1,192 @@ +--- +name: sherlock +description: OSINT username search across 400+ social networks. Hunt down social media accounts by username. +version: 1.0.0 +author: unmodeled-tyler +license: MIT +metadata: + hermes: + tags: [osint, security, username, social-media, reconnaissance] + category: security +prerequisites: + commands: [sherlock] +--- + +# Sherlock OSINT Username Search + +Hunt down social media accounts by username across 400+ social networks using the [Sherlock Project](https://github.com/sherlock-project/sherlock). + +## When to Use + +- User asks to find accounts associated with a username +- User wants to check username availability across platforms +- User is conducting OSINT or reconnaissance research +- User asks "where is this username registered?" or similar + +## Requirements + +- Sherlock CLI installed: `pipx install sherlock-project` or `pip install sherlock-project` +- Alternatively: Docker available (`docker run -it --rm sherlock/sherlock`) +- Network access to query social platforms + +## Procedure + +### 1. Check if Sherlock is Installed + +**Before doing anything else**, verify sherlock is available: + +```bash +sherlock --version +``` + +If the command fails: +- Offer to install: `pipx install sherlock-project` (recommended) or `pip install sherlock-project` +- **Do NOT** try multiple installation methods — pick one and proceed +- If installation fails, inform the user and stop + +### 2. Extract Username + +**Extract the username directly from the user's message if clearly stated.** + +Examples where you should **NOT** use clarify: +- "Find accounts for nasa" → username is `nasa` +- "Search for johndoe123" → username is `johndoe123` +- "Check if alice exists on social media" → username is `alice` +- "Look up user bob on social networks" → username is `bob` + +**Only use clarify if:** +- Multiple potential usernames mentioned ("search for alice or bob") +- Ambiguous phrasing ("search for my username" without specifying) +- No username mentioned at all ("do an OSINT search") + +When extracting, take the **exact** username as stated — preserve case, numbers, underscores, etc. + +### 3. Build Command + +**Default command** (use this unless user specifically requests otherwise): +```bash +sherlock --print-found --no-color "" --timeout 90 +``` + +**Optional flags** (only add if user explicitly requests): +- `--nsfw` — Include NSFW sites (only if user asks) +- `--tor` — Route through Tor (only if user asks for anonymity) + +**Do NOT ask about options via clarify** — just run the default search. Users can request specific options if needed. + +### 4. Execute Search + +Run via the `terminal` tool. The command typically takes 30-120 seconds depending on network conditions and site count. + +**Example terminal call:** +```json +{ + "command": "sherlock --print-found --no-color \"target_username\"", + "timeout": 180 +} +``` + +### 5. Parse and Present Results + +Sherlock outputs found accounts in a simple format. Parse the output and present: + +1. **Summary line:** "Found X accounts for username 'Y'" +2. **Categorized links:** Group by platform type if helpful (social, professional, forums, etc.) +3. **Output file location:** Sherlock saves results to `.txt` by default + +**Example output parsing:** +``` +[+] Instagram: https://instagram.com/username +[+] Twitter: https://twitter.com/username +[+] GitHub: https://github.com/username +``` + +Present findings as clickable links when possible. + +## Pitfalls + +### No Results Found +If Sherlock finds no accounts, this is often correct — the username may not be registered on checked platforms. Suggest: +- Checking spelling/variation +- Trying similar usernames with `?` wildcard: `sherlock "user?name"` +- The user may have privacy settings or deleted accounts + +### Timeout Issues +Some sites are slow or block automated requests. Use `--timeout 120` to increase wait time, or `--site` to limit scope. + +### Tor Configuration +`--tor` requires Tor daemon running. If user wants anonymity but Tor isn't available, suggest: +- Installing Tor service +- Using `--proxy` with an alternative proxy + +### False Positives +Some sites always return "found" due to their response structure. Cross-reference unexpected results with manual checks. + +### Rate Limiting +Aggressive searches may trigger rate limits. For bulk username searches, add delays between calls or use `--local` with cached data. + +## Installation + +### pipx (recommended) +```bash +pipx install sherlock-project +``` + +### pip +```bash +pip install sherlock-project +``` + +### Docker +```bash +docker pull sherlock/sherlock +docker run -it --rm sherlock/sherlock +``` + +### Linux packages +Available on Debian 13+, Ubuntu 22.10+, Homebrew, Kali, BlackArch. + +## Ethical Use + +This tool is for legitimate OSINT and research purposes only. Remind users: +- Only search usernames they own or have permission to investigate +- Respect platform terms of service +- Do not use for harassment, stalking, or illegal activities +- Consider privacy implications before sharing results + +## Verification + +After running sherlock, verify: +1. Output lists found sites with URLs +2. `.txt` file created (default output) if using file output +3. If `--print-found` used, output should only contain `[+]` lines for matches + +## Example Interaction + +**User:** "Can you check if the username 'johndoe123' exists on social media?" + +**Agent procedure:** +1. Check `sherlock --version` (verify installed) +2. Username provided — proceed directly +3. Run: `sherlock --print-found --no-color "johndoe123" --timeout 90` +4. Parse output and present links + +**Response format:** +> Found 12 accounts for username 'johndoe123': +> +> • https://twitter.com/johndoe123 +> • https://github.com/johndoe123 +> • https://instagram.com/johndoe123 +> • [... additional links] +> +> Results saved to: johndoe123.txt + +--- + +**User:** "Search for username 'alice' including NSFW sites" + +**Agent procedure:** +1. Check sherlock installed +2. Username + NSFW flag both provided +3. Run: `sherlock --print-found --no-color --nsfw "alice" --timeout 90` +4. Present results \ No newline at end of file From d9d937b7f7f429bd466cbb18f6a92766fbcb8324 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:48:33 -0700 Subject: [PATCH 2/9] fix: detect Claude Code version dynamically for OAuth user-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent infinite 400 failure loop on context overflow (#1630) When a gateway session exceeds the model's context window, Anthropic may return a generic 400 invalid_request_error with just 'Error' as the message. This bypassed the phrase-based context-length detection, causing the agent to treat it as a non-retryable client error. Worse, the failed user message was still persisted to the transcript, making the session even larger on each attempt — creating an infinite loop. Three-layer fix: 1. run_agent.py — Fallback heuristic: when a 400 error has a very short generic message AND the session is large (>40% of context or >80 messages), treat it as a probable context overflow and trigger compression instead of aborting. 2. run_agent.py + gateway/run.py — Don't persist failed messages: when the agent returns failed=True before generating any response, skip writing the user's message to the transcript/DB. This prevents the session from growing on each failure. 3. gateway/run.py — Smarter error messages: detect context-overflow failures and suggest /compact or /reset specifically, instead of a generic 'try again' that will fail identically. * fix(skills): detect prompt injection patterns and block cache file reads Adds two security layers to prevent prompt injection via skills hub cache files (#1558): 1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory (index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json was the original injection vector — untrusted skill descriptions in the catalog contained adversarial text that the model executed. 2. skill_view: warns when skills are loaded from outside the trusted ~/.hermes/skills/ directory, and detects common injection patterns in skill content ("ignore previous instructions", "", etc.). Cherry-picked from PR #1562 by ygd58. * fix(tools): chunk long messages in send_message_tool before dispatch (#1552) Long messages sent via send_message tool or cron delivery silently failed when exceeding platform limits. Gateway adapters handle this via truncate_message(), but the standalone senders in send_message_tool bypassed that entirely. - Apply truncate_message() chunking in _send_to_platform() before dispatching to individual platform senders - Remove naive message[i:i+2000] character split in _send_discord() in favor of centralized smart splitting - Attach media files to last chunk only for Telegram - Add regression tests for chunking and media placement Cherry-picked from PR #1557 by llbn. * fix(approval): show full command in dangerous command approval (#1553) Previously the command was truncated to 80 chars in CLI (with a [v]iew full option), 500 chars in Discord embeds, and missing entirely in Telegram/Slack approval messages. Now the full command is always displayed everywhere: - CLI: removed 80-char truncation and [v]iew full menu option - Gateway (TG/Slack): approval_required message includes full command in a code block - Discord: embed shows full command up to 4096-char limit - Windows: skip SIGALRM-based test timeout (Unix-only) - Updated tests: replaced view-flow tests with direct approval tests Cherry-picked from PR #1566 by crazywriter1. * fix(cli): flush stdout during agent loop to prevent macOS display freeze (#1624) The interrupt polling loop in chat() waited on the queue without invalidating the prompt_toolkit renderer. On macOS, the StdoutProxy buffer only flushed on input events, causing the CLI to appear frozen during tool execution until the user typed a key. Fix: call _invalidate() on each queue timeout (every ~100ms, throttled to 150ms) to force the renderer to flush buffered agent output. * fix(claw): warn when API keys are skipped during OpenClaw migration (#1580) When --migrate-secrets is not passed (the default), API keys like OPENROUTER_API_KEY are silently skipped with no warning. Users don't realize their keys weren't migrated until the agent fails to connect. Add a post-migration warning with actionable instructions: either re-run with --migrate-secrets or add the key manually via hermes config set. Cherry-picked from PR #1593 by ygd58. * fix(security): block sandbox backend creds from subprocess env (#1264) Add Modal and Daytona sandbox credentials to the subprocess env blocklist so they're not leaked to agent terminal sessions via printenv/env. Cherry-picked from PR #1571 by ygd58. * fix(gateway): cap interrupt recursion depth to prevent resource exhaustion (#816) When a user sends multiple messages while the agent keeps failing, _run_agent() calls itself recursively with no depth limit. This can exhaust stack/memory if the agent is in a failure loop. Add _MAX_INTERRUPT_DEPTH = 3. When exceeded, the pending message is logged and the current result is returned instead of recursing deeper. The log handler duplication bug described in #816 was already fixed separately (AIAgent.__init__ deduplicates handlers). * fix(gateway): /model shows active fallback model instead of config default (#1615) When the agent falls back to a different model (e.g. due to rate limiting), /model still showed the config default. Now tracks the effective model/provider after each agent run and displays it. Cleared when the primary model succeeds again or the user explicitly switches via /model. Cherry-picked from PR #1616 by MaxKerkula. Added hasattr guard for test compatibility. * feat(gateway): inject reply-to message context for out-of-session replies (#1594) When a user replies to a Telegram message, check if the quoted text exists in the current session transcript. If missing (from cron jobs, background tasks, or old sessions), prepend [Replying to: "..."] to the message so the agent has context about what's being referenced. - Add reply_to_text field to MessageEvent (base.py) - Populate from Telegram's reply_to_message (text or caption) - Inject context in _handle_message when not found in history Based on PR #1596 by anpicasso (cherry-picked reply-to feature only, excluded unrelated /server command and background delegation changes). * fix: recognize Claude Code OAuth credentials in startup gate (#1455) The _has_any_provider_configured() startup check didn't look for Claude Code OAuth credentials (~/.claude/.credentials.json). Users with only Claude Code auth got the setup wizard instead of starting. Cherry-picked from PR #1455 by kshitijk4poor. * perf: use ripgrep for file search (200x faster than find) search_files(target='files') now uses rg --files -g instead of find. Ripgrep respects .gitignore, excludes hidden dirs by default, and has parallel directory traversal — ~200x faster on wide trees (0.14s vs 34s benchmarked on 164-repo tree). Falls back to find when rg is unavailable, preserving hidden-dir exclusion and BSD find compatibility. Salvaged from PR #1464 by @light-merlin-dark (Merlin) — adapted to preserve hidden-dir exclusion added since the original PR. * refactor(tts): replace NeuTTS optional skill with built-in provider + setup flow Remove the optional skill (redundant now that NeuTTS is a built-in TTS provider). Replace neutts_cli dependency with a standalone synthesis helper (tools/neutts_synth.py) that calls the neutts Python API directly in a subprocess. Add TTS provider selection to hermes setup: - 'hermes setup' now prompts for TTS provider after model selection - 'hermes setup tts' available as standalone section - Selecting NeuTTS checks for deps and offers to install: espeak-ng (system) + neutts[all] (pip) - ElevenLabs/OpenAI selections prompt for API keys - Tool status display shows NeuTTS install state Changes: - Remove optional-skills/mlops/models/neutts/ (skill + CLI scaffold) - Add tools/neutts_synth.py (standalone synthesis subprocess helper) - Move jo.wav/jo.txt to tools/neutts_samples/ (bundled default voice) - Refactor _generate_neutts() — uses neutts API via subprocess, no neutts_cli dependency, config-driven ref_audio/ref_text/model/device - Add TTS setup to hermes_cli/setup.py (SETUP_SECTIONS, tool status) - Update config.py defaults (ref_audio, ref_text, model, device) * fix(docker): add explicit env allowlist for container credentials (#1436) Docker terminal sessions are secret-dark by default. This adds terminal.docker_forward_env as an explicit allowlist for env vars that may be forwarded into Docker containers. Values resolve from the current shell first, then fall back to ~/.hermes/.env. Only variables the user explicitly lists are forwarded — nothing is auto-exposed. Cherry-picked from PR #1449 by @teknium1, conflict-resolved onto current main. Fixes #1436 Supersedes #1439 * fix: email send_typing metadata param + ☤ Hermes staff symbol - email.py: add missing metadata parameter to send_typing() to match BasePlatformAdapter signature (PR #1431 by @ItsChoudhry) - README.md: ⚕ → ☤ — the caduceus is Hermes's staff, not the medical Staff of Asclepius (PR #1420 by @rianczerwinski) * fix(whatsapp): support LID format in self-chat mode (#1556) WhatsApp now uses LID (Linked Identity Device) format alongside classic @s.whatsapp.net. Self-chat detection checked only the classic format, breaking self-chat mode for users on newer WhatsApp versions. - Check both sock.user.id and sock.user.lid for self-chat detection - Accept 'append' message type in addition to 'notify' (self-chat messages arrive as 'append') - Track sent message IDs to prevent echo-back loops with media - Add WHATSAPP_DEBUG env var for troubleshooting Based on PR #1556 by jcorrego (manually applied due to cherry-pick conflicts). * fix: detect Claude Code version dynamically for OAuth user-agent The _CLAUDE_CODE_VERSION was hardcoded to '2.1.2' but Anthropic rejects OAuth requests when the spoofed user-agent version is too far behind the current Claude Code release. The error is a generic 400 with just 'Error' as the message, making it very hard to diagnose. Fix: detect the installed version via 'claude --version' at import time, falling back to a bumped static constant (2.1.74) when Claude Code isn't installed. This means users who keep Claude Code updated never hit stale-version rejections. Reported by Jack — changing the version string to match the installed claude binary fixed persistent OAuth 400 errors immediately. --------- Co-authored-by: buray Co-authored-by: lbn Co-authored-by: crazywriter1 <53251494+crazywriter1@users.noreply.github.com> Co-authored-by: Max K Co-authored-by: Angello Picasso Co-authored-by: kshitij Co-authored-by: jcorrego --- agent/anthropic_adapter.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 8b6039b911f..3e1bd85bbdf 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -54,7 +54,37 @@ _OAUTH_ONLY_BETAS = [ # Claude Code identity — required for OAuth requests to be routed correctly. # Without these, Anthropic's infrastructure intermittently 500s OAuth traffic. -_CLAUDE_CODE_VERSION = "2.1.2" +# The version must stay reasonably current — Anthropic rejects OAuth requests +# when the spoofed user-agent version is too far behind the actual release. +_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74" + + +def _detect_claude_code_version() -> str: + """Detect the installed Claude Code version, fall back to a static constant. + + Anthropic's OAuth infrastructure validates the user-agent version and may + reject requests with a version that's too old. Detecting dynamically means + users who keep Claude Code updated never hit stale-version 400s. + """ + import subprocess as _sp + + for cmd in ("claude", "claude-code"): + try: + result = _sp.run( + [cmd, "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + # Output is like "2.1.74 (Claude Code)" or just "2.1.74" + version = result.stdout.strip().split()[0] + if version and version[0].isdigit(): + return version + except Exception: + pass + return _CLAUDE_CODE_VERSION_FALLBACK + + +_CLAUDE_CODE_VERSION = _detect_claude_code_version() _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." _MCP_TOOL_PREFIX = "mcp_" From 7042a748f5773f962d508721639fcdceb5c81bd8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:49:22 -0700 Subject: [PATCH 3/9] feat: add Alibaba Cloud provider and Anthropic base_url override (#1673) Add Alibaba Cloud (DashScope) as a first-class inference provider using the Anthropic-compatible endpoint. This gives access to Qwen models (qwen3.5-plus, qwen3-max, qwen3-coder-plus, etc.) through the same api_mode as native Anthropic. Also add ANTHROPIC_BASE_URL env var support so users can point the Anthropic provider at any compatible endpoint. Changes: - auth.py: Add alibaba ProviderConfig + ANTHROPIC_BASE_URL on anthropic - models.py: Add alibaba to catalog, labels, aliases (dashscope/aliyun/qwen), provider order - runtime_provider.py: Add alibaba resolution (anthropic_messages api_mode) + ANTHROPIC_BASE_URL - model_metadata.py: Add Qwen model context lengths (128K) - config.py: Add DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL, ANTHROPIC_BASE_URL env vars Usage: hermes --provider alibaba --model qwen3.5-plus # or via aliases: hermes --provider qwen --model qwen3-max --- agent/model_metadata.py | 8 ++++++++ hermes_cli/auth.py | 9 +++++++++ hermes_cli/config.py | 23 +++++++++++++++++++++++ hermes_cli/models.py | 16 +++++++++++++++- hermes_cli/runtime_provider.py | 17 ++++++++++++++++- 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index ae7abb5621d..2f9ea666ca3 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -116,6 +116,14 @@ DEFAULT_CONTEXT_LENGTHS = { "kimi-k2": 262144, "qwen3-coder": 32768, "big-pickle": 128000, + # Alibaba Cloud / DashScope Qwen models + "qwen3.5-plus": 131072, + "qwen3-max": 131072, + "qwen3-coder-plus": 131072, + "qwen3-coder-next": 131072, + "qwen-plus-latest": 131072, + "qwen3.5-flash": 131072, + "qwen-vl-max": 32768, } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d30dc5b34ad..f1341d5d60b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -138,6 +138,15 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="api_key", inference_base_url="https://api.anthropic.com", api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), + base_url_env_var="ANTHROPIC_BASE_URL", + ), + "alibaba": ProviderConfig( + id="alibaba", + name="Alibaba Cloud (DashScope)", + auth_type="api_key", + inference_base_url="https://dashscope-intl.aliyuncs.com/apps/anthropic", + api_key_env_vars=("DASHSCOPE_API_KEY",), + base_url_env_var="DASHSCOPE_BASE_URL", ), "minimax-cn": ProviderConfig( id="minimax-cn", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 85054350fc3..ae0d9cb75f7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -492,6 +492,29 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "provider", }, + "ANTHROPIC_BASE_URL": { + "description": "Custom Anthropic-compatible API base URL (e.g. Alibaba Cloud DashScope)", + "prompt": "Anthropic Base URL", + "url": "", + "password": False, + "category": "provider", + "advanced": True, + }, + "DASHSCOPE_API_KEY": { + "description": "Alibaba Cloud DashScope API key for Qwen models", + "prompt": "DashScope API Key", + "url": "https://modelstudio.console.alibabacloud.com/", + "password": True, + "category": "provider", + }, + "DASHSCOPE_BASE_URL": { + "description": "Custom DashScope base URL (default: international endpoint)", + "prompt": "DashScope Base URL", + "url": "", + "password": False, + "category": "provider", + "advanced": True, + }, "OPENCODE_ZEN_API_KEY": { "description": "OpenCode Zen API key (pay-as-you-go access to curated models)", "prompt": "OpenCode Zen API key", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5701641e089..25c9eea54df 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -146,6 +146,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-3-pro-preview", "google/gemini-3-flash-preview", ], + "alibaba": [ + "qwen3.5-plus", + "qwen3-max", + "qwen3-coder-plus", + "qwen3-coder-next", + "qwen-plus-latest", + "qwen3.5-flash", + "qwen-vl-max", + ], } _PROVIDER_LABELS = { @@ -162,6 +171,7 @@ _PROVIDER_LABELS = { "opencode-go": "OpenCode Go", "ai-gateway": "AI Gateway", "kilocode": "Kilo Code", + "alibaba": "Alibaba Cloud (DashScope)", "custom": "Custom endpoint", } @@ -187,6 +197,10 @@ _PROVIDER_ALIASES = { "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", + "dashscope": "alibaba", + "aliyun": "alibaba", + "qwen": "alibaba", + "alibaba-cloud": "alibaba", } @@ -220,7 +234,7 @@ def list_available_providers() -> list[dict[str, str]]: # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", - "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", + "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", "opencode-zen", "opencode-go", "ai-gateway", "deepseek", "custom", ] diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 148e30bfbc3..db96edccd15 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -276,15 +276,30 @@ def resolve_runtime_provider( "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " "run 'claude setup-token', or authenticate with 'claude /login'." ) + # Support custom Anthropic-compatible endpoints via ANTHROPIC_BASE_URL + base_url = os.getenv("ANTHROPIC_BASE_URL", "").strip() or "https://api.anthropic.com" return { "provider": "anthropic", "api_mode": "anthropic_messages", - "base_url": "https://api.anthropic.com", + "base_url": base_url, "api_key": token, "source": "env", "requested_provider": requested_provider, } + # Alibaba Cloud / DashScope (Anthropic-compatible endpoint) + if provider == "alibaba": + creds = resolve_api_key_provider_credentials(provider) + base_url = creds.get("base_url", "").rstrip("/") or "https://dashscope-intl.aliyuncs.com/apps/anthropic" + return { + "provider": "alibaba", + "api_mode": "anthropic_messages", + "base_url": base_url, + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "env"), + "requested_provider": requested_provider, + } + # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": From d15694241977530cb1440cc21b0857f28d855ca2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:49:57 -0700 Subject: [PATCH 4/9] fix(telegram): aggregate split text messages before dispatching (#1674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends a long message, Telegram clients split it into multiple updates that arrive within milliseconds of each other. Previously each chunk was dispatched independently — the first would start the agent, and subsequent chunks would interrupt or queue as separate turns, causing the agent to only see part of the message. Add text message batching to TelegramAdapter following the same pattern as the existing photo burst batching: - _enqueue_text_event() buffers text by session key, concatenating chunks that arrive in rapid succession - _flush_text_batch() dispatches the combined message after a 0.6s quiet period (configurable via HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS) - Timer resets on each new chunk, so all parts of a split arrive before the batch is dispatched Reported by NulledVector on Discord. --- gateway/platforms/telegram.py | 78 +++++++++++- tests/gateway/test_telegram_text_batching.py | 121 +++++++++++++++++++ 2 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_telegram_text_batching.py diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index b4ef75f8ec8..978c800f36f 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -118,6 +118,11 @@ class TelegramAdapter(BasePlatformAdapter): self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {} self._media_group_events: Dict[str, MessageEvent] = {} self._media_group_tasks: Dict[str, asyncio.Task] = {} + # Buffer rapid text messages so Telegram client-side splits of long + # messages are aggregated into a single MessageEvent. + self._text_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", "0.6")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} self._token_lock_identity: Optional[str] = None self._polling_error_task: Optional[asyncio.Task] = None @@ -795,12 +800,17 @@ class TelegramAdapter(BasePlatformAdapter): return text async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle incoming text messages.""" + """Handle incoming text messages. + + Telegram clients split long messages into multiple updates. Buffer + rapid successive text messages from the same user/chat and aggregate + them into a single MessageEvent before dispatching. + """ if not update.message or not update.message.text: return - + event = self._build_message_event(update.message, MessageType.TEXT) - await self.handle_message(event) + self._enqueue_text_event(event) async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming command messages.""" @@ -845,6 +855,68 @@ class TelegramAdapter(BasePlatformAdapter): event.text = "\n".join(parts) await self.handle_message(event) + # ------------------------------------------------------------------ + # Text message aggregation (handles Telegram client-side splits) + # ------------------------------------------------------------------ + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When Telegram splits a long user message into multiple updates, + they arrive within a few hundred milliseconds. This method + concatenates them and waits for a short quiet period before + dispatching the combined message. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + if existing is None: + self._pending_text_batches[key] = event + else: + # Append text from the follow-up chunk + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + # Merge any media that might be attached + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + # Cancel any pending flush and restart the timer + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text.""" + current_task = asyncio.current_task() + try: + await asyncio.sleep(self._text_batch_delay_seconds) + event = self._pending_text_batches.pop(key, None) + if not event: + return + logger.info( + "[Telegram] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + # ------------------------------------------------------------------ + # Photo batching + # ------------------------------------------------------------------ + def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str: """Return a batching key for Telegram photos/albums.""" from gateway.session import build_session_key diff --git a/tests/gateway/test_telegram_text_batching.py b/tests/gateway/test_telegram_text_batching.py new file mode 100644 index 00000000000..14c3f0dd67e --- /dev/null +++ b/tests/gateway/test_telegram_text_batching.py @@ -0,0 +1,121 @@ +"""Tests for Telegram text message aggregation. + +When a user sends a long message, Telegram clients split it into multiple +updates. The TelegramAdapter should buffer rapid successive text messages +from the same session and aggregate them before dispatching. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, SessionSource + + +def _make_adapter(): + """Create a minimal TelegramAdapter for testing text batching.""" + from gateway.platforms.telegram import TelegramAdapter + + config = PlatformConfig(enabled=True, token="test-token") + adapter = object.__new__(TelegramAdapter) + adapter._platform = Platform.TELEGRAM + adapter.config = config + adapter._pending_text_batches = {} + adapter._pending_text_batch_tasks = {} + adapter._text_batch_delay_seconds = 0.1 # fast for tests + adapter._active_sessions = {} + adapter._pending_messages = {} + adapter._message_handler = AsyncMock() + adapter.handle_message = AsyncMock() + return adapter + + +def _make_event(text: str, chat_id: str = "12345") -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource(platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"), + ) + + +class TestTextBatching: + @pytest.mark.asyncio + async def test_single_message_dispatched_after_delay(self): + adapter = _make_adapter() + event = _make_event("hello world") + + adapter._enqueue_text_event(event) + + # Not dispatched yet + adapter.handle_message.assert_not_called() + + # Wait for flush + await asyncio.sleep(0.2) + + adapter.handle_message.assert_called_once() + dispatched = adapter.handle_message.call_args[0][0] + assert dispatched.text == "hello world" + + @pytest.mark.asyncio + async def test_split_messages_aggregated(self): + """Two rapid messages from the same chat should be merged.""" + adapter = _make_adapter() + + adapter._enqueue_text_event(_make_event("This is part one of a long")) + await asyncio.sleep(0.02) # small gap, within batch window + adapter._enqueue_text_event(_make_event("message that was split by Telegram.")) + + # Not dispatched yet (timer restarted) + adapter.handle_message.assert_not_called() + + # Wait for flush + await asyncio.sleep(0.2) + + adapter.handle_message.assert_called_once() + dispatched = adapter.handle_message.call_args[0][0] + assert "part one" in dispatched.text + assert "split by Telegram" in dispatched.text + + @pytest.mark.asyncio + async def test_three_way_split_aggregated(self): + """Three rapid messages should all merge.""" + adapter = _make_adapter() + + adapter._enqueue_text_event(_make_event("chunk 1")) + await asyncio.sleep(0.02) + adapter._enqueue_text_event(_make_event("chunk 2")) + await asyncio.sleep(0.02) + adapter._enqueue_text_event(_make_event("chunk 3")) + + await asyncio.sleep(0.2) + + adapter.handle_message.assert_called_once() + text = adapter.handle_message.call_args[0][0].text + assert "chunk 1" in text + assert "chunk 2" in text + assert "chunk 3" in text + + @pytest.mark.asyncio + async def test_different_chats_not_merged(self): + """Messages from different chats should be separate batches.""" + adapter = _make_adapter() + + adapter._enqueue_text_event(_make_event("from user A", chat_id="111")) + adapter._enqueue_text_event(_make_event("from user B", chat_id="222")) + + await asyncio.sleep(0.2) + + assert adapter.handle_message.call_count == 2 + + @pytest.mark.asyncio + async def test_batch_cleans_up_after_flush(self): + """After flushing, internal state should be clean.""" + adapter = _make_adapter() + + adapter._enqueue_text_event(_make_event("test")) + await asyncio.sleep(0.2) + + assert len(adapter._pending_text_batches) == 0 + assert len(adapter._pending_text_batch_tasks) == 0 From a1c81360a57d3c7f6d677f31183cc2dfbfe66b13 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 02:51:31 -0700 Subject: [PATCH 5/9] feat(cli): skin-aware light/dark theme mode with terminal auto-detection Add display.theme_mode setting (auto/light/dark) that makes the CLI readable on light terminal backgrounds. - Auto-detect terminal background via COLORFGBG, OSC 11, and macOS appearance (fallback chain in hermes_cli/colors.py) - Add colors_light overrides to all 7 built-in skins with dark/readable colors for light backgrounds - SkinConfig.get_color() now returns light overrides when theme is light - get_prompt_toolkit_style_overrides() uses light bg colors for completion menus in light mode - init_skin_from_config() reads display.theme_mode from config - 7 new tests covering theme mode resolution, detection fallbacks, and light-mode skin overrides Salvaged from PR #1187 by @peteromallet. Core design preserved; adapted to current main (kept all existing helpers, tool_emojis, convenience functions that were added after the PR branched). Co-authored-by: Peter O'Mallet --- cli.py | 1 + hermes_cli/colors.py | 121 ++++++++++++++++++ hermes_cli/config.py | 1 + hermes_cli/skin_engine.py | 179 ++++++++++++++++++++++++++- tests/hermes_cli/test_skin_engine.py | 66 ++++++++++ 5 files changed, 362 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index fb44790832b..febe3278998 100755 --- a/cli.py +++ b/cli.py @@ -214,6 +214,7 @@ def load_cli_config() -> Dict[str, Any]: "streaming": False, "show_cost": False, "skin": "default", + "theme_mode": "auto", }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding diff --git a/hermes_cli/colors.py b/hermes_cli/colors.py index d30f99c62d1..415db159116 100644 --- a/hermes_cli/colors.py +++ b/hermes_cli/colors.py @@ -1,5 +1,6 @@ """Shared ANSI color utilities for Hermes CLI modules.""" +import os import sys @@ -20,3 +21,123 @@ def color(text: str, *codes) -> str: if not sys.stdout.isatty(): return text return "".join(codes) + text + Colors.RESET + + +# ============================================================================= +# Terminal background detection (light vs dark) +# ============================================================================= + + +def _detect_via_colorfgbg() -> str: + """Check the COLORFGBG environment variable. + + Some terminals (rxvt, xterm, iTerm2) set COLORFGBG to ``;`` + where bg >= 8 usually means a dark background. + Returns "light", "dark", or "unknown". + """ + val = os.environ.get("COLORFGBG", "") + if not val: + return "unknown" + parts = val.split(";") + try: + bg = int(parts[-1]) + except (ValueError, IndexError): + return "unknown" + # Standard terminal colors 0-6 are dark, 7+ are light. + # bg < 7 → dark background; bg >= 7 → light background. + if bg >= 7: + return "light" + return "dark" + + +def _detect_via_macos_appearance() -> str: + """Check macOS AppleInterfaceStyle via ``defaults read``. + + Returns "light", "dark", or "unknown". + """ + if sys.platform != "darwin": + return "unknown" + try: + import subprocess + result = subprocess.run( + ["defaults", "read", "-g", "AppleInterfaceStyle"], + capture_output=True, text=True, timeout=2, + ) + if result.returncode == 0 and "dark" in result.stdout.lower(): + return "dark" + # If the key doesn't exist, macOS is in light mode. + return "light" + except Exception: + return "unknown" + + +def _detect_via_osc11() -> str: + """Query the terminal background colour via the OSC 11 escape sequence. + + Writes ``\\e]11;?\\a`` and reads the response to determine luminance. + Only works when stdin/stdout are connected to a real TTY (not piped). + Returns "light", "dark", or "unknown". + """ + if sys.platform == "win32": + return "unknown" + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return "unknown" + try: + import select + import termios + import tty + + fd = sys.stdin.fileno() + old_attrs = termios.tcgetattr(fd) + try: + tty.setraw(fd) + # Send OSC 11 query + sys.stdout.write("\x1b]11;?\x07") + sys.stdout.flush() + # Wait briefly for response + if not select.select([fd], [], [], 0.1)[0]: + return "unknown" + response = b"" + while select.select([fd], [], [], 0.05)[0]: + response += os.read(fd, 128) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) + + # Parse response: \x1b]11;rgb:RRRR/GGGG/BBBB\x07 (or \x1b\\) + text = response.decode("latin-1", errors="replace") + if "rgb:" not in text: + return "unknown" + rgb_part = text.split("rgb:")[-1].split("\x07")[0].split("\x1b")[0] + channels = rgb_part.split("/") + if len(channels) < 3: + return "unknown" + # Each channel is 2 or 4 hex digits; normalise to 0-255 + vals = [] + for ch in channels[:3]: + ch = ch.strip() + if len(ch) <= 2: + vals.append(int(ch, 16)) + else: + vals.append(int(ch[:2], 16)) # take high byte + # Perceived luminance (ITU-R BT.601) + luminance = 0.299 * vals[0] + 0.587 * vals[1] + 0.114 * vals[2] + return "light" if luminance > 128 else "dark" + except Exception: + return "unknown" + + +def detect_terminal_background() -> str: + """Detect whether the terminal has a light or dark background. + + Tries three strategies in order: + 1. COLORFGBG environment variable + 2. macOS appearance setting + 3. OSC 11 escape sequence query + + Returns "light", "dark", or "unknown" if detection fails. + """ + for detector in (_detect_via_colorfgbg, _detect_via_macos_appearance, _detect_via_osc11): + result = detector() + if result != "unknown": + return result + return "unknown" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ae0d9cb75f7..834b8a3fcbd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -233,6 +233,7 @@ DEFAULT_CONFIG = { "streaming": False, "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", + "theme_mode": "auto", }, # Privacy settings diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 980ed8b1fbc..7ef0ad4c7e4 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -114,6 +114,7 @@ class SkinConfig: name: str description: str = "" colors: Dict[str, str] = field(default_factory=dict) + colors_light: Dict[str, str] = field(default_factory=dict) spinner: Dict[str, Any] = field(default_factory=dict) branding: Dict[str, str] = field(default_factory=dict) tool_prefix: str = "┊" @@ -122,7 +123,12 @@ class SkinConfig: banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS) def get_color(self, key: str, fallback: str = "") -> str: - """Get a color value with fallback.""" + """Get a color value with fallback. + + In light theme mode, returns the light override if available. + """ + if get_theme_mode() == "light" and key in self.colors_light: + return self.colors_light[key] return self.colors.get(key, fallback) def get_spinner_list(self, key: str) -> List[str]: @@ -168,6 +174,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#DAA520", "session_border": "#8B8682", }, + "colors_light": { + "banner_border": "#7A5A00", + "banner_title": "#6B4C00", + "banner_accent": "#7A5500", + "banner_dim": "#8B7355", + "banner_text": "#3D2B00", + "prompt": "#3D2B00", + "ui_accent": "#7A5500", + "ui_label": "#01579B", + "ui_ok": "#1B5E20", + "input_rule": "#7A5A00", + "response_border": "#6B4C00", + "session_label": "#5C4300", + "session_border": "#8B7355", + }, "spinner": { # Empty = use hardcoded defaults in display.py }, @@ -201,6 +222,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#C7A96B", "session_border": "#6E584B", }, + "colors_light": { + "banner_border": "#6B1010", + "banner_title": "#5C4300", + "banner_accent": "#8B1A1A", + "banner_dim": "#5C4030", + "banner_text": "#3A1800", + "prompt": "#3A1800", + "ui_accent": "#8B1A1A", + "ui_label": "#5C4300", + "ui_ok": "#1B5E20", + "input_rule": "#6B1010", + "response_border": "#7A1515", + "session_label": "#5C4300", + "session_border": "#5C4A3A", + }, "spinner": { "waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"], "thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"], @@ -265,6 +301,22 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#888888", "session_border": "#555555", }, + "colors_light": { + "banner_border": "#333333", + "banner_title": "#222222", + "banner_accent": "#333333", + "banner_dim": "#555555", + "banner_text": "#333333", + "prompt": "#222222", + "ui_accent": "#333333", + "ui_label": "#444444", + "ui_ok": "#444444", + "ui_error": "#333333", + "input_rule": "#333333", + "response_border": "#444444", + "session_label": "#444444", + "session_border": "#666666", + }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -296,6 +348,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#7eb8f6", "session_border": "#4b5563", }, + "colors_light": { + "banner_border": "#1A3A7A", + "banner_title": "#1A3570", + "banner_accent": "#1E4090", + "banner_dim": "#3B4555", + "banner_text": "#1A2A50", + "prompt": "#1A2A50", + "ui_accent": "#1A3570", + "ui_label": "#1E3A80", + "ui_ok": "#1B5E20", + "input_rule": "#1A3A7A", + "response_border": "#2A4FA0", + "session_label": "#1A3570", + "session_border": "#5A6070", + }, "spinner": {}, "branding": { "agent_name": "Hermes Agent", @@ -327,6 +394,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#A9DFFF", "session_border": "#496884", }, + "colors_light": { + "banner_border": "#0D3060", + "banner_title": "#0D3060", + "banner_accent": "#154080", + "banner_dim": "#2A4565", + "banner_text": "#0A2850", + "prompt": "#0A2850", + "ui_accent": "#0D3060", + "ui_label": "#0D3060", + "ui_ok": "#1B5E20", + "input_rule": "#0D3060", + "response_border": "#1A5090", + "session_label": "#0D3060", + "session_border": "#3A5575", + }, "spinner": { "waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"], "thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"], @@ -391,6 +473,23 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#919191", "session_border": "#656565", }, + "colors_light": { + "banner_border": "#666666", + "banner_title": "#222222", + "banner_accent": "#333333", + "banner_dim": "#555555", + "banner_text": "#333333", + "prompt": "#222222", + "ui_accent": "#333333", + "ui_label": "#444444", + "ui_ok": "#444444", + "ui_error": "#333333", + "ui_warn": "#444444", + "input_rule": "#666666", + "response_border": "#555555", + "session_label": "#444444", + "session_border": "#777777", + }, "spinner": { "waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"], "thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"], @@ -456,6 +555,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "session_label": "#FFD39A", "session_border": "#6C4724", }, + "colors_light": { + "banner_border": "#7A3511", + "banner_title": "#5C2D00", + "banner_accent": "#8B4000", + "banner_dim": "#5A3A1A", + "banner_text": "#3A1E00", + "prompt": "#3A1E00", + "ui_accent": "#8B4000", + "ui_label": "#5C2D00", + "ui_ok": "#1B5E20", + "input_rule": "#7A3511", + "response_border": "#8B4513", + "session_label": "#5C2D00", + "session_border": "#6B5540", + }, "spinner": { "waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"], "thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"], @@ -509,6 +623,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { _active_skin: Optional[SkinConfig] = None _active_skin_name: str = "default" +_theme_mode: str = "auto" +_resolved_theme_mode: Optional[str] = None def _skins_dir() -> Path: @@ -536,6 +652,8 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: default = _BUILTIN_SKINS["default"] colors = dict(default.get("colors", {})) colors.update(data.get("colors", {})) + colors_light = dict(default.get("colors_light", {})) + colors_light.update(data.get("colors_light", {})) spinner = dict(default.get("spinner", {})) spinner.update(data.get("spinner", {})) branding = dict(default.get("branding", {})) @@ -545,6 +663,7 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: name=data.get("name", "unknown"), description=data.get("description", ""), colors=colors, + colors_light=colors_light, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), @@ -625,6 +744,39 @@ def get_active_skin_name() -> str: return _active_skin_name +def get_theme_mode() -> str: + """Return the resolved theme mode: "light" or "dark". + + When ``_theme_mode`` is ``"auto"``, detection is attempted once and cached. + If detection returns ``"unknown"``, defaults to ``"dark"``. + """ + global _resolved_theme_mode + if _theme_mode in ("light", "dark"): + return _theme_mode + # Auto mode — detect and cache + if _resolved_theme_mode is None: + try: + from hermes_cli.colors import detect_terminal_background + detected = detect_terminal_background() + except Exception: + detected = "unknown" + _resolved_theme_mode = detected if detected in ("light", "dark") else "dark" + return _resolved_theme_mode + + +def set_theme_mode(mode: str) -> None: + """Set the theme mode to "light", "dark", or "auto".""" + global _theme_mode, _resolved_theme_mode + _theme_mode = mode + # Reset cached detection so it re-runs on next get_theme_mode() if auto + _resolved_theme_mode = None + + +def get_theme_mode_setting() -> str: + """Return the raw theme mode setting (may be "auto", "light", or "dark").""" + return _theme_mode + + def init_skin_from_config(config: dict) -> None: """Initialize the active skin from CLI config at startup. @@ -637,6 +789,13 @@ def init_skin_from_config(config: dict) -> None: else: set_active_skin("default") + # Theme mode + theme_mode = display.get("theme_mode", "auto") + if isinstance(theme_mode, str) and theme_mode.strip(): + set_theme_mode(theme_mode.strip()) + else: + set_theme_mode("auto") + # ============================================================================= # Convenience helpers for CLI modules @@ -690,6 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: warn = skin.get_color("ui_warn", "#FF8C00") error = skin.get_color("ui_error", "#FF6B6B") + # Use lighter background colours for completion menus in light mode + if get_theme_mode() == "light": + menu_bg = "bg:#e8e8e8" + menu_sel_bg = "bg:#d0d0d0" + else: + menu_bg = "bg:#1a1a2e" + menu_sel_bg = "bg:#333355" + return { "input-area": prompt, "placeholder": f"{dim} italic", @@ -698,11 +865,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: "hint": f"{dim} italic", "input-rule": input_rule, "image-badge": f"{label} bold", - "completion-menu": f"bg:#1a1a2e {text}", - "completion-menu.completion": f"bg:#1a1a2e {text}", - "completion-menu.completion.current": f"bg:#333355 {title}", - "completion-menu.meta.completion": f"bg:#1a1a2e {dim}", - "completion-menu.meta.completion.current": f"bg:#333355 {label}", + "completion-menu": f"{menu_bg} {text}", + "completion-menu.completion": f"{menu_bg} {text}", + "completion-menu.completion.current": f"{menu_sel_bg} {title}", + "completion-menu.meta.completion": f"{menu_bg} {dim}", + "completion-menu.meta.completion.current": f"{menu_sel_bg} {label}", "clarify-border": input_rule, "clarify-title": f"{title} bold", "clarify-question": f"{text} bold", diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6a5a032f1c6..7732007df20 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -13,9 +13,13 @@ def reset_skin_state(): from hermes_cli import skin_engine skin_engine._active_skin = None skin_engine._active_skin_name = "default" + skin_engine._theme_mode = "auto" + skin_engine._resolved_theme_mode = None yield skin_engine._active_skin = None skin_engine._active_skin_name = "default" + skin_engine._theme_mode = "auto" + skin_engine._resolved_theme_mode = None class TestSkinConfig: @@ -312,3 +316,65 @@ class TestCliBrandingHelpers: assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" + + +class TestThemeMode: + def test_get_theme_mode_defaults_to_dark_on_unknown(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("auto") + # In a test env, detection returns "unknown" → defaults to "dark" + with patch("hermes_cli.colors.detect_terminal_background", return_value="unknown"): + from hermes_cli import skin_engine + skin_engine._resolved_theme_mode = None # force re-detection + assert get_theme_mode() == "dark" + + def test_set_theme_mode_light(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("light") + assert get_theme_mode() == "light" + + def test_set_theme_mode_dark(self): + from hermes_cli.skin_engine import get_theme_mode, set_theme_mode + + set_theme_mode("dark") + assert get_theme_mode() == "dark" + + def test_get_color_respects_light_mode(self): + from hermes_cli.skin_engine import SkinConfig, set_theme_mode + + skin = SkinConfig( + name="test", + colors={"banner_title": "#FFD700", "prompt": "#FFF8DC"}, + colors_light={"banner_title": "#6B4C00"}, + ) + set_theme_mode("light") + assert skin.get_color("banner_title") == "#6B4C00" + # Key not in colors_light falls back to colors + assert skin.get_color("prompt") == "#FFF8DC" + + def test_get_color_falls_back_in_dark_mode(self): + from hermes_cli.skin_engine import SkinConfig, set_theme_mode + + skin = SkinConfig( + name="test", + colors={"banner_title": "#FFD700", "prompt": "#FFF8DC"}, + colors_light={"banner_title": "#6B4C00"}, + ) + set_theme_mode("dark") + assert skin.get_color("banner_title") == "#FFD700" + assert skin.get_color("prompt") == "#FFF8DC" + + def test_init_skin_from_config_reads_theme_mode(self): + from hermes_cli.skin_engine import init_skin_from_config, get_theme_mode_setting + + init_skin_from_config({"display": {"skin": "default", "theme_mode": "light"}}) + assert get_theme_mode_setting() == "light" + + def test_builtin_skins_have_colors_light(self): + from hermes_cli.skin_engine import _BUILTIN_SKINS, _build_skin_config + + for name, data in _BUILTIN_SKINS.items(): + skin = _build_skin_config(data) + assert len(skin.colors_light) > 0, f"Skin '{name}' has empty colors_light" From 71c6b1ee992fa17cb47632092ef463cc758a0fce Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:51:49 -0700 Subject: [PATCH 6/9] fix: remove ANTHROPIC_BASE_URL env var to avoid collisions (#1675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANTHROPIC_BASE_URL collides with Claude Code and other Anthropic tooling. Remove it from the Anthropic provider — base URL overrides should go through config.yaml model.base_url instead. The Alibaba/DashScope provider has its own dedicated base URL and API key env vars which don't collide with anything. --- hermes_cli/auth.py | 1 - hermes_cli/config.py | 8 -------- hermes_cli/runtime_provider.py | 4 +--- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f1341d5d60b..54573acf180 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -138,7 +138,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="api_key", inference_base_url="https://api.anthropic.com", api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), - base_url_env_var="ANTHROPIC_BASE_URL", ), "alibaba": ProviderConfig( id="alibaba", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 834b8a3fcbd..62d8a19a773 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -493,14 +493,6 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "provider", }, - "ANTHROPIC_BASE_URL": { - "description": "Custom Anthropic-compatible API base URL (e.g. Alibaba Cloud DashScope)", - "prompt": "Anthropic Base URL", - "url": "", - "password": False, - "category": "provider", - "advanced": True, - }, "DASHSCOPE_API_KEY": { "description": "Alibaba Cloud DashScope API key for Qwen models", "prompt": "DashScope API Key", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index db96edccd15..34ae43be8f7 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -276,12 +276,10 @@ def resolve_runtime_provider( "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " "run 'claude setup-token', or authenticate with 'claude /login'." ) - # Support custom Anthropic-compatible endpoints via ANTHROPIC_BASE_URL - base_url = os.getenv("ANTHROPIC_BASE_URL", "").strip() or "https://api.anthropic.com" return { "provider": "anthropic", "api_mode": "anthropic_messages", - "base_url": base_url, + "base_url": "https://api.anthropic.com", "api_key": token, "source": "env", "requested_provider": requested_provider, From ef67037f8ee538ed6995655374ed994b560d4342 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:52:34 -0700 Subject: [PATCH 7/9] feat: add SMS (Telnyx) platform adapter Implement SMS as a first-class messaging platform following ADDING_A_PLATFORM.md checklist. All 16 integration points covered: - gateway/platforms/sms.py: Core adapter with aiohttp webhook server, Telnyx REST API send, markdown stripping, 1600-char chunking, echo loop prevention, multi-number reply-from tracking - gateway/config.py: Platform.SMS enum + env override block - gateway/run.py: Adapter factory + auth maps (SMS_ALLOWED_USERS, SMS_ALLOW_ALL_USERS) - toolsets.py: hermes-sms toolset + included in hermes-gateway - cron/scheduler.py: SMS in platform_map for cron delivery - tools/send_message_tool.py: SMS routing + _send_sms() standalone sender - tools/cronjob_tools.py: 'sms' in deliver description - gateway/channel_directory.py: SMS in session-based discovery - agent/prompt_builder.py: SMS platform hint (plain text, concise) - hermes_cli/status.py: SMS in platforms status display - hermes_cli/gateway.py: SMS in setup wizard with Telnyx instructions - pyproject.toml: sms optional dependency group (aiohttp>=3.9.0) - tests/gateway/test_sms.py: Unit tests for config, format, truncate, echo prevention, requirements, toolset integration Co-authored-by: sunsakis Co-authored-by: Claude Opus 4.6 --- agent/prompt_builder.py | 6 + cron/scheduler.py | 1 + gateway/channel_directory.py | 2 +- gateway/config.py | 19 +++ gateway/platforms/sms.py | 282 +++++++++++++++++++++++++++++++++++ gateway/run.py | 11 +- hermes_cli/gateway.py | 24 +++ hermes_cli/status.py | 1 + pyproject.toml | 2 + tests/gateway/test_sms.py | 240 +++++++++++++++++++++++++++++ tools/cronjob_tools.py | 2 +- tools/send_message_tool.py | 51 +++++++ toolsets.py | 8 +- 13 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 gateway/platforms/sms.py create mode 100644 tests/gateway/test_sms.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b71a962931c..1b59c40f264 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -157,6 +157,12 @@ PLATFORM_HINTS = { "the scheduled destination, put it directly in your final response. Use " "send_message only for additional or different targets." ), + "sms": ( + "You are communicating via SMS text messaging. Keep responses concise " + "and plain text only -- no markdown, no formatting. SMS has a 1600 " + "character limit per message (10 segments). Longer replies are split " + "across multiple messages. Be brief and direct." + ), "cli": ( "You are a CLI AI Agent. Try not to use markdown but simple text " "renderable inside a terminal." diff --git a/cron/scheduler.py b/cron/scheduler.py index ded88ef53e5..a3636883f01 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -132,6 +132,7 @@ def _deliver_result(job: dict, content: str) -> None: "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, "email": Platform.EMAIL, + "sms": Platform.SMS, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 9154741f6ec..ec8d2a84b37 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -63,7 +63,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal", "email"): + for plat_name in ("telegram", "whatsapp", "signal", "email", "sms"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index 0b01ed26c9f..cf8fc1faecc 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -42,6 +42,7 @@ class Platform(Enum): SIGNAL = "signal" HOMEASSISTANT = "homeassistant" EMAIL = "email" + SMS = "sms" @dataclass @@ -225,6 +226,9 @@ class GatewayConfig: # WhatsApp uses enabled flag only (bridge handles auth) elif platform == Platform.WHATSAPP: connected.append(platform) + # SMS uses api_key from env (checked via extra or env var) + elif platform == Platform.SMS and os.getenv("TELNYX_API_KEY"): + connected.append(platform) # Signal uses extra dict for config (http_url + account) elif platform == Platform.SIGNAL and config.extra.get("http_url"): connected.append(platform) @@ -563,6 +567,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), ) + # SMS (Telnyx) + telnyx_key = os.getenv("TELNYX_API_KEY") + if telnyx_key: + if Platform.SMS not in config.platforms: + config.platforms[Platform.SMS] = PlatformConfig() + config.platforms[Platform.SMS].enabled = True + config.platforms[Platform.SMS].api_key = telnyx_key + sms_home = os.getenv("SMS_HOME_CHANNEL") + if sms_home: + config.platforms[Platform.SMS].home_channel = HomeChannel( + platform=Platform.SMS, + chat_id=sms_home, + name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/sms.py b/gateway/platforms/sms.py new file mode 100644 index 00000000000..f83ecaf976e --- /dev/null +++ b/gateway/platforms/sms.py @@ -0,0 +1,282 @@ +"""SMS (Telnyx) platform adapter. + +Connects to the Telnyx REST API for outbound SMS and runs an aiohttp +webhook server to receive inbound messages. + +Requires: + - aiohttp installed: pip install 'hermes-agent[sms]' + - TELNYX_API_KEY environment variable set + - TELNYX_FROM_NUMBERS: comma-separated E.164 numbers (e.g. +15551234567) +""" + +import asyncio +import json +import logging +import os +import re +from typing import Any, Dict, List, Optional + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +TELNYX_BASE = "https://api.telnyx.com/v2" +MAX_SMS_LENGTH = 1600 # ~10 SMS segments +DEFAULT_WEBHOOK_PORT = 8080 + +# E.164 phone number pattern for redaction +_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}") + + +def _redact_phone(phone: str) -> str: + """Redact a phone number for logging: +15551234567 -> +155****4567.""" + if not phone: + return "" + if len(phone) <= 8: + return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****" + return phone[:4] + "****" + phone[-4:] + + +def _parse_comma_list(value: str) -> List[str]: + """Split a comma-separated string into a list, stripping whitespace.""" + return [v.strip() for v in value.split(",") if v.strip()] + + +def check_sms_requirements() -> bool: + """Check if SMS adapter dependencies are available.""" + try: + import aiohttp # noqa: F401 + except ImportError: + return False + return bool(os.getenv("TELNYX_API_KEY")) + + +class SmsAdapter(BasePlatformAdapter): + """ + Telnyx SMS <-> Hermes gateway adapter. + + Each inbound phone number gets its own Hermes session (multi-tenant). + Tracks which owned number received each user's message to reply from + the same number. + """ + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.SMS) + self._api_key: str = os.environ["TELNYX_API_KEY"] + self._webhook_port: int = int( + os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) + ) + # Set of owned numbers + self._from_numbers: set = set( + _parse_comma_list(os.getenv("TELNYX_FROM_NUMBERS", "")) + ) + # Runtime map: user phone -> which owned number to reply from + self._reply_from: Dict[str, str] = {} + self._runner = None + + # ------------------------------------------------------------------ + # Required abstract methods + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + import aiohttp + from aiohttp import web + + app = web.Application() + app.router.add_post("/webhooks/telnyx", self._handle_webhook) + app.router.add_get("/health", lambda _: web.Response(text="ok")) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port) + await site.start() + self._running = True + + from_display = ", ".join(_redact_phone(n) for n in self._from_numbers) or "(none)" + logger.info( + "[sms] Webhook server listening on port %d, from numbers: %s", + self._webhook_port, + from_display, + ) + return True + + async def disconnect(self) -> None: + if self._runner: + await self._runner.cleanup() + self._runner = None + self._running = False + logger.info("[sms] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + import aiohttp + + from_number = self._get_reply_from(chat_id, metadata) + formatted = self.format_message(content) + chunks = self.truncate_message(formatted) + last_result = SendResult(success=True) + + async with aiohttp.ClientSession() as session: + for i, chunk in enumerate(chunks): + payload = {"from": from_number, "to": chat_id, "text": chunk} + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + try: + async with session.post( + f"{TELNYX_BASE}/messages", + json=payload, + headers=headers, + ) as resp: + body = await resp.json() + if resp.status >= 400: + logger.error( + "[sms] send failed %s: %s %s", + _redact_phone(chat_id), + resp.status, + body, + ) + return SendResult( + success=False, + error=f"Telnyx {resp.status}: {body}", + ) + msg_id = body.get("data", {}).get("id", "") + last_result = SendResult(success=True, message_id=msg_id) + except Exception as e: + logger.error("[sms] send error %s: %s", _redact_phone(chat_id), e) + return SendResult(success=False, error=str(e)) + + return last_result + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "dm"} + + # ------------------------------------------------------------------ + # SMS-specific formatting + # ------------------------------------------------------------------ + + def format_message(self, content: str) -> str: + """Strip markdown -- SMS renders it as literal characters.""" + content = re.sub(r"\*\*(.+?)\*\*", r"\1", content, flags=re.DOTALL) + content = re.sub(r"\*(.+?)\*", r"\1", content, flags=re.DOTALL) + content = re.sub(r"__(.+?)__", r"\1", content, flags=re.DOTALL) + content = re.sub(r"_(.+?)_", r"\1", content, flags=re.DOTALL) + content = re.sub(r"```[a-z]*\n?", "", content) + content = re.sub(r"`(.+?)`", r"\1", content) + content = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE) + content = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content) + content = re.sub(r"\n{3,}", "\n\n", content) + return content.strip() + + def truncate_message( + self, content: str, max_length: int = MAX_SMS_LENGTH + ) -> List[str]: + """Split into <=1600-char chunks (10 SMS segments).""" + if len(content) <= max_length: + return [content] + chunks: List[str] = [] + while content: + if len(content) <= max_length: + chunks.append(content) + break + split_at = content.rfind("\n", 0, max_length) + if split_at < max_length // 2: + split_at = content.rfind(" ", 0, max_length) + if split_at < 1: + split_at = max_length + chunks.append(content[:split_at].strip()) + content = content[split_at:].strip() + return chunks + + # ------------------------------------------------------------------ + # Telnyx webhook handler + # ------------------------------------------------------------------ + + async def _handle_webhook(self, request) -> "aiohttp.web.Response": + from aiohttp import web + + try: + raw = await request.read() + body = json.loads(raw.decode("utf-8")) + except Exception as e: + logger.error("[sms] webhook parse error: %s", e) + return web.json_response({"error": "invalid json"}, status=400) + + # Only handle inbound messages + if body.get("data", {}).get("event_type") != "message.received": + return web.json_response({"received": True}) + + payload = body["data"]["payload"] + from_number: str = payload.get("from", {}).get("phone_number", "") + to_list = payload.get("to", []) + to_number: str = to_list[0].get("phone_number", "") if to_list else "" + text: str = payload.get("text", "").strip() + + if not from_number or not text: + return web.json_response({"received": True}) + + # Ignore messages sent FROM one of our own numbers (echo loop prevention) + if from_number in self._from_numbers: + logger.debug("[sms] ignoring echo from own number %s", _redact_phone(from_number)) + return web.json_response({"received": True}) + + # Remember which owned number received this user's message + if to_number and to_number in self._from_numbers: + self._reply_from[from_number] = to_number + + logger.info( + "[sms] inbound from %s -> %s: %s", + _redact_phone(from_number), + _redact_phone(to_number), + text[:80], + ) + + source = self.build_source( + chat_id=from_number, + chat_name=from_number, + chat_type="dm", + user_id=from_number, + user_name=from_number, + ) + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=body, + message_id=payload.get("id"), + ) + + # Non-blocking: Telnyx expects a fast 200 + asyncio.create_task(self.handle_message(event)) + return web.json_response({"received": True}) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_reply_from( + self, user_phone: str, metadata: Optional[Dict] = None + ) -> str: + """Determine which owned number to send from.""" + if metadata and "from_number" in metadata: + return metadata["from_number"] + if user_phone in self._reply_from: + return self._reply_from[user_phone] + if self._from_numbers: + return next(iter(self._from_numbers)) + raise RuntimeError( + "No FROM number configured (TELNYX_FROM_NUMBERS) and no prior " + "reply_from mapping for this user" + ) diff --git a/gateway/run.py b/gateway/run.py index f1e1be68ac7..aed55e8b8f2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -848,7 +848,7 @@ class GatewayRunner: os.getenv(v) for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS", "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", - "GATEWAY_ALLOWED_USERS") + "SMS_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") if not _any_allowlist and not _allow_all: @@ -1132,6 +1132,13 @@ class GatewayRunner: return None return EmailAdapter(config) + elif platform == Platform.SMS: + from gateway.platforms.sms import SmsAdapter, check_sms_requirements + if not check_sms_requirements(): + logger.warning("SMS: aiohttp not installed or TELNYX_API_KEY not set. Run: pip install 'hermes-agent[sms]'") + return None + return SmsAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -1162,6 +1169,7 @@ class GatewayRunner: Platform.SLACK: "SLACK_ALLOWED_USERS", Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", Platform.EMAIL: "EMAIL_ALLOWED_USERS", + Platform.SMS: "SMS_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -1170,6 +1178,7 @@ class GatewayRunner: Platform.SLACK: "SLACK_ALLOW_ALL_USERS", Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", + Platform.SMS: "SMS_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 73956dc916b..3f63a1d18e0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1013,6 +1013,30 @@ _PLATFORMS = [ "emoji": "📡", "token_var": "SIGNAL_HTTP_URL", }, + { + "key": "sms", + "label": "SMS (Telnyx)", + "emoji": "📱", + "token_var": "TELNYX_API_KEY", + "setup_instructions": [ + "1. Create a Telnyx account at https://portal.telnyx.com/", + "2. Buy a phone number with SMS capability", + "3. Create an API key: API Keys → Create API Key", + "4. Set up a Messaging Profile and assign your number to it", + "5. Configure the webhook URL: https://your-server/webhooks/telnyx", + ], + "vars": [ + {"name": "TELNYX_API_KEY", "prompt": "Telnyx API key", "password": True, + "help": "Paste the API key from step 3 above."}, + {"name": "TELNYX_FROM_NUMBERS", "prompt": "From numbers (comma-separated E.164, e.g. +15551234567)", "password": False, + "help": "The Telnyx phone number(s) Hermes will send SMS from."}, + {"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated E.164)", "password": False, + "is_allowlist": True, + "help": "Only messages from these phone numbers will be processed."}, + {"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone (for cron/notification delivery, or empty)", "password": False, + "help": "A phone number where cron job outputs are delivered."}, + ], + }, { "key": "email", "label": "Email", diff --git a/hermes_cli/status.py b/hermes_cli/status.py index be490e9306c..ccdeca4d0bb 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -252,6 +252,7 @@ def show_status(args): "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"), "Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"), + "SMS": ("TELNYX_API_KEY", "SMS_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/pyproject.toml b/pyproject.toml index 74d8f1178b5..b7b1f167d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ pty = [ honcho = ["honcho-ai>=2.0.1"] mcp = ["mcp>=1.2.0"] homeassistant = ["aiohttp>=3.9.0"] +sms = ["aiohttp>=3.9.0"] acp = ["agent-client-protocol>=0.8.1,<1.0"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git", @@ -80,6 +81,7 @@ all = [ "hermes-agent[homeassistant]", "hermes-agent[acp]", "hermes-agent[voice]", + "hermes-agent[sms]", ] [project.scripts] diff --git a/tests/gateway/test_sms.py b/tests/gateway/test_sms.py new file mode 100644 index 00000000000..e3d927bb3c8 --- /dev/null +++ b/tests/gateway/test_sms.py @@ -0,0 +1,240 @@ +"""Tests for SMS (Telnyx) platform adapter.""" +import json +import pytest +from unittest.mock import MagicMock, patch, AsyncMock + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Platform & Config +# --------------------------------------------------------------------------- + +class TestSmsPlatformEnum: + def test_sms_enum_exists(self): + assert Platform.SMS.value == "sms" + + def test_sms_in_platform_list(self): + platforms = [p.value for p in Platform] + assert "sms" in platforms + + +class TestSmsConfigLoading: + def test_apply_env_overrides_sms(self, monkeypatch): + monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.SMS in config.platforms + sc = config.platforms[Platform.SMS] + assert sc.enabled is True + assert sc.api_key == "KEY_test123" + + def test_sms_not_loaded_without_key(self, monkeypatch): + monkeypatch.delenv("TELNYX_API_KEY", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + assert Platform.SMS not in config.platforms + + def test_connected_platforms_includes_sms(self, monkeypatch): + monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + connected = config.get_connected_platforms() + assert Platform.SMS in connected + + def test_sms_home_channel(self, monkeypatch): + monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") + monkeypatch.setenv("SMS_HOME_CHANNEL", "+15559876543") + monkeypatch.setenv("SMS_HOME_CHANNEL_NAME", "Owner") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + home = config.get_home_channel(Platform.SMS) + assert home is not None + assert home.chat_id == "+15559876543" + assert home.name == "Owner" + + +# --------------------------------------------------------------------------- +# Adapter format / truncate +# --------------------------------------------------------------------------- + +class TestSmsFormatMessage: + def setup_method(self): + from gateway.platforms.sms import SmsAdapter + config = PlatformConfig(enabled=True, api_key="test_key") + with patch.dict("os.environ", {"TELNYX_API_KEY": "test_key"}): + self.adapter = SmsAdapter(config) + + def test_strip_bold(self): + assert self.adapter.format_message("**bold**") == "bold" + + def test_strip_italic(self): + assert self.adapter.format_message("*italic*") == "italic" + + def test_strip_code_block(self): + result = self.adapter.format_message("```python\ncode\n```") + assert "```" not in result + assert "code" in result + + def test_strip_inline_code(self): + assert self.adapter.format_message("`code`") == "code" + + def test_strip_headers(self): + assert self.adapter.format_message("## Header") == "Header" + + def test_strip_links(self): + assert self.adapter.format_message("[click](http://example.com)") == "click" + + def test_collapse_newlines(self): + result = self.adapter.format_message("a\n\n\n\nb") + assert result == "a\n\nb" + + +class TestSmsTruncateMessage: + def setup_method(self): + from gateway.platforms.sms import SmsAdapter + config = PlatformConfig(enabled=True, api_key="test_key") + with patch.dict("os.environ", {"TELNYX_API_KEY": "test_key"}): + self.adapter = SmsAdapter(config) + + def test_short_message_single_chunk(self): + msg = "Hello, world!" + chunks = self.adapter.truncate_message(msg) + assert len(chunks) == 1 + assert chunks[0] == msg + + def test_long_message_splits(self): + msg = "a " * 1000 # 2000 chars + chunks = self.adapter.truncate_message(msg) + assert len(chunks) >= 2 + for chunk in chunks: + assert len(chunk) <= 1600 + + def test_custom_max_length(self): + msg = "Hello " * 20 + chunks = self.adapter.truncate_message(msg, max_length=50) + assert all(len(c) <= 50 for c in chunks) + + +# --------------------------------------------------------------------------- +# Echo loop prevention +# --------------------------------------------------------------------------- + +class TestSmsEchoLoop: + def test_own_number_ignored(self): + from gateway.platforms.sms import SmsAdapter + config = PlatformConfig(enabled=True, api_key="test_key") + with patch.dict("os.environ", { + "TELNYX_API_KEY": "test_key", + "TELNYX_FROM_NUMBERS": "+15551234567,+15559876543", + }): + adapter = SmsAdapter(config) + assert "+15551234567" in adapter._from_numbers + assert "+15559876543" in adapter._from_numbers + + +# --------------------------------------------------------------------------- +# Auth maps +# --------------------------------------------------------------------------- + +class TestSmsAuthMaps: + def test_sms_in_allowed_users_map(self): + """SMS should be in the platform auth maps in run.py.""" + # Verify the env var names are consistent + import os + os.environ.setdefault("SMS_ALLOWED_USERS", "+15551234567") + assert os.getenv("SMS_ALLOWED_USERS") == "+15551234567" + + def test_sms_allow_all_env_var(self): + """SMS_ALLOW_ALL_USERS should be recognized.""" + import os + os.environ.setdefault("SMS_ALLOW_ALL_USERS", "true") + assert os.getenv("SMS_ALLOW_ALL_USERS") == "true" + + +# --------------------------------------------------------------------------- +# Requirements check +# --------------------------------------------------------------------------- + +class TestSmsRequirements: + def test_check_sms_requirements_with_key(self, monkeypatch): + monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") + from gateway.platforms.sms import check_sms_requirements + # aiohttp is available in test environment + assert check_sms_requirements() is True + + def test_check_sms_requirements_without_key(self, monkeypatch): + monkeypatch.delenv("TELNYX_API_KEY", raising=False) + from gateway.platforms.sms import check_sms_requirements + assert check_sms_requirements() is False + + +# --------------------------------------------------------------------------- +# Toolset & integration points +# --------------------------------------------------------------------------- + +class TestSmsToolset: + def test_hermes_sms_toolset_exists(self): + from toolsets import get_toolset + ts = get_toolset("hermes-sms") + assert ts is not None + assert "hermes-sms" in ts.get("description", "").lower() or "sms" in ts.get("description", "").lower() + + def test_hermes_gateway_includes_sms(self): + from toolsets import get_toolset + gw = get_toolset("hermes-gateway") + assert "hermes-sms" in gw["includes"] + + +class TestSmsPlatformHints: + def test_sms_in_platform_hints(self): + from agent.prompt_builder import PLATFORM_HINTS + assert "sms" in PLATFORM_HINTS + assert "SMS" in PLATFORM_HINTS["sms"] or "sms" in PLATFORM_HINTS["sms"].lower() + + +class TestSmsCronDelivery: + def test_sms_in_cron_platform_map(self): + """Verify the cron scheduler can resolve 'sms' platform.""" + # The platform_map in _deliver_result should include sms + from gateway.config import Platform + assert Platform.SMS.value == "sms" + + +class TestSmsSendMessageTool: + def test_sms_in_send_message_platform_map(self): + """The send_message tool should recognize 'sms' as a valid platform.""" + # We verify by checking that SMS is in the Platform enum + # and the code path exists + from gateway.config import Platform + assert hasattr(Platform, "SMS") + + +class TestSmsChannelDirectory: + def test_sms_in_session_discovery(self): + """Verify SMS is included in session-based channel discovery.""" + import inspect + from gateway.channel_directory import build_channel_directory + source = inspect.getsource(build_channel_directory) + assert '"sms"' in source + + +class TestSmsStatus: + def test_sms_in_status_platforms(self): + """Verify SMS appears in the status command platforms dict.""" + import inspect + from hermes_cli.status import show_status + source = inspect.getsource(show_status) + assert '"SMS"' in source or "'SMS'" in source diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index c16e2ece9cd..74b958a56d1 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id" + "description": "Delivery target: origin, local, telegram, discord, signal, sms, or platform:chat_id" }, "model": { "type": "string", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9a404adaa2a..2f0f014abe4 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -125,6 +125,7 @@ def _handle_send(args): "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, "email": Platform.EMAIL, + "sms": Platform.SMS, } platform = platform_map.get(platform_name) if not platform: @@ -334,6 +335,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_signal(pconfig.extra, chat_id, chunk) elif platform == Platform.EMAIL: result = await _send_email(pconfig.extra, chat_id, chunk) + elif platform == Platform.SMS: + result = await _send_sms(pconfig.api_key, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -562,6 +565,54 @@ async def _send_email(extra, chat_id, message): return {"error": f"Email send failed: {e}"} +async def _send_sms(api_key, chat_id, message): + """Send via Telnyx SMS REST API (one-shot, no persistent connection needed).""" + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + try: + from_number = os.getenv("TELNYX_FROM_NUMBERS", "").split(",")[0].strip() + if not from_number: + return {"error": "TELNYX_FROM_NUMBERS not configured"} + if not api_key: + api_key = os.getenv("TELNYX_API_KEY", "") + if not api_key: + return {"error": "TELNYX_API_KEY not configured"} + + # Strip markdown for SMS + text = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL) + text = re.sub(r"\*(.+?)\*", r"\1", text, flags=re.DOTALL) + text = re.sub(r"```[a-z]*\n?", "", text) + text = re.sub(r"`(.+?)`", r"\1", text) + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) + text = text.strip() + + # Chunk to 1600 chars + chunks = [text[i:i+1600] for i in range(0, len(text), 1600)] if len(text) > 1600 else [text] + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + message_ids = [] + async with aiohttp.ClientSession() as session: + for chunk in chunks: + payload = {"from": from_number, "to": chat_id, "text": chunk} + async with session.post( + "https://api.telnyx.com/v2/messages", + json=payload, + headers=headers, + ) as resp: + body = await resp.json() + if resp.status >= 400: + return {"error": f"Telnyx API error ({resp.status}): {body}"} + message_ids.append(body.get("data", {}).get("id", "")) + return {"success": True, "platform": "sms", "chat_id": chat_id, "message_ids": message_ids} + except Exception as e: + return {"error": f"SMS send failed: {e}"} + + def _check_send_message(): """Gate send_message on gateway running (always available on messaging platforms).""" platform = os.getenv("HERMES_SESSION_PLATFORM", "") diff --git a/toolsets.py b/toolsets.py index 1a73ff1b824..b7b2e48fb57 100644 --- a/toolsets.py +++ b/toolsets.py @@ -292,10 +292,16 @@ TOOLSETS = { "includes": [] }, + "hermes-sms": { + "description": "SMS bot toolset - interact with Hermes via SMS (Telnyx)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms"] } } From fd61ae13e590e2e09dc780d7301f1c94815a18d8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:53:30 -0700 Subject: [PATCH 8/9] revert: revert SMS (Telnyx) platform adapter for review This reverts commit ef67037f8ee538ed6995655374ed994b560d4342. --- agent/prompt_builder.py | 6 - cron/scheduler.py | 1 - gateway/channel_directory.py | 2 +- gateway/config.py | 19 --- gateway/platforms/sms.py | 282 ----------------------------------- gateway/run.py | 11 +- hermes_cli/gateway.py | 24 --- hermes_cli/status.py | 1 - pyproject.toml | 2 - tests/gateway/test_sms.py | 240 ----------------------------- tools/cronjob_tools.py | 2 +- tools/send_message_tool.py | 51 ------- toolsets.py | 8 +- 13 files changed, 4 insertions(+), 645 deletions(-) delete mode 100644 gateway/platforms/sms.py delete mode 100644 tests/gateway/test_sms.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 1b59c40f264..b71a962931c 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -157,12 +157,6 @@ PLATFORM_HINTS = { "the scheduled destination, put it directly in your final response. Use " "send_message only for additional or different targets." ), - "sms": ( - "You are communicating via SMS text messaging. Keep responses concise " - "and plain text only -- no markdown, no formatting. SMS has a 1600 " - "character limit per message (10 segments). Longer replies are split " - "across multiple messages. Be brief and direct." - ), "cli": ( "You are a CLI AI Agent. Try not to use markdown but simple text " "renderable inside a terminal." diff --git a/cron/scheduler.py b/cron/scheduler.py index a3636883f01..ded88ef53e5 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -132,7 +132,6 @@ def _deliver_result(job: dict, content: str) -> None: "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, "email": Platform.EMAIL, - "sms": Platform.SMS, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ec8d2a84b37..9154741f6ec 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -63,7 +63,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal", "email", "sms"): + for plat_name in ("telegram", "whatsapp", "signal", "email"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index cf8fc1faecc..0b01ed26c9f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -42,7 +42,6 @@ class Platform(Enum): SIGNAL = "signal" HOMEASSISTANT = "homeassistant" EMAIL = "email" - SMS = "sms" @dataclass @@ -226,9 +225,6 @@ class GatewayConfig: # WhatsApp uses enabled flag only (bridge handles auth) elif platform == Platform.WHATSAPP: connected.append(platform) - # SMS uses api_key from env (checked via extra or env var) - elif platform == Platform.SMS and os.getenv("TELNYX_API_KEY"): - connected.append(platform) # Signal uses extra dict for config (http_url + account) elif platform == Platform.SIGNAL and config.extra.get("http_url"): connected.append(platform) @@ -567,21 +563,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), ) - # SMS (Telnyx) - telnyx_key = os.getenv("TELNYX_API_KEY") - if telnyx_key: - if Platform.SMS not in config.platforms: - config.platforms[Platform.SMS] = PlatformConfig() - config.platforms[Platform.SMS].enabled = True - config.platforms[Platform.SMS].api_key = telnyx_key - sms_home = os.getenv("SMS_HOME_CHANNEL") - if sms_home: - config.platforms[Platform.SMS].home_channel = HomeChannel( - platform=Platform.SMS, - chat_id=sms_home, - name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"), - ) - # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/sms.py b/gateway/platforms/sms.py deleted file mode 100644 index f83ecaf976e..00000000000 --- a/gateway/platforms/sms.py +++ /dev/null @@ -1,282 +0,0 @@ -"""SMS (Telnyx) platform adapter. - -Connects to the Telnyx REST API for outbound SMS and runs an aiohttp -webhook server to receive inbound messages. - -Requires: - - aiohttp installed: pip install 'hermes-agent[sms]' - - TELNYX_API_KEY environment variable set - - TELNYX_FROM_NUMBERS: comma-separated E.164 numbers (e.g. +15551234567) -""" - -import asyncio -import json -import logging -import os -import re -from typing import Any, Dict, List, Optional - -from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, -) - -logger = logging.getLogger(__name__) - -TELNYX_BASE = "https://api.telnyx.com/v2" -MAX_SMS_LENGTH = 1600 # ~10 SMS segments -DEFAULT_WEBHOOK_PORT = 8080 - -# E.164 phone number pattern for redaction -_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}") - - -def _redact_phone(phone: str) -> str: - """Redact a phone number for logging: +15551234567 -> +155****4567.""" - if not phone: - return "" - if len(phone) <= 8: - return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****" - return phone[:4] + "****" + phone[-4:] - - -def _parse_comma_list(value: str) -> List[str]: - """Split a comma-separated string into a list, stripping whitespace.""" - return [v.strip() for v in value.split(",") if v.strip()] - - -def check_sms_requirements() -> bool: - """Check if SMS adapter dependencies are available.""" - try: - import aiohttp # noqa: F401 - except ImportError: - return False - return bool(os.getenv("TELNYX_API_KEY")) - - -class SmsAdapter(BasePlatformAdapter): - """ - Telnyx SMS <-> Hermes gateway adapter. - - Each inbound phone number gets its own Hermes session (multi-tenant). - Tracks which owned number received each user's message to reply from - the same number. - """ - - def __init__(self, config: PlatformConfig): - super().__init__(config, Platform.SMS) - self._api_key: str = os.environ["TELNYX_API_KEY"] - self._webhook_port: int = int( - os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) - ) - # Set of owned numbers - self._from_numbers: set = set( - _parse_comma_list(os.getenv("TELNYX_FROM_NUMBERS", "")) - ) - # Runtime map: user phone -> which owned number to reply from - self._reply_from: Dict[str, str] = {} - self._runner = None - - # ------------------------------------------------------------------ - # Required abstract methods - # ------------------------------------------------------------------ - - async def connect(self) -> bool: - import aiohttp - from aiohttp import web - - app = web.Application() - app.router.add_post("/webhooks/telnyx", self._handle_webhook) - app.router.add_get("/health", lambda _: web.Response(text="ok")) - - self._runner = web.AppRunner(app) - await self._runner.setup() - site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port) - await site.start() - self._running = True - - from_display = ", ".join(_redact_phone(n) for n in self._from_numbers) or "(none)" - logger.info( - "[sms] Webhook server listening on port %d, from numbers: %s", - self._webhook_port, - from_display, - ) - return True - - async def disconnect(self) -> None: - if self._runner: - await self._runner.cleanup() - self._runner = None - self._running = False - logger.info("[sms] Disconnected") - - async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - import aiohttp - - from_number = self._get_reply_from(chat_id, metadata) - formatted = self.format_message(content) - chunks = self.truncate_message(formatted) - last_result = SendResult(success=True) - - async with aiohttp.ClientSession() as session: - for i, chunk in enumerate(chunks): - payload = {"from": from_number, "to": chat_id, "text": chunk} - headers = { - "Authorization": f"Bearer {self._api_key}", - "Content-Type": "application/json", - } - try: - async with session.post( - f"{TELNYX_BASE}/messages", - json=payload, - headers=headers, - ) as resp: - body = await resp.json() - if resp.status >= 400: - logger.error( - "[sms] send failed %s: %s %s", - _redact_phone(chat_id), - resp.status, - body, - ) - return SendResult( - success=False, - error=f"Telnyx {resp.status}: {body}", - ) - msg_id = body.get("data", {}).get("id", "") - last_result = SendResult(success=True, message_id=msg_id) - except Exception as e: - logger.error("[sms] send error %s: %s", _redact_phone(chat_id), e) - return SendResult(success=False, error=str(e)) - - return last_result - - async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: - return {"name": chat_id, "type": "dm"} - - # ------------------------------------------------------------------ - # SMS-specific formatting - # ------------------------------------------------------------------ - - def format_message(self, content: str) -> str: - """Strip markdown -- SMS renders it as literal characters.""" - content = re.sub(r"\*\*(.+?)\*\*", r"\1", content, flags=re.DOTALL) - content = re.sub(r"\*(.+?)\*", r"\1", content, flags=re.DOTALL) - content = re.sub(r"__(.+?)__", r"\1", content, flags=re.DOTALL) - content = re.sub(r"_(.+?)_", r"\1", content, flags=re.DOTALL) - content = re.sub(r"```[a-z]*\n?", "", content) - content = re.sub(r"`(.+?)`", r"\1", content) - content = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE) - content = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content) - content = re.sub(r"\n{3,}", "\n\n", content) - return content.strip() - - def truncate_message( - self, content: str, max_length: int = MAX_SMS_LENGTH - ) -> List[str]: - """Split into <=1600-char chunks (10 SMS segments).""" - if len(content) <= max_length: - return [content] - chunks: List[str] = [] - while content: - if len(content) <= max_length: - chunks.append(content) - break - split_at = content.rfind("\n", 0, max_length) - if split_at < max_length // 2: - split_at = content.rfind(" ", 0, max_length) - if split_at < 1: - split_at = max_length - chunks.append(content[:split_at].strip()) - content = content[split_at:].strip() - return chunks - - # ------------------------------------------------------------------ - # Telnyx webhook handler - # ------------------------------------------------------------------ - - async def _handle_webhook(self, request) -> "aiohttp.web.Response": - from aiohttp import web - - try: - raw = await request.read() - body = json.loads(raw.decode("utf-8")) - except Exception as e: - logger.error("[sms] webhook parse error: %s", e) - return web.json_response({"error": "invalid json"}, status=400) - - # Only handle inbound messages - if body.get("data", {}).get("event_type") != "message.received": - return web.json_response({"received": True}) - - payload = body["data"]["payload"] - from_number: str = payload.get("from", {}).get("phone_number", "") - to_list = payload.get("to", []) - to_number: str = to_list[0].get("phone_number", "") if to_list else "" - text: str = payload.get("text", "").strip() - - if not from_number or not text: - return web.json_response({"received": True}) - - # Ignore messages sent FROM one of our own numbers (echo loop prevention) - if from_number in self._from_numbers: - logger.debug("[sms] ignoring echo from own number %s", _redact_phone(from_number)) - return web.json_response({"received": True}) - - # Remember which owned number received this user's message - if to_number and to_number in self._from_numbers: - self._reply_from[from_number] = to_number - - logger.info( - "[sms] inbound from %s -> %s: %s", - _redact_phone(from_number), - _redact_phone(to_number), - text[:80], - ) - - source = self.build_source( - chat_id=from_number, - chat_name=from_number, - chat_type="dm", - user_id=from_number, - user_name=from_number, - ) - event = MessageEvent( - text=text, - message_type=MessageType.TEXT, - source=source, - raw_message=body, - message_id=payload.get("id"), - ) - - # Non-blocking: Telnyx expects a fast 200 - asyncio.create_task(self.handle_message(event)) - return web.json_response({"received": True}) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _get_reply_from( - self, user_phone: str, metadata: Optional[Dict] = None - ) -> str: - """Determine which owned number to send from.""" - if metadata and "from_number" in metadata: - return metadata["from_number"] - if user_phone in self._reply_from: - return self._reply_from[user_phone] - if self._from_numbers: - return next(iter(self._from_numbers)) - raise RuntimeError( - "No FROM number configured (TELNYX_FROM_NUMBERS) and no prior " - "reply_from mapping for this user" - ) diff --git a/gateway/run.py b/gateway/run.py index aed55e8b8f2..f1e1be68ac7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -848,7 +848,7 @@ class GatewayRunner: os.getenv(v) for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS", "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", - "SMS_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") + "GATEWAY_ALLOWED_USERS") ) _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") if not _any_allowlist and not _allow_all: @@ -1132,13 +1132,6 @@ class GatewayRunner: return None return EmailAdapter(config) - elif platform == Platform.SMS: - from gateway.platforms.sms import SmsAdapter, check_sms_requirements - if not check_sms_requirements(): - logger.warning("SMS: aiohttp not installed or TELNYX_API_KEY not set. Run: pip install 'hermes-agent[sms]'") - return None - return SmsAdapter(config) - return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -1169,7 +1162,6 @@ class GatewayRunner: Platform.SLACK: "SLACK_ALLOWED_USERS", Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", Platform.EMAIL: "EMAIL_ALLOWED_USERS", - Platform.SMS: "SMS_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -1178,7 +1170,6 @@ class GatewayRunner: Platform.SLACK: "SLACK_ALLOW_ALL_USERS", Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", - Platform.SMS: "SMS_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 3f63a1d18e0..73956dc916b 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1013,30 +1013,6 @@ _PLATFORMS = [ "emoji": "📡", "token_var": "SIGNAL_HTTP_URL", }, - { - "key": "sms", - "label": "SMS (Telnyx)", - "emoji": "📱", - "token_var": "TELNYX_API_KEY", - "setup_instructions": [ - "1. Create a Telnyx account at https://portal.telnyx.com/", - "2. Buy a phone number with SMS capability", - "3. Create an API key: API Keys → Create API Key", - "4. Set up a Messaging Profile and assign your number to it", - "5. Configure the webhook URL: https://your-server/webhooks/telnyx", - ], - "vars": [ - {"name": "TELNYX_API_KEY", "prompt": "Telnyx API key", "password": True, - "help": "Paste the API key from step 3 above."}, - {"name": "TELNYX_FROM_NUMBERS", "prompt": "From numbers (comma-separated E.164, e.g. +15551234567)", "password": False, - "help": "The Telnyx phone number(s) Hermes will send SMS from."}, - {"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated E.164)", "password": False, - "is_allowlist": True, - "help": "Only messages from these phone numbers will be processed."}, - {"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone (for cron/notification delivery, or empty)", "password": False, - "help": "A phone number where cron job outputs are delivered."}, - ], - }, { "key": "email", "label": "Email", diff --git a/hermes_cli/status.py b/hermes_cli/status.py index ccdeca4d0bb..be490e9306c 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -252,7 +252,6 @@ def show_status(args): "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"), "Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"), - "SMS": ("TELNYX_API_KEY", "SMS_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/pyproject.toml b/pyproject.toml index b7b1f167d0c..74d8f1178b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ pty = [ honcho = ["honcho-ai>=2.0.1"] mcp = ["mcp>=1.2.0"] homeassistant = ["aiohttp>=3.9.0"] -sms = ["aiohttp>=3.9.0"] acp = ["agent-client-protocol>=0.8.1,<1.0"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git", @@ -81,7 +80,6 @@ all = [ "hermes-agent[homeassistant]", "hermes-agent[acp]", "hermes-agent[voice]", - "hermes-agent[sms]", ] [project.scripts] diff --git a/tests/gateway/test_sms.py b/tests/gateway/test_sms.py deleted file mode 100644 index e3d927bb3c8..00000000000 --- a/tests/gateway/test_sms.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Tests for SMS (Telnyx) platform adapter.""" -import json -import pytest -from unittest.mock import MagicMock, patch, AsyncMock - -from gateway.config import Platform, PlatformConfig - - -# --------------------------------------------------------------------------- -# Platform & Config -# --------------------------------------------------------------------------- - -class TestSmsPlatformEnum: - def test_sms_enum_exists(self): - assert Platform.SMS.value == "sms" - - def test_sms_in_platform_list(self): - platforms = [p.value for p in Platform] - assert "sms" in platforms - - -class TestSmsConfigLoading: - def test_apply_env_overrides_sms(self, monkeypatch): - monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") - - from gateway.config import GatewayConfig, _apply_env_overrides - config = GatewayConfig() - _apply_env_overrides(config) - - assert Platform.SMS in config.platforms - sc = config.platforms[Platform.SMS] - assert sc.enabled is True - assert sc.api_key == "KEY_test123" - - def test_sms_not_loaded_without_key(self, monkeypatch): - monkeypatch.delenv("TELNYX_API_KEY", raising=False) - - from gateway.config import GatewayConfig, _apply_env_overrides - config = GatewayConfig() - _apply_env_overrides(config) - - assert Platform.SMS not in config.platforms - - def test_connected_platforms_includes_sms(self, monkeypatch): - monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") - - from gateway.config import GatewayConfig, _apply_env_overrides - config = GatewayConfig() - _apply_env_overrides(config) - - connected = config.get_connected_platforms() - assert Platform.SMS in connected - - def test_sms_home_channel(self, monkeypatch): - monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") - monkeypatch.setenv("SMS_HOME_CHANNEL", "+15559876543") - monkeypatch.setenv("SMS_HOME_CHANNEL_NAME", "Owner") - - from gateway.config import GatewayConfig, _apply_env_overrides - config = GatewayConfig() - _apply_env_overrides(config) - - home = config.get_home_channel(Platform.SMS) - assert home is not None - assert home.chat_id == "+15559876543" - assert home.name == "Owner" - - -# --------------------------------------------------------------------------- -# Adapter format / truncate -# --------------------------------------------------------------------------- - -class TestSmsFormatMessage: - def setup_method(self): - from gateway.platforms.sms import SmsAdapter - config = PlatformConfig(enabled=True, api_key="test_key") - with patch.dict("os.environ", {"TELNYX_API_KEY": "test_key"}): - self.adapter = SmsAdapter(config) - - def test_strip_bold(self): - assert self.adapter.format_message("**bold**") == "bold" - - def test_strip_italic(self): - assert self.adapter.format_message("*italic*") == "italic" - - def test_strip_code_block(self): - result = self.adapter.format_message("```python\ncode\n```") - assert "```" not in result - assert "code" in result - - def test_strip_inline_code(self): - assert self.adapter.format_message("`code`") == "code" - - def test_strip_headers(self): - assert self.adapter.format_message("## Header") == "Header" - - def test_strip_links(self): - assert self.adapter.format_message("[click](http://example.com)") == "click" - - def test_collapse_newlines(self): - result = self.adapter.format_message("a\n\n\n\nb") - assert result == "a\n\nb" - - -class TestSmsTruncateMessage: - def setup_method(self): - from gateway.platforms.sms import SmsAdapter - config = PlatformConfig(enabled=True, api_key="test_key") - with patch.dict("os.environ", {"TELNYX_API_KEY": "test_key"}): - self.adapter = SmsAdapter(config) - - def test_short_message_single_chunk(self): - msg = "Hello, world!" - chunks = self.adapter.truncate_message(msg) - assert len(chunks) == 1 - assert chunks[0] == msg - - def test_long_message_splits(self): - msg = "a " * 1000 # 2000 chars - chunks = self.adapter.truncate_message(msg) - assert len(chunks) >= 2 - for chunk in chunks: - assert len(chunk) <= 1600 - - def test_custom_max_length(self): - msg = "Hello " * 20 - chunks = self.adapter.truncate_message(msg, max_length=50) - assert all(len(c) <= 50 for c in chunks) - - -# --------------------------------------------------------------------------- -# Echo loop prevention -# --------------------------------------------------------------------------- - -class TestSmsEchoLoop: - def test_own_number_ignored(self): - from gateway.platforms.sms import SmsAdapter - config = PlatformConfig(enabled=True, api_key="test_key") - with patch.dict("os.environ", { - "TELNYX_API_KEY": "test_key", - "TELNYX_FROM_NUMBERS": "+15551234567,+15559876543", - }): - adapter = SmsAdapter(config) - assert "+15551234567" in adapter._from_numbers - assert "+15559876543" in adapter._from_numbers - - -# --------------------------------------------------------------------------- -# Auth maps -# --------------------------------------------------------------------------- - -class TestSmsAuthMaps: - def test_sms_in_allowed_users_map(self): - """SMS should be in the platform auth maps in run.py.""" - # Verify the env var names are consistent - import os - os.environ.setdefault("SMS_ALLOWED_USERS", "+15551234567") - assert os.getenv("SMS_ALLOWED_USERS") == "+15551234567" - - def test_sms_allow_all_env_var(self): - """SMS_ALLOW_ALL_USERS should be recognized.""" - import os - os.environ.setdefault("SMS_ALLOW_ALL_USERS", "true") - assert os.getenv("SMS_ALLOW_ALL_USERS") == "true" - - -# --------------------------------------------------------------------------- -# Requirements check -# --------------------------------------------------------------------------- - -class TestSmsRequirements: - def test_check_sms_requirements_with_key(self, monkeypatch): - monkeypatch.setenv("TELNYX_API_KEY", "KEY_test123") - from gateway.platforms.sms import check_sms_requirements - # aiohttp is available in test environment - assert check_sms_requirements() is True - - def test_check_sms_requirements_without_key(self, monkeypatch): - monkeypatch.delenv("TELNYX_API_KEY", raising=False) - from gateway.platforms.sms import check_sms_requirements - assert check_sms_requirements() is False - - -# --------------------------------------------------------------------------- -# Toolset & integration points -# --------------------------------------------------------------------------- - -class TestSmsToolset: - def test_hermes_sms_toolset_exists(self): - from toolsets import get_toolset - ts = get_toolset("hermes-sms") - assert ts is not None - assert "hermes-sms" in ts.get("description", "").lower() or "sms" in ts.get("description", "").lower() - - def test_hermes_gateway_includes_sms(self): - from toolsets import get_toolset - gw = get_toolset("hermes-gateway") - assert "hermes-sms" in gw["includes"] - - -class TestSmsPlatformHints: - def test_sms_in_platform_hints(self): - from agent.prompt_builder import PLATFORM_HINTS - assert "sms" in PLATFORM_HINTS - assert "SMS" in PLATFORM_HINTS["sms"] or "sms" in PLATFORM_HINTS["sms"].lower() - - -class TestSmsCronDelivery: - def test_sms_in_cron_platform_map(self): - """Verify the cron scheduler can resolve 'sms' platform.""" - # The platform_map in _deliver_result should include sms - from gateway.config import Platform - assert Platform.SMS.value == "sms" - - -class TestSmsSendMessageTool: - def test_sms_in_send_message_platform_map(self): - """The send_message tool should recognize 'sms' as a valid platform.""" - # We verify by checking that SMS is in the Platform enum - # and the code path exists - from gateway.config import Platform - assert hasattr(Platform, "SMS") - - -class TestSmsChannelDirectory: - def test_sms_in_session_discovery(self): - """Verify SMS is included in session-based channel discovery.""" - import inspect - from gateway.channel_directory import build_channel_directory - source = inspect.getsource(build_channel_directory) - assert '"sms"' in source - - -class TestSmsStatus: - def test_sms_in_status_platforms(self): - """Verify SMS appears in the status command platforms dict.""" - import inspect - from hermes_cli.status import show_status - source = inspect.getsource(show_status) - assert '"SMS"' in source or "'SMS'" in source diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 74b958a56d1..c16e2ece9cd 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, signal, sms, or platform:chat_id" + "description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id" }, "model": { "type": "string", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 2f0f014abe4..9a404adaa2a 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -125,7 +125,6 @@ def _handle_send(args): "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, "email": Platform.EMAIL, - "sms": Platform.SMS, } platform = platform_map.get(platform_name) if not platform: @@ -335,8 +334,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_signal(pconfig.extra, chat_id, chunk) elif platform == Platform.EMAIL: result = await _send_email(pconfig.extra, chat_id, chunk) - elif platform == Platform.SMS: - result = await _send_sms(pconfig.api_key, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -565,54 +562,6 @@ async def _send_email(extra, chat_id, message): return {"error": f"Email send failed: {e}"} -async def _send_sms(api_key, chat_id, message): - """Send via Telnyx SMS REST API (one-shot, no persistent connection needed).""" - try: - import aiohttp - except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} - try: - from_number = os.getenv("TELNYX_FROM_NUMBERS", "").split(",")[0].strip() - if not from_number: - return {"error": "TELNYX_FROM_NUMBERS not configured"} - if not api_key: - api_key = os.getenv("TELNYX_API_KEY", "") - if not api_key: - return {"error": "TELNYX_API_KEY not configured"} - - # Strip markdown for SMS - text = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL) - text = re.sub(r"\*(.+?)\*", r"\1", text, flags=re.DOTALL) - text = re.sub(r"```[a-z]*\n?", "", text) - text = re.sub(r"`(.+?)`", r"\1", text) - text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) - text = text.strip() - - # Chunk to 1600 chars - chunks = [text[i:i+1600] for i in range(0, len(text), 1600)] if len(text) > 1600 else [text] - - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - message_ids = [] - async with aiohttp.ClientSession() as session: - for chunk in chunks: - payload = {"from": from_number, "to": chat_id, "text": chunk} - async with session.post( - "https://api.telnyx.com/v2/messages", - json=payload, - headers=headers, - ) as resp: - body = await resp.json() - if resp.status >= 400: - return {"error": f"Telnyx API error ({resp.status}): {body}"} - message_ids.append(body.get("data", {}).get("id", "")) - return {"success": True, "platform": "sms", "chat_id": chat_id, "message_ids": message_ids} - except Exception as e: - return {"error": f"SMS send failed: {e}"} - - def _check_send_message(): """Gate send_message on gateway running (always available on messaging platforms).""" platform = os.getenv("HERMES_SESSION_PLATFORM", "") diff --git a/toolsets.py b/toolsets.py index b7b2e48fb57..1a73ff1b824 100644 --- a/toolsets.py +++ b/toolsets.py @@ -292,16 +292,10 @@ TOOLSETS = { "includes": [] }, - "hermes-sms": { - "description": "SMS bot toolset - interact with Hermes via SMS (Telnyx)", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"] } } From 1d5a39e00228b20ec505260f2420e28ebe1c47e2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:53:33 -0700 Subject: [PATCH 9/9] fix: thread safety for concurrent subagent delegation (#1672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: thread safety for concurrent subagent delegation Four thread-safety fixes that prevent crashes and data races when running multiple subagents concurrently via delegate_task: 1. Remove redirect_stdout/stderr from delegate_tool — mutating global sys.stdout races with the spinner thread when multiple children start concurrently, causing segfaults. Children already run with quiet_mode=True so the redirect was redundant. 2. Split _run_single_child into _build_child_agent (main thread) + _run_single_child (worker thread). AIAgent construction creates httpx/SSL clients which are not thread-safe to initialize concurrently. 3. Add threading.Lock to SessionDB — subagents share the parent's SessionDB and call create_session/append_message from worker threads with no synchronization. 4. Add _active_children_lock to AIAgent — interrupt() iterates _active_children while worker threads append/remove children. 5. Add _client_cache_lock to auxiliary_client — multiple subagent threads may resolve clients concurrently via call_llm(). Based on PR #1471 by peteromallet. * feat: Honcho base_url override via config.yaml + quick command alias type Two features salvaged from PR #1576: 1. Honcho base_url override: allows pointing Hermes at a remote self-hosted Honcho deployment via config.yaml: honcho: base_url: "http://192.168.x.x:8000" When set, this overrides the Honcho SDK's environment mapping (production/local), enabling LAN/VPN Honcho deployments without requiring the server to live on localhost. Uses config.yaml instead of env var (HONCHO_URL) per project convention. 2. Quick command alias type: adds a new 'alias' quick command type that rewrites to another slash command before normal dispatch: quick_commands: sc: type: alias target: /context Supports both CLI and gateway. Arguments are forwarded to the target command. Based on PR #1576 by redhelix. --------- Co-authored-by: peteromallet Co-authored-by: redhelix --- agent/auxiliary_client.py | 16 +- cli.py | 11 +- gateway/run.py | 13 +- hermes_state.py | 314 ++++++++++++++------------ honcho_integration/client.py | 35 ++- run_agent.py | 5 +- tests/run_interrupt_test.py | 1 + tests/test_cli_interrupt_subagent.py | 13 +- tests/test_interactive_interrupt.py | 1 + tests/test_interrupt_propagation.py | 6 + tests/test_quick_commands.py | 22 ++ tests/test_real_interrupt_subagent.py | 28 ++- tests/tools/test_delegate.py | 12 +- tools/delegate_tool.py | 192 ++++++++-------- 14 files changed, 397 insertions(+), 272 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d008361b5a5..3142e4894ab 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -39,6 +39,7 @@ custom OpenAI-compatible endpoint without touching the main model settings. import json import logging import os +import threading from pathlib import Path from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple @@ -1171,6 +1172,7 @@ def auxiliary_max_tokens_param(value: int) -> dict: # Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model) _client_cache: Dict[tuple, tuple] = {} +_client_cache_lock = threading.Lock() def _get_cached_client( @@ -1182,9 +1184,11 @@ def _get_cached_client( ) -> Tuple[Optional[Any], Optional[str]]: """Get or create a cached client for the given provider.""" cache_key = (provider, async_mode, base_url or "", api_key or "") - if cache_key in _client_cache: - cached_client, cached_default = _client_cache[cache_key] - return cached_client, model or cached_default + with _client_cache_lock: + if cache_key in _client_cache: + cached_client, cached_default = _client_cache[cache_key] + return cached_client, model or cached_default + # Build outside the lock client, default_model = resolve_provider_client( provider, model, @@ -1193,7 +1197,11 @@ def _get_cached_client( explicit_api_key=api_key, ) if client is not None: - _client_cache[cache_key] = (client, default_model) + with _client_cache_lock: + if cache_key not in _client_cache: + _client_cache[cache_key] = (client, default_model) + else: + client, default_model = _client_cache[cache_key] return client, model or default_model diff --git a/cli.py b/cli.py index febe3278998..2b0c4ad82c7 100755 --- a/cli.py +++ b/cli.py @@ -3652,8 +3652,17 @@ class HermesCLI: self.console.print(f"[bold red]Quick command error: {e}[/]") else: self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") + elif qcmd.get("type") == "alias": + target = qcmd.get("target", "").strip() + if target: + target = target if target.startswith("/") else f"/{target}" + user_args = cmd_original[len(base_cmd):].strip() + aliased_command = f"{target} {user_args}".strip() + return self.process_command(aliased_command) + else: + self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") else: - self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (only 'exec' is supported)[/]") + self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") # Check for skill slash commands (/gif-search, /axolotl, etc.) elif base_cmd in _skill_commands: user_instruction = cmd_original[len(base_cmd):].strip() diff --git a/gateway/run.py b/gateway/run.py index f1e1be68ac7..7856e6a03b0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1421,8 +1421,19 @@ class GatewayRunner: return f"Quick command error: {e}" else: return f"Quick command '/{command}' has no command defined." + elif qcmd.get("type") == "alias": + target = qcmd.get("target", "").strip() + if target: + target = target if target.startswith("/") else f"/{target}" + target_command = target.lstrip("/") + user_args = event.get_command_args().strip() + event.text = f"{target} {user_args}".strip() + command = target_command + # Fall through to normal command dispatch below + else: + return f"Quick command '/{command}' has no target defined." else: - return f"Quick command '/{command}' has unsupported type (only 'exec' is supported)." + return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')." # Skill slash commands: /skill-name loads the skill and sends to agent if command: diff --git a/hermes_state.py b/hermes_state.py index 3f47150673a..d0237a5bbeb 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -18,6 +18,7 @@ import json import os import re import sqlite3 +import threading import time from pathlib import Path from typing import Dict, Any, List, Optional @@ -104,6 +105,7 @@ class SessionDB: self.db_path = db_path or DEFAULT_DB_PATH self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() self._conn = sqlite3.connect( str(self.db_path), check_same_thread=False, @@ -173,9 +175,10 @@ class SessionDB: def close(self): """Close the database connection.""" - if self._conn: - self._conn.close() - self._conn = None + with self._lock: + if self._conn: + self._conn.close() + self._conn = None # ========================================================================= # Session lifecycle @@ -192,61 +195,66 @@ class SessionDB: parent_session_id: str = None, ) -> str: """Create a new session record. Returns the session_id.""" - self._conn.execute( - """INSERT INTO sessions (id, source, user_id, model, model_config, - system_prompt, parent_session_id, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ( - session_id, - source, - user_id, - model, - json.dumps(model_config) if model_config else None, - system_prompt, - parent_session_id, - time.time(), - ), - ) - self._conn.commit() + with self._lock: + self._conn.execute( + """INSERT INTO sessions (id, source, user_id, model, model_config, + system_prompt, parent_session_id, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + source, + user_id, + model, + json.dumps(model_config) if model_config else None, + system_prompt, + parent_session_id, + time.time(), + ), + ) + self._conn.commit() return session_id def end_session(self, session_id: str, end_reason: str) -> None: """Mark a session as ended.""" - self._conn.execute( - "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?", - (time.time(), end_reason, session_id), - ) - self._conn.commit() + with self._lock: + self._conn.execute( + "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?", + (time.time(), end_reason, session_id), + ) + self._conn.commit() def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" - self._conn.execute( - "UPDATE sessions SET system_prompt = ? WHERE id = ?", - (system_prompt, session_id), - ) - self._conn.commit() + with self._lock: + self._conn.execute( + "UPDATE sessions SET system_prompt = ? WHERE id = ?", + (system_prompt, session_id), + ) + self._conn.commit() def update_token_counts( self, session_id: str, input_tokens: int = 0, output_tokens: int = 0, model: str = None, ) -> None: """Increment token counters and backfill model if not already set.""" - self._conn.execute( - """UPDATE sessions SET - input_tokens = input_tokens + ?, - output_tokens = output_tokens + ?, - model = COALESCE(model, ?) - WHERE id = ?""", - (input_tokens, output_tokens, model, session_id), - ) - self._conn.commit() + with self._lock: + self._conn.execute( + """UPDATE sessions SET + input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, + model = COALESCE(model, ?) + WHERE id = ?""", + (input_tokens, output_tokens, model, session_id), + ) + self._conn.commit() def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: """Get a session by ID.""" - cursor = self._conn.execute( - "SELECT * FROM sessions WHERE id = ?", (session_id,) - ) - row = cursor.fetchone() + with self._lock: + cursor = self._conn.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,) + ) + row = cursor.fetchone() return dict(row) if row else None def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]: @@ -331,38 +339,42 @@ class SessionDB: Empty/whitespace-only strings are normalized to None (clearing the title). """ title = self.sanitize_title(title) - if title: - # Check uniqueness (allow the same session to keep its own title) + with self._lock: + if title: + # Check uniqueness (allow the same session to keep its own title) + cursor = self._conn.execute( + "SELECT id FROM sessions WHERE title = ? AND id != ?", + (title, session_id), + ) + conflict = cursor.fetchone() + if conflict: + raise ValueError( + f"Title '{title}' is already in use by session {conflict['id']}" + ) cursor = self._conn.execute( - "SELECT id FROM sessions WHERE title = ? AND id != ?", + "UPDATE sessions SET title = ? WHERE id = ?", (title, session_id), ) - conflict = cursor.fetchone() - if conflict: - raise ValueError( - f"Title '{title}' is already in use by session {conflict['id']}" - ) - cursor = self._conn.execute( - "UPDATE sessions SET title = ? WHERE id = ?", - (title, session_id), - ) - self._conn.commit() - return cursor.rowcount > 0 + self._conn.commit() + rowcount = cursor.rowcount + return rowcount > 0 def get_session_title(self, session_id: str) -> Optional[str]: """Get the title for a session, or None.""" - cursor = self._conn.execute( - "SELECT title FROM sessions WHERE id = ?", (session_id,) - ) - row = cursor.fetchone() + with self._lock: + cursor = self._conn.execute( + "SELECT title FROM sessions WHERE id = ?", (session_id,) + ) + row = cursor.fetchone() return row["title"] if row else None def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" - cursor = self._conn.execute( - "SELECT * FROM sessions WHERE title = ?", (title,) - ) - row = cursor.fetchone() + with self._lock: + cursor = self._conn.execute( + "SELECT * FROM sessions WHERE title = ?", (title,) + ) + row = cursor.fetchone() return dict(row) if row else None def resolve_session_by_title(self, title: str) -> Optional[str]: @@ -379,12 +391,13 @@ class SessionDB: # Also search for numbered variants: "title #2", "title #3", etc. # Escape SQL LIKE wildcards (%, _) in the title to prevent false matches escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - cursor = self._conn.execute( - "SELECT id, title, started_at FROM sessions " - "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC", - (f"{escaped} #%",), - ) - numbered = cursor.fetchall() + with self._lock: + cursor = self._conn.execute( + "SELECT id, title, started_at FROM sessions " + "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC", + (f"{escaped} #%",), + ) + numbered = cursor.fetchall() if numbered: # Return the most recent numbered variant @@ -409,11 +422,12 @@ class SessionDB: # Find all existing numbered variants # Escape SQL LIKE wildcards (%, _) in the base to prevent false matches escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - cursor = self._conn.execute( - "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'", - (base, f"{escaped} #%"), - ) - existing = [row["title"] for row in cursor.fetchall()] + with self._lock: + cursor = self._conn.execute( + "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'", + (base, f"{escaped} #%"), + ) + existing = [row["title"] for row in cursor.fetchall()] if not existing: return base # No conflict, use the base name as-is @@ -461,9 +475,11 @@ class SessionDB: LIMIT ? OFFSET ? """ params = (source, limit, offset) if source else (limit, offset) - cursor = self._conn.execute(query, params) + with self._lock: + cursor = self._conn.execute(query, params) + rows = cursor.fetchall() sessions = [] - for row in cursor.fetchall(): + for row in rows: s = dict(row) # Build the preview from the raw substring raw = s.pop("_preview_raw", "").strip() @@ -497,52 +513,54 @@ class SessionDB: Also increments the session's message_count (and tool_call_count if role is 'tool' or tool_calls is present). """ - cursor = self._conn.execute( - """INSERT INTO messages (session_id, role, content, tool_call_id, - tool_calls, tool_name, timestamp, token_count, finish_reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - session_id, - role, - content, - tool_call_id, - json.dumps(tool_calls) if tool_calls else None, - tool_name, - time.time(), - token_count, - finish_reason, - ), - ) - msg_id = cursor.lastrowid - - # Update counters - # Count actual tool calls from the tool_calls list (not from tool responses). - # A single assistant message can contain multiple parallel tool calls. - num_tool_calls = 0 - if tool_calls is not None: - num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1 - if num_tool_calls > 0: - self._conn.execute( - """UPDATE sessions SET message_count = message_count + 1, - tool_call_count = tool_call_count + ? WHERE id = ?""", - (num_tool_calls, session_id), - ) - else: - self._conn.execute( - "UPDATE sessions SET message_count = message_count + 1 WHERE id = ?", - (session_id,), + with self._lock: + cursor = self._conn.execute( + """INSERT INTO messages (session_id, role, content, tool_call_id, + tool_calls, tool_name, timestamp, token_count, finish_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + role, + content, + tool_call_id, + json.dumps(tool_calls) if tool_calls else None, + tool_name, + time.time(), + token_count, + finish_reason, + ), ) + msg_id = cursor.lastrowid - self._conn.commit() + # Update counters + # Count actual tool calls from the tool_calls list (not from tool responses). + # A single assistant message can contain multiple parallel tool calls. + num_tool_calls = 0 + if tool_calls is not None: + num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1 + if num_tool_calls > 0: + self._conn.execute( + """UPDATE sessions SET message_count = message_count + 1, + tool_call_count = tool_call_count + ? WHERE id = ?""", + (num_tool_calls, session_id), + ) + else: + self._conn.execute( + "UPDATE sessions SET message_count = message_count + 1 WHERE id = ?", + (session_id,), + ) + + self._conn.commit() return msg_id def get_messages(self, session_id: str) -> List[Dict[str, Any]]: """Load all messages for a session, ordered by timestamp.""" - cursor = self._conn.execute( - "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id", - (session_id,), - ) - rows = cursor.fetchall() + with self._lock: + cursor = self._conn.execute( + "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id", + (session_id,), + ) + rows = cursor.fetchall() result = [] for row in rows: msg = dict(row) @@ -559,13 +577,15 @@ class SessionDB: Load messages in the OpenAI conversation format (role + content dicts). Used by the gateway to restore conversation history. """ - cursor = self._conn.execute( - "SELECT role, content, tool_call_id, tool_calls, tool_name " - "FROM messages WHERE session_id = ? ORDER BY timestamp, id", - (session_id,), - ) + with self._lock: + cursor = self._conn.execute( + "SELECT role, content, tool_call_id, tool_calls, tool_name " + "FROM messages WHERE session_id = ? ORDER BY timestamp, id", + (session_id,), + ) + rows = cursor.fetchall() messages = [] - for row in cursor.fetchall(): + for row in rows: msg = {"role": row["role"], "content": row["content"]} if row["tool_call_id"]: msg["tool_call_id"] = row["tool_call_id"] @@ -675,31 +695,33 @@ class SessionDB: LIMIT ? OFFSET ? """ - try: - cursor = self._conn.execute(sql, params) - except sqlite3.OperationalError: - # FTS5 query syntax error despite sanitization — return empty - return [] - matches = [dict(row) for row in cursor.fetchall()] - - # Add surrounding context (1 message before + after each match) - for match in matches: + with self._lock: try: - ctx_cursor = self._conn.execute( - """SELECT role, content FROM messages - WHERE session_id = ? AND id >= ? - 1 AND id <= ? + 1 - ORDER BY id""", - (match["session_id"], match["id"], match["id"]), - ) - context_msgs = [ - {"role": r["role"], "content": (r["content"] or "")[:200]} - for r in ctx_cursor.fetchall() - ] - match["context"] = context_msgs - except Exception: - match["context"] = [] + cursor = self._conn.execute(sql, params) + except sqlite3.OperationalError: + # FTS5 query syntax error despite sanitization — return empty + return [] + matches = [dict(row) for row in cursor.fetchall()] - # Remove full content from result (snippet is enough, saves tokens) + # Add surrounding context (1 message before + after each match) + for match in matches: + try: + ctx_cursor = self._conn.execute( + """SELECT role, content FROM messages + WHERE session_id = ? AND id >= ? - 1 AND id <= ? + 1 + ORDER BY id""", + (match["session_id"], match["id"], match["id"]), + ) + context_msgs = [ + {"role": r["role"], "content": (r["content"] or "")[:200]} + for r in ctx_cursor.fetchall() + ] + match["context"] = context_msgs + except Exception: + match["context"] = [] + + # Remove full content from result (snippet is enough, saves tokens) + for match in matches: match.pop("content", None) return matches diff --git a/honcho_integration/client.py b/honcho_integration/client.py index ccc2f6f25aa..759576adaf7 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -69,6 +69,8 @@ class HonchoClientConfig: workspace_id: str = "hermes" api_key: str | None = None environment: str = "production" + # Optional base URL for self-hosted Honcho (overrides environment mapping) + base_url: str | None = None # Identity peer_name: str | None = None ai_peer: str = "hermes" @@ -361,13 +363,34 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: "Install it with: pip install honcho-ai" ) - logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id) + # Allow config.yaml honcho.base_url to override the SDK's environment + # mapping, enabling remote self-hosted Honcho deployments without + # requiring the server to live on localhost. + resolved_base_url = config.base_url + if not resolved_base_url: + try: + from hermes_cli.config import load_config + hermes_cfg = load_config() + honcho_cfg = hermes_cfg.get("honcho", {}) + if isinstance(honcho_cfg, dict): + resolved_base_url = honcho_cfg.get("base_url", "").strip() or None + except Exception: + pass - _honcho_client = Honcho( - workspace_id=config.workspace_id, - api_key=config.api_key, - environment=config.environment, - ) + if resolved_base_url: + logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id) + else: + logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id) + + kwargs: dict = { + "workspace_id": config.workspace_id, + "api_key": config.api_key, + "environment": config.environment, + } + if resolved_base_url: + kwargs["base_url"] = resolved_base_url + + _honcho_client = Honcho(**kwargs) return _honcho_client diff --git a/run_agent.py b/run_agent.py index 2c8fad0b89f..e8bf35c4794 100644 --- a/run_agent.py +++ b/run_agent.py @@ -407,6 +407,7 @@ class AIAgent: # Subagent delegation state self._delegate_depth = 0 # 0 = top-level agent, incremented for children self._active_children = [] # Running child AIAgents (for interrupt propagation) + self._active_children_lock = threading.Lock() # Store OpenRouter provider preferences self.providers_allowed = providers_allowed @@ -1526,7 +1527,9 @@ class AIAgent: # Signal all tools to abort any in-flight operations immediately _set_interrupt(True) # Propagate interrupt to any running child agents (subagent delegation) - for child in self._active_children: + with self._active_children_lock: + children_copy = list(self._active_children) + for child in children_copy: try: child.interrupt(message) except Exception as e: diff --git a/tests/run_interrupt_test.py b/tests/run_interrupt_test.py index 845060ffa08..a539c6ca9b8 100644 --- a/tests/run_interrupt_test.py +++ b/tests/run_interrupt_test.py @@ -24,6 +24,7 @@ def main() -> int: parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True parent.model = "test/model" parent.base_url = "http://localhost:1" diff --git a/tests/test_cli_interrupt_subagent.py b/tests/test_cli_interrupt_subagent.py index b91a7b65457..f4322ea6b96 100644 --- a/tests/test_cli_interrupt_subagent.py +++ b/tests/test_cli_interrupt_subagent.py @@ -43,6 +43,7 @@ class TestCLISubagentInterrupt(unittest.TestCase): parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True parent.model = "test/model" parent.base_url = "http://localhost:1" @@ -112,21 +113,21 @@ class TestCLISubagentInterrupt(unittest.TestCase): mock_instance._interrupt_requested = False mock_instance._interrupt_message = None mock_instance._active_children = [] + mock_instance._active_children_lock = threading.Lock() mock_instance.quiet_mode = True mock_instance.run_conversation = mock_child_run_conversation mock_instance.interrupt = lambda msg=None: setattr(mock_instance, '_interrupt_requested', True) or setattr(mock_instance, '_interrupt_message', msg) mock_instance.tools = [] MockAgent.return_value = mock_instance - + + # Register child manually (normally done by _build_child_agent) + parent._active_children.append(mock_instance) + result = _run_single_child( task_index=0, goal="Do something slow", - context=None, - toolsets=["terminal"], - model=None, - max_iterations=50, + child=mock_instance, parent_agent=parent, - task_count=1, ) delegate_result[0] = result except Exception as e: diff --git a/tests/test_interactive_interrupt.py b/tests/test_interactive_interrupt.py index c01404e1ce8..8c0d328c248 100644 --- a/tests/test_interactive_interrupt.py +++ b/tests/test_interactive_interrupt.py @@ -57,6 +57,7 @@ def main() -> int: parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True parent.model = "test/model" parent.base_url = "http://localhost:1" diff --git a/tests/test_interrupt_propagation.py b/tests/test_interrupt_propagation.py index ff1cafdc84f..7f8cb01c35b 100644 --- a/tests/test_interrupt_propagation.py +++ b/tests/test_interrupt_propagation.py @@ -30,12 +30,14 @@ class TestInterruptPropagationToChild(unittest.TestCase): parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True child = AIAgent.__new__(AIAgent) child._interrupt_requested = False child._interrupt_message = None child._active_children = [] + child._active_children_lock = threading.Lock() child.quiet_mode = True parent._active_children.append(child) @@ -60,6 +62,7 @@ class TestInterruptPropagationToChild(unittest.TestCase): child._interrupt_message = "msg" child.quiet_mode = True child._active_children = [] + child._active_children_lock = threading.Lock() # Global is set set_interrupt(True) @@ -78,6 +81,7 @@ class TestInterruptPropagationToChild(unittest.TestCase): child._interrupt_requested = False child._interrupt_message = None child._active_children = [] + child._active_children_lock = threading.Lock() child.quiet_mode = True child.api_mode = "chat_completions" child.log_prefix = "" @@ -119,12 +123,14 @@ class TestInterruptPropagationToChild(unittest.TestCase): parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True child = AIAgent.__new__(AIAgent) child._interrupt_requested = False child._interrupt_message = None child._active_children = [] + child._active_children_lock = threading.Lock() child.quiet_mode = True # Register child (simulating what _run_single_child does) diff --git a/tests/test_quick_commands.py b/tests/test_quick_commands.py index 9708b1fb319..7a89d4ca28a 100644 --- a/tests/test_quick_commands.py +++ b/tests/test_quick_commands.py @@ -47,6 +47,28 @@ class TestCLIQuickCommands: args = cli.console.print.call_args[0][0] assert "no output" in args.lower() + def test_alias_command_routes_to_target(self): + """Alias quick commands rewrite to the target command.""" + cli = self._make_cli({"shortcut": {"type": "alias", "target": "/help"}}) + with patch.object(cli, "process_command", wraps=cli.process_command) as spy: + cli.process_command("/shortcut") + # Should recursively call process_command with /help + spy.assert_any_call("/help") + + def test_alias_command_passes_args(self): + """Alias quick commands forward user arguments to the target.""" + cli = self._make_cli({"sc": {"type": "alias", "target": "/context"}}) + with patch.object(cli, "process_command", wraps=cli.process_command) as spy: + cli.process_command("/sc some args") + spy.assert_any_call("/context some args") + + def test_alias_no_target_shows_error(self): + cli = self._make_cli({"broken": {"type": "alias", "target": ""}}) + cli.process_command("/broken") + cli.console.print.assert_called_once() + args = cli.console.print.call_args[0][0] + assert "no target defined" in args.lower() + def test_unsupported_type_shows_error(self): cli = self._make_cli({"bad": {"type": "prompt", "command": "echo hi"}}) cli.process_command("/bad") diff --git a/tests/test_real_interrupt_subagent.py b/tests/test_real_interrupt_subagent.py index f1a16753a9a..e0e681cdf40 100644 --- a/tests/test_real_interrupt_subagent.py +++ b/tests/test_real_interrupt_subagent.py @@ -55,6 +55,7 @@ class TestRealSubagentInterrupt(unittest.TestCase): parent._interrupt_requested = False parent._interrupt_message = None parent._active_children = [] + parent._active_children_lock = threading.Lock() parent.quiet_mode = True parent.model = "test/model" parent.base_url = "http://localhost:1" @@ -103,19 +104,28 @@ class TestRealSubagentInterrupt(unittest.TestCase): return original_run(self_agent, *args, **kwargs) with patch.object(AIAgent, 'run_conversation', patched_run): + # Build a real child agent (AIAgent is NOT patched here, + # only run_conversation and _build_system_prompt are) + child = AIAgent( + base_url="http://localhost:1", + api_key="test-key", + model="test/model", + provider="test", + api_mode="chat_completions", + max_iterations=5, + enabled_toolsets=["terminal"], + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + platform="cli", + ) + child._delegate_depth = 1 + parent._active_children.append(child) result = _run_single_child( task_index=0, goal="Test task", - context=None, - toolsets=["terminal"], - model="test/model", - max_iterations=5, + child=child, parent_agent=parent, - task_count=1, - override_provider="test", - override_base_url="http://localhost:1", - override_api_key="test", - override_api_mode="chat_completions", ) result_holder[0] = result except Exception as e: diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index a29560b2c74..476a2401b62 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -12,6 +12,7 @@ Run with: python -m pytest tests/test_delegate.py -v import json import os import sys +import threading import unittest from unittest.mock import MagicMock, patch @@ -44,6 +45,7 @@ def _make_mock_parent(depth=0): parent._session_db = None parent._delegate_depth = depth parent._active_children = [] + parent._active_children_lock = threading.Lock() return parent @@ -722,7 +724,12 @@ class TestDelegationProviderIntegration(unittest.TestCase): } parent = _make_mock_parent(depth=0) - with patch("tools.delegate_tool._run_single_child") as mock_run: + # Patch _build_child_agent since credentials are now passed there + # (agents are built in the main thread before being handed to workers) + with patch("tools.delegate_tool._build_child_agent") as mock_build, \ + patch("tools.delegate_tool._run_single_child") as mock_run: + mock_child = MagicMock() + mock_build.return_value = mock_child mock_run.return_value = { "task_index": 0, "status": "completed", "summary": "Done", "api_calls": 1, "duration_seconds": 1.0 @@ -731,7 +738,8 @@ class TestDelegationProviderIntegration(unittest.TestCase): tasks = [{"goal": "Task A"}, {"goal": "Task B"}] delegate_task(tasks=tasks, parent_agent=parent) - for call in mock_run.call_args_list: + self.assertEqual(mock_build.call_count, 2) + for call in mock_build.call_args_list: self.assertEqual(call.kwargs.get("model"), "meta-llama/llama-4-scout") self.assertEqual(call.kwargs.get("override_provider"), "openrouter") self.assertEqual(call.kwargs.get("override_base_url"), "https://openrouter.ai/api/v1") diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 1ac75ea8867..2ef505dab33 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -16,13 +16,10 @@ The parent's context only sees the delegation call and the summary result, never the child's intermediate tool calls or reasoning. """ -import contextlib -import io import json import logging logger = logging.getLogger(__name__) import os -import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional @@ -150,7 +147,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in return _callback -def _run_single_child( +def _build_child_agent( task_index: int, goal: str, context: Optional[str], @@ -158,16 +155,15 @@ def _run_single_child( model: Optional[str], max_iterations: int, parent_agent, - task_count: int = 1, # Credential overrides from delegation config (provider:model resolution) override_provider: Optional[str] = None, override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, -) -> Dict[str, Any]: +): """ - Spawn and run a single child agent. Called from within a thread. - Returns a structured result dict. + Build a child AIAgent on the main thread (thread-safe construction). + Returns the constructed child agent without running it. When override_* params are set (from delegation config), the child uses those credentials instead of inheriting from the parent. This enables @@ -176,8 +172,6 @@ def _run_single_child( """ from run_agent import AIAgent - child_start = time.monotonic() - # When no explicit toolsets given, inherit from parent's enabled toolsets # so disabled tools (e.g. web) don't leak to subagents. if toolsets: @@ -188,65 +182,84 @@ def _run_single_child( child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS) child_prompt = _build_child_system_prompt(goal, context) + # Extract parent's API key so subagents inherit auth (e.g. Nous Portal). + parent_api_key = getattr(parent_agent, "api_key", None) + if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"): + parent_api_key = parent_agent._client_kwargs.get("api_key") - try: - # Extract parent's API key so subagents inherit auth (e.g. Nous Portal). - parent_api_key = getattr(parent_agent, "api_key", None) - if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"): - parent_api_key = parent_agent._client_kwargs.get("api_key") + # Build progress callback to relay tool calls to parent display + child_progress_cb = _build_child_progress_callback(task_index, parent_agent) - # Build progress callback to relay tool calls to parent display - child_progress_cb = _build_child_progress_callback(task_index, parent_agent, task_count) + # Share the parent's iteration budget so subagent tool calls + # count toward the session-wide limit. + shared_budget = getattr(parent_agent, "iteration_budget", None) - # Share the parent's iteration budget so subagent tool calls - # count toward the session-wide limit. - shared_budget = getattr(parent_agent, "iteration_budget", None) + # Resolve effective credentials: config override > parent inherit + effective_model = model or parent_agent.model + effective_provider = override_provider or getattr(parent_agent, "provider", None) + effective_base_url = override_base_url or parent_agent.base_url + effective_api_key = override_api_key or parent_api_key + effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None) - # Resolve effective credentials: config override > parent inherit - effective_model = model or parent_agent.model - effective_provider = override_provider or getattr(parent_agent, "provider", None) - effective_base_url = override_base_url or parent_agent.base_url - effective_api_key = override_api_key or parent_api_key - effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None) + child = AIAgent( + base_url=effective_base_url, + api_key=effective_api_key, + model=effective_model, + provider=effective_provider, + api_mode=effective_api_mode, + max_iterations=max_iterations, + max_tokens=getattr(parent_agent, "max_tokens", None), + reasoning_config=getattr(parent_agent, "reasoning_config", None), + prefill_messages=getattr(parent_agent, "prefill_messages", None), + enabled_toolsets=child_toolsets, + quiet_mode=True, + ephemeral_system_prompt=child_prompt, + log_prefix=f"[subagent-{task_index}]", + platform=parent_agent.platform, + skip_context_files=True, + skip_memory=True, + clarify_callback=None, + session_db=getattr(parent_agent, '_session_db', None), + providers_allowed=parent_agent.providers_allowed, + providers_ignored=parent_agent.providers_ignored, + providers_order=parent_agent.providers_order, + provider_sort=parent_agent.provider_sort, + tool_progress_callback=child_progress_cb, + iteration_budget=shared_budget, + ) - child = AIAgent( - base_url=effective_base_url, - api_key=effective_api_key, - model=effective_model, - provider=effective_provider, - api_mode=effective_api_mode, - max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), - reasoning_config=getattr(parent_agent, "reasoning_config", None), - prefill_messages=getattr(parent_agent, "prefill_messages", None), - enabled_toolsets=child_toolsets, - quiet_mode=True, - ephemeral_system_prompt=child_prompt, - log_prefix=f"[subagent-{task_index}]", - platform=parent_agent.platform, - skip_context_files=True, - skip_memory=True, - clarify_callback=None, - session_db=getattr(parent_agent, '_session_db', None), - providers_allowed=parent_agent.providers_allowed, - providers_ignored=parent_agent.providers_ignored, - providers_order=parent_agent.providers_order, - provider_sort=parent_agent.provider_sort, - tool_progress_callback=child_progress_cb, - iteration_budget=shared_budget, - ) + # Set delegation depth so children can't spawn grandchildren + child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 - # Set delegation depth so children can't spawn grandchildren - child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 - - # Register child for interrupt propagation - if hasattr(parent_agent, '_active_children'): + # Register child for interrupt propagation + if hasattr(parent_agent, '_active_children'): + lock = getattr(parent_agent, '_active_children_lock', None) + if lock: + with lock: + parent_agent._active_children.append(child) + else: parent_agent._active_children.append(child) - # Run with stdout/stderr suppressed to prevent interleaved output - devnull = io.StringIO() - with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull): - result = child.run_conversation(user_message=goal) + return child + +def _run_single_child( + task_index: int, + goal: str, + child=None, + parent_agent=None, + **_kwargs, +) -> Dict[str, Any]: + """ + Run a pre-built child agent. Called from within a thread. + Returns a structured result dict. + """ + child_start = time.monotonic() + + # Get the progress callback from the child agent + child_progress_cb = getattr(child, 'tool_progress_callback', None) + + try: + result = child.run_conversation(user_message=goal) # Flush any remaining batched progress to gateway if child_progress_cb and hasattr(child_progress_cb, '_flush'): @@ -355,11 +368,15 @@ def _run_single_child( # Unregister child from interrupt propagation if hasattr(parent_agent, '_active_children'): try: - parent_agent._active_children.remove(child) + lock = getattr(parent_agent, '_active_children_lock', None) + if lock: + with lock: + parent_agent._active_children.remove(child) + else: + parent_agent._active_children.remove(child) except (ValueError, UnboundLocalError) as e: logger.debug("Could not remove child from active_children: %s", e) - def delegate_task( goal: Optional[str] = None, context: Optional[str] = None, @@ -428,51 +445,38 @@ def delegate_task( # Track goal labels for progress display (truncated for readability) task_labels = [t["goal"][:40] for t in task_list] - if n_tasks == 1: - # Single task -- run directly (no thread pool overhead) - t = task_list[0] - result = _run_single_child( - task_index=0, - goal=t["goal"], - context=t.get("context"), - toolsets=t.get("toolsets") or toolsets, - model=creds["model"], - max_iterations=effective_max_iter, - parent_agent=parent_agent, - task_count=1, - override_provider=creds["provider"], - override_base_url=creds["base_url"], + # Build all child agents on the main thread (thread-safe construction) + children = [] + for i, t in enumerate(task_list): + child = _build_child_agent( + task_index=i, goal=t["goal"], context=t.get("context"), + toolsets=t.get("toolsets") or toolsets, model=creds["model"], + max_iterations=effective_max_iter, parent_agent=parent_agent, + override_provider=creds["provider"], override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], ) + children.append((i, t, child)) + + if n_tasks == 1: + # Single task -- run directly (no thread pool overhead) + _i, _t, child = children[0] + result = _run_single_child(0, _t["goal"], child, parent_agent) results.append(result) else: # Batch -- run in parallel with per-task progress lines completed_count = 0 spinner_ref = getattr(parent_agent, '_delegate_spinner', None) - # Save stdout/stderr before the executor — redirect_stdout in child - # threads races on sys.stdout and can leave it as devnull permanently. - _saved_stdout = sys.stdout - _saved_stderr = sys.stderr - with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CHILDREN) as executor: futures = {} - for i, t in enumerate(task_list): + for i, t, child in children: future = executor.submit( _run_single_child, task_index=i, goal=t["goal"], - context=t.get("context"), - toolsets=t.get("toolsets") or toolsets, - model=creds["model"], - max_iterations=effective_max_iter, + child=child, parent_agent=parent_agent, - task_count=n_tasks, - override_provider=creds["provider"], - override_base_url=creds["base_url"], - override_api_key=creds["api_key"], - override_api_mode=creds["api_mode"], ) futures[future] = i @@ -515,10 +519,6 @@ def delegate_task( except Exception as e: logger.debug("Spinner update_text failed: %s", e) - # Restore stdout/stderr in case redirect_stdout race left them as devnull - sys.stdout = _saved_stdout - sys.stderr = _saved_stderr - # Sort by task_index so results match input order results.sort(key=lambda r: r["task_index"])