From 1ec1f6a68aa17075b72029bcf4dbf79b26501823 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:38:53 -0700 Subject: [PATCH 01/51] =?UTF-8?q?fix:=20model=20fallback=20=E2=80=94=20sta?= =?UTF-8?q?le=20model=20on=20Nous=20login=20+=20connection=20error=20fallb?= =?UTF-8?q?ack=20(#6554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the model fallback system: 1. Nous login leaves stale model in config (provider=nous, model=opus from previous OpenRouter setup). Fixed by deferring the config.yaml provider write until AFTER model selection completes, and passing the selected model atomically via _update_config_for_provider's default_model parameter. Previously, _update_config_for_provider was called before model selection — if selection failed (free tier, no models, exception), config stayed as nous+opus permanently. 2. Codex/stale providers in auxiliary fallback can't connect but block the auto-detection chain. Added _is_connection_error() detection (APIConnectionError, APITimeoutError, DNS failures, connection refused) alongside the existing _is_payment_error() check in call_llm(). When a provider endpoint is unreachable, the system now falls back to the next available provider instead of crashing. --- agent/auxiliary_client.py | 39 ++++++++++++++++++++++++++++++++++++++- hermes_cli/auth.py | 19 ++++++++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 27c67c10..2887447d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1047,6 +1047,32 @@ def _is_payment_error(exc: Exception) -> bool: return False +def _is_connection_error(exc: Exception) -> bool: + """Detect connection/network errors that warrant provider fallback. + + Returns True for errors indicating the provider endpoint is unreachable + (DNS failure, connection refused, TLS errors, timeouts). These are + distinct from API errors (4xx/5xx) which indicate the provider IS + reachable but returned an error. + """ + from openai import APIConnectionError, APITimeoutError + + if isinstance(exc, (APIConnectionError, APITimeoutError)): + return True + # urllib3 / httpx / httpcore connection errors + err_type = type(exc).__name__ + if any(kw in err_type for kw in ("Connection", "Timeout", "DNS", "SSL")): + return True + err_lower = str(exc).lower() + if any(kw in err_lower for kw in ( + "connection refused", "name or service not known", + "no route to host", "network is unreachable", + "timed out", "connection reset", + )): + return True + return False + + def _try_payment_fallback( failed_provider: str, task: str = None, @@ -2093,7 +2119,18 @@ def call_llm( # try alternative providers instead of giving up. This handles the # common case where a user runs out of OpenRouter credits but has # Codex OAuth or another provider available. - if _is_payment_error(first_err): + # + # ── Connection error fallback ──────────────────────────────── + # When a provider endpoint is unreachable (DNS failure, connection + # refused, timeout), try alternative providers. This handles stale + # Codex/OAuth tokens that authenticate but whose endpoint is down, + # and providers the user never configured that got picked up by + # the auto-detection chain. + should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err) + if should_fallback: + reason = "payment error" if _is_payment_error(first_err) else "connection error" + logger.info("Auxiliary %s: %s on %s (%s), trying fallback", + task or "call", reason, resolved_provider, first_err) fb_client, fb_model, fb_label = _try_payment_fallback( resolved_provider, task) if fb_client is not None: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b7360fdd..6689e5fb 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3017,12 +3017,15 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: _save_provider_state(auth_store, "nous", auth_state) saved_to = _save_auth_store(auth_store) - config_path = _update_config_for_provider("nous", inference_base_url) print() print("Login successful!") print(f" Auth state: {saved_to}") - print(f" Config updated: {config_path} (model.provider=nous)") + # Resolve model BEFORE writing provider to config.yaml so we never + # leave the config in a half-updated state (provider=nous but model + # still set to the previous provider's model, e.g. opus from + # OpenRouter). The auth.json active_provider was already set above. + selected_model = None try: runtime_key = auth_state.get("agent_key") or auth_state.get("access_token") if not isinstance(runtime_key, str) or not runtime_key: @@ -3056,9 +3059,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: unavailable_models=unavailable_models, portal_url=_portal, ) - if selected_model: - _save_model_choice(selected_model) - print(f"Default model set to: {selected_model}") elif unavailable_models: _url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/") print("No free models currently available.") @@ -3070,6 +3070,15 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: print() print(f"Login succeeded, but could not fetch available models. Reason: {message}") + # Write provider + model atomically so config is never mismatched. + config_path = _update_config_for_provider( + "nous", inference_base_url, default_model=selected_model, + ) + if selected_model: + _save_model_choice(selected_model) + print(f"Default model set to: {selected_model}") + print(f" Config updated: {config_path} (model.provider=nous)") + except KeyboardInterrupt: print("\nLogin cancelled.") raise SystemExit(130) From fce23e8024cfac3343ca0c433bfdd3a7dea1de0e Mon Sep 17 00:00:00 2001 From: MustafaKara7 Date: Wed, 8 Apr 2026 22:13:11 +0300 Subject: [PATCH 02/51] fix(docker): #6197 enable unbuffered stdout for live logs --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index a9624530..0eddaba0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM debian:13.4 +# Disable Python stdout buffering to ensure logs are printed immediately +ENV PYTHONUNBUFFERED=1 + # Install system dependencies in one layer, clear APT cache RUN apt-get update && \ apt-get install -y --no-install-recommends \ From 019c11d07e082acf401cfc7f008757df0cd98a05 Mon Sep 17 00:00:00 2001 From: Yang Zhi Date: Wed, 8 Apr 2026 16:18:07 +0800 Subject: [PATCH 03/51] fix(fallback): preserve provider-specific headers when activating fallback When _try_activate_fallback() swaps to a new provider (e.g. kimi-coding), resolve_provider_client() correctly injects provider-specific default_headers (like KimiCLI User-Agent) into the returned OpenAI client. However, _client_kwargs was saved with only api_key and base_url, dropping those headers. Every subsequent API call rebuilds the client from _client_kwargs via _create_request_openai_client(), producing a bare OpenAI client without the required headers. Kimi Coding rejects this with 403; Copilot would lose its auth headers similarly. This patch reads _custom_headers from the fallback client (where the OpenAI SDK stores the default_headers kwarg) and includes them in _client_kwargs so any client rebuild preserves provider-specific headers. Fixes #6075 --- run_agent.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/run_agent.py b/run_agent.py index b8ed44ef..d1c8980d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4968,9 +4968,21 @@ class AIAgent: # Swap OpenAI client and config in-place self.api_key = fb_client.api_key self.client = fb_client + # Preserve provider-specific headers that + # resolve_provider_client() may have baked into + # fb_client via the default_headers kwarg. The OpenAI + # SDK stores these in _custom_headers. Without this, + # subsequent request-client rebuilds (via + # _create_request_openai_client) drop the headers, + # causing 403s from providers like Kimi Coding that + # require a User-Agent sentinel. + fb_headers = getattr(fb_client, "_custom_headers", None) + if not fb_headers: + fb_headers = getattr(fb_client, "default_headers", None) self._client_kwargs = { "api_key": fb_client.api_key, "base_url": fb_base_url, + **({"default_headers": dict(fb_headers)} if fb_headers else {}), } # Re-evaluate prompt caching for the new provider/model From 4d1b98807016ba7d5c573c00477626863083f48d Mon Sep 17 00:00:00 2001 From: Yang Zhi Date: Tue, 7 Apr 2026 01:18:07 +0800 Subject: [PATCH 04/51] fix(credential_pool): use _resolve_kimi_base_url when seeding kimi-coding pool The credential pool seeder (_seed_from_env) hardcoded the base URL for API-key providers without running provider-specific auto-detection. For kimi-coding, this caused sk-kimi- prefixed keys to be seeded with the legacy api.moonshot.ai/v1 endpoint instead of api.kimi.com/coding/v1, resulting in HTTP 401 on the first request. Import and call _resolve_kimi_base_url for kimi-coding so the pool uses the correct endpoint based on the key prefix, matching the runtime credential resolver behavior. Also fix a comment: sk-kimi- keys are issued by kimi.com/code, not platform.kimi.ai. Fixes #5561 --- agent/credential_pool.py | 6 +++++- hermes_cli/auth.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index dd2c9abc..a17d71ba 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -18,12 +18,14 @@ import hermes_cli.auth as auth_mod from hermes_cli.auth import ( CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, + KIMI_CODE_BASE_URL, PROVIDER_REGISTRY, _codex_access_token_is_expiring, _decode_jwt_claims, _import_codex_cli_tokens, _load_auth_store, _load_provider_state, + _resolve_kimi_base_url, _resolve_zai_base_url, read_credential_pool, write_credential_pool, @@ -1084,7 +1086,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool active_sources.add(source) auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY base_url = env_url or pconfig.inference_base_url - if provider == "zai": + if provider == "kimi-coding": + base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url) + elif provider == "zai": base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url) changed |= _upsert_entry( entries, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6689e5fb..4d59f7db 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -250,7 +250,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { # Kimi Code Endpoint Detection # ============================================================================= -# Kimi Code (platform.kimi.ai) issues keys prefixed "sk-kimi-" that only work +# Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work # on api.kimi.com/coding/v1. Legacy keys from platform.moonshot.ai work on # api.moonshot.ai/v1 (the default). Auto-detect when user hasn't set # KIMI_BASE_URL explicitly. From 110cdd573a4e106614d7ab5b69d8e8e06459afb9 Mon Sep 17 00:00:00 2001 From: Yang Zhi Date: Tue, 7 Apr 2026 17:50:42 +0800 Subject: [PATCH 05/51] fix(auxiliary_client): inject KimiCLI User-Agent for custom endpoint sync clients When is explicitly set to , the custom-endpoint path in creates a plain client without provider-specific headers. This means sync vision calls (e.g. ) use the generic User-Agent and get rejected by Kimi's coding endpoint with a 403: 'Kimi For Coding is currently only available for Coding Agents such as Kimi CLI...' The async converter already injects , and the auto-detected API-key provider path also injects it, but the explicit custom endpoint shortcut was missing it entirely. This patch adds the same injection to the custom endpoint branch, and updates all existing Kimi header sites to for consistency. Fixes --- agent/auxiliary_client.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2887447d..2f3a64a6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -702,7 +702,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model) extra = {} if "api.kimi.com" in base_url.lower(): - extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"} + extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"} elif "api.githubcopilot.com" in base_url.lower(): from hermes_cli.models import copilot_default_headers @@ -721,7 +721,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model) extra = {} if "api.kimi.com" in base_url.lower(): - extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"} + extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"} elif "api.githubcopilot.com" in base_url.lower(): from hermes_cli.models import copilot_default_headers @@ -1195,7 +1195,7 @@ def _to_async_client(sync_client, model: str): async_kwargs["default_headers"] = copilot_default_headers() elif "api.kimi.com" in base_lower: - async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"} + async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"} return AsyncOpenAI(**async_kwargs), model @@ -1315,7 +1315,13 @@ def resolve_provider_client( ) return None, None final_model = model or _read_main_model() or "gpt-4o-mini" - client = OpenAI(api_key=custom_key, base_url=custom_base) + extra = {} + if "api.kimi.com" in custom_base.lower(): + extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"} + elif "api.githubcopilot.com" in custom_base.lower(): + from hermes_cli.models import copilot_default_headers + extra["default_headers"] = copilot_default_headers() + client = OpenAI(api_key=custom_key, base_url=custom_base, **extra) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) # Try custom first, then codex, then API-key providers @@ -1394,7 +1400,7 @@ def resolve_provider_client( # Provider-specific headers headers = {} if "api.kimi.com" in base_url.lower(): - headers["User-Agent"] = "KimiCLI/1.0" + headers["User-Agent"] = "KimiCLI/1.3" elif "api.githubcopilot.com" in base_url.lower(): from hermes_cli.models import copilot_default_headers From 2f0a83dd126d66dacc6ccbc88a98a964e443586b Mon Sep 17 00:00:00 2001 From: Yang Zhi Date: Thu, 9 Apr 2026 11:31:41 +0800 Subject: [PATCH 06/51] fix(cli): update TUI status bar model name on provider fallback The status bar reads self.model from the CLI class, which is set once at init and never updated when _try_activate_fallback() switches to a backup provider/model in run_agent.py. This causes the TUI to display the original model name while context_length_max changes, creating a confusing mismatch. Read the model name from agent.model (live, updated by fallback) with self.model as fallback before the agent is created. Remove the redundant getattr(self, 'agent') call that was already done above. --- cli.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index fa32ae91..347250e3 100644 --- a/cli.py +++ b/cli.py @@ -1603,7 +1603,12 @@ class HermesCLI: return f"[{('█' * filled) + ('░' * max(0, width - filled))}]" def _get_status_bar_snapshot(self) -> Dict[str, Any]: - model_name = self.model or "unknown" + # Prefer the agent's model name — it updates on fallback. + # self.model reflects the originally configured model and never + # changes mid-session, so the TUI would show a stale name after + # _try_activate_fallback() switches provider/model. + agent = getattr(self, "agent", None) + model_name = (getattr(agent, "model", None) or self.model or "unknown") model_short = model_name.split("/")[-1] if "/" in model_name else model_name if model_short.endswith(".gguf"): model_short = model_short[:-5] @@ -1629,7 +1634,6 @@ class HermesCLI: "compressions": 0, } - agent = getattr(self, "agent", None) if not agent: return snapshot From 3007174a61cbad9145794f24c9d7f9251fdf1d39 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:11:34 -0700 Subject: [PATCH 07/51] fix: prevent 400 format errors from triggering compression loop on Codex Responses API (#6751) The error classifier's generic-400 heuristic only extracted err_body_msg from the nested body structure (body['error']['message']), missing the flat body format used by OpenAI's Responses API (body['message']). This caused descriptive 400 errors like 'Invalid input[index].name: string does not match pattern' to appear generic when the session was large, misclassifying them as context overflow and triggering an infinite compression loop. Added flat-body fallback in _classify_400() consistent with the parent classify_api_error() function's existing handling at line 297-298. --- agent/error_classifier.py | 3 +++ tests/agent/test_error_classifier.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index b227932a..0f145011 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -596,6 +596,9 @@ def _classify_400( err_obj = body.get("error", {}) if isinstance(err_obj, dict): err_body_msg = (err_obj.get("message") or "").strip().lower() + # Responses API (and some providers) use flat body: {"message": "..."} + if not err_body_msg: + err_body_msg = (body.get("message") or "").strip().lower() is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "") is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80 diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index da248f82..c5973558 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -507,6 +507,38 @@ class TestClassifyApiError: assert result.reason == FailoverReason.format_error assert result.retryable is False + def test_400_flat_body_descriptive_not_context_overflow(self): + """Responses API flat body with descriptive error + large session → format error. + + The Codex Responses API returns errors in flat body format: + {"message": "...", "type": "..."} without an "error" wrapper. + A descriptive 400 must NOT be misclassified as context overflow + just because the session is large. + """ + e = MockAPIError( + "Invalid 'input[index].name': string does not match pattern.", + status_code=400, + body={"message": "Invalid 'input[index].name': string does not match pattern.", + "type": "invalid_request_error"}, + ) + result = classify_api_error(e, approx_tokens=200000, context_length=400000, num_messages=500) + assert result.reason == FailoverReason.format_error + assert result.retryable is False + + def test_400_flat_body_generic_large_session_still_context_overflow(self): + """Flat body with generic 'Error' message + large session → context overflow. + + Regression: the flat-body fallback must not break the existing heuristic + for genuinely generic errors from providers that use flat bodies. + """ + e = MockAPIError( + "Error", + status_code=400, + body={"message": "Error"}, + ) + result = classify_api_error(e, approx_tokens=100000, context_length=200000) + assert result.reason == FailoverReason.context_overflow + # ── Peer closed + large session ── def test_peer_closed_large_session(self): From ee16416c7b23b670d8e4172b6346b077adc83d2d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:13:11 -0700 Subject: [PATCH 08/51] fix(cli): prefer auth.py env vars over models.dev in provider detection (#6755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_authenticated_providers() was using env var names from the external models.dev registry to detect credentials. This registry has incorrect mappings for 5 providers: minimax-cn, zai, opencode-zen, opencode-go, and kilocode — causing them to not appear in /model even when the correct API key is set. Now checks PROVIDER_REGISTRY from auth.py first (our source of truth), falling back to models.dev only for providers not in our registry. Fixes #6620. Based on devorun's investigation in PR #6625. --- hermes_cli/model_switch.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7d120d94..ef35108d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -733,6 +733,7 @@ def list_authenticated_providers( fetch_models_dev, get_provider_info as _mdev_pinfo, ) + from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS results: List[dict] = [] @@ -753,9 +754,16 @@ def list_authenticated_providers( if not isinstance(pdata, dict): continue - env_vars = pdata.get("env", []) - if not isinstance(env_vars, list): - continue + # Prefer auth.py PROVIDER_REGISTRY for env var names — it's our + # source of truth. models.dev can have wrong mappings (e.g. + # minimax-cn → MINIMAX_API_KEY instead of MINIMAX_CN_API_KEY). + pconfig = PROVIDER_REGISTRY.get(hermes_id) + if pconfig and pconfig.api_key_env_vars: + env_vars = list(pconfig.api_key_env_vars) + else: + env_vars = pdata.get("env", []) + if not isinstance(env_vars, list): + continue # Check if any env var is set has_creds = any(os.environ.get(ev) for ev in env_vars) From 2772d990853faf5ddf6fc07e4f28f8ff7b692794 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:27:27 -0700 Subject: [PATCH 09/51] =?UTF-8?q?fix:=20remove=20/prompt=20slash=20command?= =?UTF-8?q?=20=E2=80=94=20footgun=20via=20prefix=20expansion=20(#6752)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /pr silently resolved to /prompt via the shortest-match tiebreaker in prefix expansion, permanently overwriting the system prompt and persisting to config. The command's functionality (setting agent.system_prompt) is available via config.yaml and /personality covers the common use case. Removes: CommandDef, dispatch branch, _handle_prompt_command handler, docs references, and updates subcommand extraction test. --- cli.py | 58 +------------------ hermes_cli/commands.py | 3 +- .../hermes-agent/SKILL.md | 1 - tests/hermes_cli/test_commands.py | 4 +- website/docs/reference/slash-commands.md | 3 +- 5 files changed, 6 insertions(+), 63 deletions(-) diff --git a/cli.py b/cli.py index 347250e3..db956766 100644 --- a/cli.py +++ b/cli.py @@ -4008,59 +4008,7 @@ class HermesCLI: print(" To change model or provider, use: hermes model") - def _handle_prompt_command(self, cmd: str): - """Handle the /prompt command to view or set system prompt.""" - parts = cmd.split(maxsplit=1) - - if len(parts) > 1: - # Set new prompt - new_prompt = parts[1].strip() - - if new_prompt.lower() == "clear": - self.system_prompt = "" - self.agent = None # Force re-init - if save_config_value("agent.system_prompt", ""): - print("(^_^)b System prompt cleared (saved to config)") - else: - print("(^_^) System prompt cleared (session only)") - else: - self.system_prompt = new_prompt - self.agent = None # Force re-init - if save_config_value("agent.system_prompt", new_prompt): - print("(^_^)b System prompt set (saved to config)") - else: - print("(^_^) System prompt set (session only)") - print(f" \"{new_prompt[:60]}{'...' if len(new_prompt) > 60 else ''}\"") - else: - # Show current prompt - print() - print("+" + "-" * 50 + "+") - print("|" + " " * 15 + "(^_^) System Prompt" + " " * 15 + "|") - print("+" + "-" * 50 + "+") - print() - if self.system_prompt: - # Word wrap the prompt for display - words = self.system_prompt.split() - lines = [] - current_line = "" - for word in words: - if len(current_line) + len(word) + 1 <= 50: - current_line += (" " if current_line else "") + word - else: - lines.append(current_line) - current_line = word - if current_line: - lines.append(current_line) - for line in lines: - print(f" {line}") - else: - print(" (no custom prompt set - using default)") - print() - print(" Usage:") - print(" /prompt - Set a custom system prompt") - print(" /prompt clear - Remove custom prompt") - print(" /personality - Use a predefined personality") - print() + @staticmethod @@ -4560,9 +4508,7 @@ class HermesCLI: self._handle_model_switch(cmd_original) elif canonical == "provider": self._show_model_and_providers() - elif canonical == "prompt": - # Use original case so prompt text isn't lowercased - self._handle_prompt_command(cmd_original) + elif canonical == "personality": # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 70d9cb8a..ac0f44d7 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -87,8 +87,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"), CommandDef("provider", "Show available providers and current provider", "Configuration"), - CommandDef("prompt", "View/set custom system prompt", "Configuration", - cli_only=True, args_hint="[text]", subcommands=("clear",)), + CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"), CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 8d93e3fb..74445c26 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -249,7 +249,6 @@ Type these during an interactive chat session. /config Show config (CLI) /model [name] Show or change model /provider Show provider info -/prompt [text] View/set system prompt (CLI) /personality [name] Set personality /reasoning [level] Set reasoning (none|low|medium|high|xhigh|show|hide) /verbose Cycle: off → new → all → verbose diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 81c262a8..98a4b2ef 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -425,8 +425,8 @@ class TestSlashCommandCompleter: class TestSubcommands: def test_explicit_subcommands_extracted(self): """Commands with explicit subcommands on CommandDef are extracted.""" - assert "/prompt" in SUBCOMMANDS - assert "clear" in SUBCOMMANDS["/prompt"] + assert "/skills" in SUBCOMMANDS + assert "install" in SUBCOMMANDS["/skills"] def test_reasoning_has_subcommands(self): assert "/reasoning" in SUBCOMMANDS diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 89a30c46..a695d8dc 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -46,7 +46,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/config` | Show current configuration | | `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint) | | `/provider` | Show available providers and current provider | -| `/prompt` | View/set custom system prompt | | `/personality` | Set a predefined personality | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | | `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) | @@ -144,7 +143,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/statusbar`, and `/plugins` are **CLI-only** commands. +- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/statusbar`, and `/plugins` are **CLI-only** commands. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/status`, `/sethome`, `/update`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands. - `/background`, `/voice`, `/reload-mcp`, `/rollback`, and `/yolo` work in **both** the CLI and the messaging gateway. From 34d06a980244fb5bd88345765708cd7e36e3f118 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 9 Apr 2026 16:54:23 +0200 Subject: [PATCH 10/51] fix(compaction): don't halve context_length on output-cap-too-large errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the API returns "max_tokens too large given prompt" (input tokens are within the context window, but input + requested output > window), the old code incorrectly routed through the same handler as "prompt too long" errors, calling get_next_probe_tier() and permanently halving context_length. This made things worse: the window was fine, only the requested output size needed trimming for that one call. Two distinct error classes now handled separately: Prompt too long — input itself exceeds context window. Fix: compress history + halve context_length (existing behaviour, unchanged). Output cap too large — input OK, but input + max_tokens > window. Fix: parse available_tokens from the error message, set a one-shot _ephemeral_max_output_tokens override for the retry, and leave context_length completely untouched. Changes: - agent/model_metadata.py: add parse_available_output_tokens_from_error() that detects Anthropic's "available_tokens: N" error format and returns the available output budget, or None for all other error types. - run_agent.py: call the new parser first in the is_context_length_error block; if it fires, set _ephemeral_max_output_tokens (with a 64-token safety margin) and break to retry without touching context_length. _build_api_kwargs consumes the ephemeral value exactly once then clears it so subsequent calls use self.max_tokens normally. - agent/anthropic_adapter.py: expand build_anthropic_kwargs docstring to clearly document the max_tokens (output cap) vs context_length (total window) distinction, which is a persistent source of confusion due to the OpenAI-inherited "max_tokens" name. - cli-config.yaml.example: add inline comments explaining both keys side by side where users are most likely to look. - website/docs/integrations/providers.md: add a callout box at the top of "Context Length Detection" and clarify the troubleshooting entry. - tests/test_ctx_halving_fix.py: 24 tests across four classes covering the parser, build_anthropic_kwargs clamping, ephemeral one-shot consumption, and the invariant that context_length is never mutated on output-cap errors. --- agent/anthropic_adapter.py | 33 ++- agent/model_metadata.py | 43 ++++ cli-config.yaml.example | 19 ++ run_agent.py | 56 ++++- tests/test_ctx_halving_fix.py | 319 +++++++++++++++++++++++++ website/docs/integrations/providers.md | 13 +- 6 files changed, 472 insertions(+), 11 deletions(-) create mode 100644 tests/test_ctx_halving_fix.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fa5e391a..d5c0c06f 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1238,10 +1238,27 @@ def build_anthropic_kwargs( ) -> Dict[str, Any]: """Build kwargs for anthropic.messages.create(). - When *max_tokens* is None, the model's native output limit is used - (e.g. 128K for Opus 4.6, 64K for Sonnet 4.6). If *context_length* - is provided, the effective limit is clamped so it doesn't exceed - the context window. + Naming note — two distinct concepts, easily confused: + max_tokens = OUTPUT token cap for a single response. + Anthropic's API calls this "max_tokens" but it only + limits the *output*. Anthropic's own native SDK + renamed it "max_output_tokens" for clarity. + context_length = TOTAL context window (input tokens + output tokens). + The API enforces: input_tokens + max_tokens ≤ context_length. + Stored on the ContextCompressor; reduced on overflow errors. + + When *max_tokens* is None the model's native output ceiling is used + (e.g. 128K for Opus 4.6, 64K for Sonnet 4.6). + + When *context_length* is provided and the model's native output ceiling + exceeds it (e.g. a local endpoint with an 8K window), the output cap is + clamped to context_length − 1. This only kicks in for unusually small + context windows; for full-size models the native output cap is always + smaller than the context window so no clamping happens. + NOTE: this clamping does not account for prompt size — if the prompt is + large, Anthropic may still reject the request. The caller must detect + "max_tokens too large given prompt" errors and retry with a smaller cap + (see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens). When *is_oauth* is True, applies Claude Code compatibility transforms: system prompt prefix, tool name prefixing, and prompt sanitization. @@ -1256,10 +1273,14 @@ def build_anthropic_kwargs( anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] model = normalize_model_name(model, preserve_dots=preserve_dots) + # effective_max_tokens = output cap for this call (≠ total context window) effective_max_tokens = max_tokens or _get_anthropic_max_output(model) - # Clamp to context window if the user set a lower context_length - # (e.g. custom endpoint with limited capacity). + # Clamp output cap to fit inside the total context window. + # Only matters for small custom endpoints where context_length < native + # output ceiling. For standard Anthropic models context_length (e.g. + # 200K) is always larger than the output ceiling (e.g. 128K), so this + # branch is not taken. if context_length and effective_max_tokens > context_length: effective_max_tokens = max(context_length - 1, 1) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 9282586f..791f778c 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -603,6 +603,49 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]: return None +def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: + """Detect an "output cap too large" error and return how many output tokens are available. + + Background — two distinct context errors exist: + 1. "Prompt too long" — the INPUT itself exceeds the context window. + Fix: compress history and/or halve context_length. + 2. "max_tokens too large" — input is fine, but input + requested_output > window. + Fix: reduce max_tokens (the output cap) for this call. + Do NOT touch context_length — the window hasn't shrunk. + + Anthropic's API returns errors like: + "max_tokens: 32768 > context_window: 200000 - input_tokens: 190000 = available_tokens: 10000" + + Returns the number of output tokens that would fit (e.g. 10000 above), or None if + the error does not look like a max_tokens-too-large error. + """ + error_lower = error_msg.lower() + + # Must look like an output-cap error, not a prompt-length error. + is_output_cap_error = ( + "max_tokens" in error_lower + and ("available_tokens" in error_lower or "available tokens" in error_lower) + ) + if not is_output_cap_error: + return None + + # Extract the available_tokens figure. + # Anthropic format: "… = available_tokens: 10000" + patterns = [ + r'available_tokens[:\s]+(\d+)', + r'available\s+tokens[:\s]+(\d+)', + # fallback: last number after "=" in expressions like "200000 - 190000 = 10000" + r'=\s*(\d+)\s*$', + ] + for pattern in patterns: + match = re.search(pattern, error_lower) + if match: + tokens = int(match.group(1)) + if tokens >= 1: + return tokens + return None + + def _model_id_matches(candidate_id: str, lookup_model: str) -> bool: """Return True if *candidate_id* (from server) matches *lookup_model* (configured). diff --git a/cli-config.yaml.example b/cli-config.yaml.example index d7528444..346e6e85 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -48,6 +48,25 @@ model: # api_key: "your-key-here" # Uncomment to set here instead of .env base_url: "https://openrouter.ai/api/v1" + # ── Token limits — two settings, easy to confuse ────────────────────────── + # + # context_length: TOTAL context window (input + output tokens combined). + # Controls when Hermes compresses history and validates requests. + # Leave unset — Hermes auto-detects the correct value from the provider. + # Set manually only when auto-detection is wrong (e.g. a local server with + # a custom num_ctx, or a proxy that doesn't expose /v1/models). + # + # context_length: 131072 + # + # max_tokens: OUTPUT cap — maximum tokens the model may generate per response. + # Unrelated to how long your conversation history can be. + # The OpenAI-standard name "max_tokens" is a misnomer; Anthropic's native + # API has since renamed it "max_output_tokens" for clarity. + # Leave unset to use the model's native output ceiling (recommended). + # Set only if you want to deliberately limit individual response length. + # + # max_tokens: 8192 + # ============================================================================= # OpenRouter Provider Routing (only applies when using OpenRouter) # ============================================================================= diff --git a/run_agent.py b/run_agent.py index d1c8980d..db3f4b31 100644 --- a/run_agent.py +++ b/run_agent.py @@ -87,6 +87,7 @@ from agent.model_metadata import ( fetch_model_metadata, estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough, get_next_probe_tier, parse_context_limit_from_error, + parse_available_output_tokens_from_error, save_context_length, is_local_endpoint, query_ollama_num_ctx, ) @@ -5397,15 +5398,22 @@ class AIAgent: if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_kwargs anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) - # Pass context_length so the adapter can clamp max_tokens if the - # user configured a smaller context window than the model's output limit. + # Pass context_length (total input+output window) so the adapter can + # clamp max_tokens (output cap) when the user configured a smaller + # context window than the model's native output limit. ctx_len = getattr(self, "context_compressor", None) ctx_len = ctx_len.context_length if ctx_len else None + # _ephemeral_max_output_tokens is set for one call when the API + # returns "max_tokens too large given prompt" — it caps output to + # the available window space without touching context_length. + ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None) + if ephemeral_out is not None: + self._ephemeral_max_output_tokens = None # consume immediately return build_anthropic_kwargs( model=self.model, messages=anthropic_messages, tools=self.tools, - max_tokens=self.max_tokens, + max_tokens=ephemeral_out if ephemeral_out is not None else self.max_tokens, reasoning_config=self.reasoning_config, is_oauth=self._is_anthropic_oauth, preserve_dots=self._anthropic_preserve_dots(), @@ -8306,6 +8314,48 @@ class AIAgent: compressor = self.context_compressor old_ctx = compressor.context_length + # ── Distinguish two very different errors ─────────── + # 1. "Prompt too long": the INPUT exceeds the context window. + # Fix: reduce context_length + compress history. + # 2. "max_tokens too large": input is fine, but + # input_tokens + requested max_tokens > context_window. + # Fix: reduce max_tokens (the OUTPUT cap) for this call. + # Do NOT shrink context_length — the window is unchanged. + # + # Note: max_tokens = output token cap (one response). + # context_length = total window (input + output combined). + available_out = parse_available_output_tokens_from_error(error_msg) + if available_out is not None: + # Error is purely about the output cap being too large. + # Cap output to the available space and retry without + # touching context_length or triggering compression. + safe_out = max(1, available_out - 64) # small safety margin + self._ephemeral_max_output_tokens = safe_out + self._vprint( + f"{self.log_prefix}⚠️ Output cap too large for current prompt — " + f"retrying with max_tokens={safe_out:,} " + f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})", + force=True, + ) + # Still count against compression_attempts so we don't + # loop forever if the error keeps recurring. + compression_attempts += 1 + if compression_attempts > max_compression_attempts: + self._vprint(f"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True) + self._vprint(f"{self.log_prefix} 💡 Try /new to start a fresh conversation, or /compress to retry compression.", force=True) + logging.error(f"{self.log_prefix}Context compression failed after {max_compression_attempts} attempts.") + self._persist_session(messages, conversation_history) + return { + "messages": messages, + "completed": False, + "api_calls": api_call_count, + "error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.", + "partial": True + } + restart_with_compressed_messages = True + break + + # Error is about the INPUT being too large — reduce context_length. # Try to parse the actual limit from the error message parsed_limit = parse_context_limit_from_error(error_msg) if parsed_limit and parsed_limit < old_ctx: diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py new file mode 100644 index 00000000..1ba423c8 --- /dev/null +++ b/tests/test_ctx_halving_fix.py @@ -0,0 +1,319 @@ +"""Tests for the context-halving bugfix. + +Background +---------- +When the API returns "max_tokens too large given prompt" (input is fine, +but input_tokens + requested max_tokens > context_window), the old code +incorrectly halved context_length via get_next_probe_tier(). + +The fix introduces: + * parse_available_output_tokens_from_error() — detects this specific + error class and returns the available output token budget. + * _ephemeral_max_output_tokens on AIAgent — a one-shot override that + caps the output for one retry without touching context_length. + +Naming note +----------- + max_tokens = OUTPUT token cap (a single response). + context_length = TOTAL context window (input + output combined). +These are different and the old code conflated them; the fix keeps them +separate. +""" + +import sys +import os +from unittest.mock import MagicMock, patch, PropertyMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest + + +# --------------------------------------------------------------------------- +# parse_available_output_tokens_from_error — unit tests +# --------------------------------------------------------------------------- + +class TestParseAvailableOutputTokens: + """Pure-function tests; no I/O required.""" + + def _parse(self, msg): + from agent.model_metadata import parse_available_output_tokens_from_error + return parse_available_output_tokens_from_error(msg) + + # ── Should detect and extract ──────────────────────────────────────── + + def test_anthropic_canonical_format(self): + """Canonical Anthropic error: max_tokens: X > context_window: Y - input_tokens: Z = available_tokens: W""" + msg = ( + "max_tokens: 32768 > context_window: 200000 " + "- input_tokens: 190000 = available_tokens: 10000" + ) + assert self._parse(msg) == 10000 + + def test_anthropic_format_large_numbers(self): + msg = ( + "max_tokens: 128000 > context_window: 200000 " + "- input_tokens: 180000 = available_tokens: 20000" + ) + assert self._parse(msg) == 20000 + + def test_available_tokens_variant_spacing(self): + """Handles extra spaces around the colon.""" + msg = "max_tokens: 32768 > 200000 available_tokens : 5000" + assert self._parse(msg) == 5000 + + def test_available_tokens_natural_language(self): + """'available tokens: N' wording (no underscore).""" + msg = "max_tokens must be at most 10000 given your prompt (available tokens: 10000)" + assert self._parse(msg) == 10000 + + def test_single_token_available(self): + """Edge case: only 1 token left.""" + msg = "max_tokens: 9999 > context_window: 10000 - input_tokens: 9999 = available_tokens: 1" + assert self._parse(msg) == 1 + + # ── Should NOT detect (returns None) ───────────────────────────────── + + def test_prompt_too_long_is_not_output_cap_error(self): + """'prompt is too long' errors must NOT be caught — they need context halving.""" + msg = "prompt is too long: 205000 tokens > 200000 maximum" + assert self._parse(msg) is None + + def test_generic_context_window_exceeded(self): + """Generic context window errors without available_tokens should not match.""" + msg = "context window exceeded: maximum is 32768 tokens" + assert self._parse(msg) is None + + def test_context_length_exceeded(self): + msg = "context_length_exceeded: prompt has 131073 tokens, limit is 131072" + assert self._parse(msg) is None + + def test_no_max_tokens_keyword(self): + """Error not related to max_tokens at all.""" + msg = "invalid_api_key: the API key is invalid" + assert self._parse(msg) is None + + def test_empty_string(self): + assert self._parse("") is None + + def test_rate_limit_error(self): + msg = "rate_limit_error: too many requests per minute" + assert self._parse(msg) is None + + +# --------------------------------------------------------------------------- +# build_anthropic_kwargs — output cap clamping +# --------------------------------------------------------------------------- + +class TestBuildAnthropicKwargsClamping: + """The context_length clamp only fires when output ceiling > window. + For standard Anthropic models (output ceiling < window) it must not fire. + """ + + def _build(self, model, max_tokens=None, context_length=None): + from agent.anthropic_adapter import build_anthropic_kwargs + return build_anthropic_kwargs( + model=model, + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=max_tokens, + reasoning_config=None, + context_length=context_length, + ) + + def test_no_clamping_when_output_ceiling_fits_in_window(self): + """Opus 4.6 native output (128K) < context window (200K) — no clamping.""" + kwargs = self._build("claude-opus-4-6", context_length=200_000) + assert kwargs["max_tokens"] == 128_000 + + def test_clamping_fires_for_tiny_custom_window(self): + """When context_length is 8K (local model), output cap is clamped to 7999.""" + kwargs = self._build("claude-opus-4-6", context_length=8_000) + assert kwargs["max_tokens"] == 7_999 + + def test_explicit_max_tokens_respected_when_within_window(self): + """Explicit max_tokens smaller than window passes through unchanged.""" + kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000) + assert kwargs["max_tokens"] == 4096 + + def test_explicit_max_tokens_clamped_when_exceeds_window(self): + """Explicit max_tokens larger than a small window is clamped.""" + kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000) + assert kwargs["max_tokens"] == 15_999 + + def test_no_context_length_uses_native_ceiling(self): + """Without context_length the native output ceiling is used directly.""" + kwargs = self._build("claude-sonnet-4-6") + assert kwargs["max_tokens"] == 64_000 + + +# --------------------------------------------------------------------------- +# Ephemeral max_tokens mechanism — _build_api_kwargs +# --------------------------------------------------------------------------- + +class TestEphemeralMaxOutputTokens: + """_build_api_kwargs consumes _ephemeral_max_output_tokens exactly once + and falls back to self.max_tokens on subsequent calls. + """ + + def _make_agent(self): + """Return a minimal AIAgent with api_mode='anthropic_messages' and + a stubbed context_compressor, bypassing full __init__ cost.""" + from run_agent import AIAgent + agent = object.__new__(AIAgent) + # Minimal attributes used by _build_api_kwargs + agent.api_mode = "anthropic_messages" + agent.model = "claude-opus-4-6" + agent.tools = [] + agent.max_tokens = None + agent.reasoning_config = None + agent._is_anthropic_oauth = False + agent._ephemeral_max_output_tokens = None + + compressor = MagicMock() + compressor.context_length = 200_000 + agent.context_compressor = compressor + + # Stub out the internal message-preparation helper + agent._prepare_anthropic_messages_for_api = MagicMock( + return_value=[{"role": "user", "content": "hi"}] + ) + agent._anthropic_preserve_dots = MagicMock(return_value=False) + return agent + + def test_ephemeral_override_is_used_on_first_call(self): + """When _ephemeral_max_output_tokens is set, it overrides self.max_tokens.""" + agent = self._make_agent() + agent._ephemeral_max_output_tokens = 5_000 + + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs["max_tokens"] == 5_000 + + def test_ephemeral_override_is_consumed_after_one_call(self): + """After one call the ephemeral override is cleared to None.""" + agent = self._make_agent() + agent._ephemeral_max_output_tokens = 5_000 + + agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert agent._ephemeral_max_output_tokens is None + + def test_subsequent_call_uses_self_max_tokens(self): + """A second _build_api_kwargs call uses the normal max_tokens path.""" + agent = self._make_agent() + agent._ephemeral_max_output_tokens = 5_000 + agent.max_tokens = None # will resolve to native ceiling (128K for Opus 4.6) + + agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + # Second call — ephemeral is gone + kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs2["max_tokens"] == 128_000 # Opus 4.6 native ceiling + + def test_no_ephemeral_uses_self_max_tokens_directly(self): + """Without an ephemeral override, self.max_tokens is used normally.""" + agent = self._make_agent() + agent.max_tokens = 8_192 + + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert kwargs["max_tokens"] == 8_192 + + +# --------------------------------------------------------------------------- +# Integration: error handler does NOT halve context_length for output-cap errors +# --------------------------------------------------------------------------- + +class TestContextNotHalvedOnOutputCapError: + """When the API returns 'max_tokens too large given prompt', the handler + must set _ephemeral_max_output_tokens and NOT modify context_length. + """ + + def _make_agent_with_compressor(self, context_length=200_000): + from run_agent import AIAgent + from agent.context_compressor import ContextCompressor + + agent = object.__new__(AIAgent) + agent.api_mode = "anthropic_messages" + agent.model = "claude-opus-4-6" + agent.base_url = "https://api.anthropic.com" + agent.tools = [] + agent.max_tokens = None + agent.reasoning_config = None + agent._is_anthropic_oauth = False + agent._ephemeral_max_output_tokens = None + agent.log_prefix = "" + agent.quiet_mode = True + agent.verbose_logging = False + + compressor = MagicMock(spec=ContextCompressor) + compressor.context_length = context_length + compressor.threshold_percent = 0.75 + agent.context_compressor = compressor + + agent._prepare_anthropic_messages_for_api = MagicMock( + return_value=[{"role": "user", "content": "hi"}] + ) + agent._anthropic_preserve_dots = MagicMock(return_value=False) + agent._vprint = MagicMock() + return agent + + def test_output_cap_error_sets_ephemeral_not_context_length(self): + """On 'max_tokens too large' error, _ephemeral_max_output_tokens is set + and compressor.context_length is left unchanged.""" + from agent.model_metadata import parse_available_output_tokens_from_error + from agent.model_metadata import get_next_probe_tier + + error_msg = ( + "max_tokens: 128000 > context_window: 200000 " + "- input_tokens: 180000 = available_tokens: 20000" + ) + + # Simulate the handler logic from run_agent.py + agent = self._make_agent_with_compressor(context_length=200_000) + old_ctx = agent.context_compressor.context_length + + available_out = parse_available_output_tokens_from_error(error_msg) + assert available_out == 20_000, "parser must detect the error" + + # The fix: set ephemeral, skip context_length modification + agent._ephemeral_max_output_tokens = max(1, available_out - 64) + + # context_length must be untouched + assert agent.context_compressor.context_length == old_ctx + assert agent._ephemeral_max_output_tokens == 19_936 + + def test_prompt_too_long_still_triggers_probe_tier(self): + """Genuine prompt-too-long errors must still use get_next_probe_tier.""" + from agent.model_metadata import parse_available_output_tokens_from_error + from agent.model_metadata import get_next_probe_tier + + error_msg = "prompt is too long: 205000 tokens > 200000 maximum" + + available_out = parse_available_output_tokens_from_error(error_msg) + assert available_out is None, "prompt-too-long must not be caught by output-cap parser" + + # The old halving path is still used for this class of error + new_ctx = get_next_probe_tier(200_000) + assert new_ctx == 128_000 + + def test_output_cap_error_safety_margin(self): + """The ephemeral value includes a 64-token safety margin below available_out.""" + from agent.model_metadata import parse_available_output_tokens_from_error + + error_msg = ( + "max_tokens: 32768 > context_window: 200000 " + "- input_tokens: 190000 = available_tokens: 10000" + ) + available_out = parse_available_output_tokens_from_error(error_msg) + safe_out = max(1, available_out - 64) + assert safe_out == 9_936 + + def test_safety_margin_never_goes_below_one(self): + """When available_out is very small, safe_out must be at least 1.""" + from agent.model_metadata import parse_available_output_tokens_from_error + + error_msg = ( + "max_tokens: 10 > context_window: 200000 " + "- input_tokens: 199990 = available_tokens: 1" + ) + available_out = parse_available_output_tokens_from_error(error_msg) + safe_out = max(1, available_out - 64) + assert safe_out == 1 diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index fbfa69ad..133990b4 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -657,8 +657,8 @@ model: #### Responses get cut off mid-sentence **Possible causes:** -1. **Low `max_tokens` on the server** — SGLang defaults to 128 tokens per response. Set `--default-max-tokens` on the server or configure Hermes with `model.max_tokens` in config.yaml. -2. **Context exhaustion** — The model filled its context window. Increase context length or enable [context compression](/docs/user-guide/configuration#context-compression) in Hermes. +1. **Low output cap (`max_tokens`) on the server** — SGLang defaults to 128 tokens per response. Set `--default-max-tokens` on the server or configure Hermes with `model.max_tokens` in config.yaml. Note: `max_tokens` controls response length only — it is unrelated to how long your conversation history can be (that is `context_length`). +2. **Context exhaustion** — The model filled its context window. Increase `model.context_length` or enable [context compression](/docs/user-guide/configuration#context-compression) in Hermes. --- @@ -751,6 +751,15 @@ model: ### Context Length Detection +:::note Two settings, easy to confuse +**`context_length`** is the **total context window** — the combined budget for input *and* output tokens (e.g. 200,000 for Claude Opus 4.6). Hermes uses this to decide when to compress history and to validate API requests. + +**`model.max_tokens`** is the **output cap** — the maximum number of tokens the model may generate in a *single response*. It has nothing to do with how long your conversation history can be. The industry-standard name `max_tokens` is a common source of confusion; Anthropic's native API has since renamed it `max_output_tokens` for clarity. + +Set `context_length` when auto-detection gets the window size wrong. +Set `model.max_tokens` only when you need to limit how long individual responses can be. +::: + Hermes uses a multi-source resolution chain to detect the correct context window for your model and provider: 1. **Config override** — `model.context_length` in config.yaml (highest priority) From 3eade90b399b99e2e76ff7b4bc16e4ccbe2f116c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:15:06 -0700 Subject: [PATCH 11/51] fix: OpenClaw migration now shows dry-run preview before executing (#6769) The setup wizard's OpenClaw migration previously ran immediately with aggressive defaults (overwrite=True, preset=full) after a single 'Would you like to import?' prompt. This caused several problems: - Config values with different semantics (e.g. tool_call_execution: 'auto' in OpenClaw vs 'off' for Hermes yolo mode) were imported without translation - Gateway tokens were hijacked from OpenClaw without warning, taking over Telegram/Slack/Discord channels - Instruction files (.md) containing OpenClaw-specific setup/restart procedures were copied, causing Hermes restart failures Now the migration: 1. Asks 'Would you like to see what can be imported?' (softer framing) 2. Runs a dry-run preview showing everything that would be imported 3. Displays categorized warnings for high-impact items (gateway takeover, config value differences, instruction files) 4. Asks for explicit confirmation with default=No 5. Executes with overwrite=False (preserves existing Hermes config) Also extracts _load_openclaw_migration_module() for reuse and adds _print_migration_preview() with keyword-based warning detection. Tests updated for two-phase behavior + new test for decline-after-preview. --- hermes_cli/setup.py | 194 +++++++++++++++--- .../test_setup_openclaw_migration.py | 80 +++++++- 2 files changed, 243 insertions(+), 31 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 95c9fa62..72b8aab1 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2572,9 +2572,120 @@ _OPENCLAW_SCRIPT = ( ) +def _load_openclaw_migration_module(): + """Load the openclaw_to_hermes migration script as a module. + + Returns the loaded module, or None if the script can't be loaded. + """ + if not _OPENCLAW_SCRIPT.exists(): + return None + + spec = importlib.util.spec_from_file_location( + "openclaw_to_hermes", _OPENCLAW_SCRIPT + ) + if spec is None or spec.loader is None: + return None + + mod = importlib.util.module_from_spec(spec) + # Register in sys.modules so @dataclass can resolve the module + # (Python 3.11+ requires this for dynamically loaded modules) + import sys as _sys + _sys.modules[spec.name] = mod + try: + spec.loader.exec_module(mod) + except Exception: + _sys.modules.pop(spec.name, None) + raise + return mod + + +# Item kinds that represent high-impact changes warranting explicit warnings. +# Gateway tokens/channels can hijack messaging platforms from the old agent. +# Config values may have different semantics between OpenClaw and Hermes. +# Instruction/context files (.md) can contain incompatible setup procedures. +_HIGH_IMPACT_KIND_KEYWORDS = { + "gateway": "⚠ Gateway/messaging — this will configure Hermes to use your OpenClaw messaging channels", + "telegram": "⚠ Telegram — this will point Hermes at your OpenClaw Telegram bot", + "slack": "⚠ Slack — this will point Hermes at your OpenClaw Slack workspace", + "discord": "⚠ Discord — this will point Hermes at your OpenClaw Discord bot", + "whatsapp": "⚠ WhatsApp — this will point Hermes at your OpenClaw WhatsApp connection", + "config": "⚠ Config values — OpenClaw settings may not map 1:1 to Hermes equivalents", + "soul": "⚠ Instruction file — may contain OpenClaw-specific setup/restart procedures", + "memory": "⚠ Memory/context file — may reference OpenClaw-specific infrastructure", + "context": "⚠ Context file — may contain OpenClaw-specific instructions", +} + + +def _print_migration_preview(report: dict): + """Print a detailed dry-run preview of what migration would do. + + Groups items by category and adds explicit warnings for high-impact + changes like gateway token takeover and config value differences. + """ + items = report.get("items", []) + if not items: + print_info("Nothing to migrate.") + return + + migrated_items = [i for i in items if i.get("status") == "migrated"] + conflict_items = [i for i in items if i.get("status") == "conflict"] + skipped_items = [i for i in items if i.get("status") == "skipped"] + + warnings_shown = set() + + if migrated_items: + print(color(" Would import:", Colors.GREEN)) + for item in migrated_items: + kind = item.get("kind", "unknown") + dest = item.get("destination", "") + if dest: + dest_short = str(dest).replace(str(Path.home()), "~") + print(f" {kind:<22s} → {dest_short}") + else: + print(f" {kind}") + + # Check for high-impact items and collect warnings + kind_lower = kind.lower() + dest_lower = str(dest).lower() + for keyword, warning in _HIGH_IMPACT_KIND_KEYWORDS.items(): + if keyword in kind_lower or keyword in dest_lower: + warnings_shown.add(warning) + print() + + if conflict_items: + print(color(" Would overwrite (conflicts with existing Hermes config):", Colors.YELLOW)) + for item in conflict_items: + kind = item.get("kind", "unknown") + reason = item.get("reason", "already exists") + print(f" {kind:<22s} {reason}") + print() + + if skipped_items: + print(color(" Would skip:", Colors.DIM)) + for item in skipped_items: + kind = item.get("kind", "unknown") + reason = item.get("reason", "") + print(f" {kind:<22s} {reason}") + print() + + # Print collected warnings + if warnings_shown: + print(color(" ── Warnings ──", Colors.YELLOW)) + for warning in sorted(warnings_shown): + print(color(f" {warning}", Colors.YELLOW)) + print() + print(color(" Note: OpenClaw config values may have different semantics in Hermes.", Colors.YELLOW)) + print(color(" For example, OpenClaw's tool_call_execution: \"auto\" ≠ Hermes's yolo mode.", Colors.YELLOW)) + print(color(" Instruction files (.md) from OpenClaw may contain incompatible procedures.", Colors.YELLOW)) + print() + + def _offer_openclaw_migration(hermes_home: Path) -> bool: """Detect ~/.openclaw and offer to migrate during first-time setup. + Runs a dry-run first to show the user exactly what would be imported, + overwritten, or taken over. Only executes after explicit confirmation. + Returns True if migration ran successfully, False otherwise. """ openclaw_dir = Path.home() / ".openclaw" @@ -2587,12 +2698,12 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: print() print_header("OpenClaw Installation Detected") print_info(f"Found OpenClaw data at {openclaw_dir}") - print_info("Hermes can import your settings, memories, skills, and API keys.") + print_info("Hermes can preview what would be imported before making any changes.") print() - if not prompt_yes_no("Would you like to import from OpenClaw?", default=True): + if not prompt_yes_no("Would you like to see what can be imported?", default=True): print_info( - "Skipping migration. You can run it later via the openclaw-migration skill." + "Skipping migration. You can run it later with: hermes claw migrate --dry-run" ) return False @@ -2601,34 +2712,71 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: if not config_path.exists(): save_config(load_config()) - # Dynamically load the migration script + # Load the migration module try: - spec = importlib.util.spec_from_file_location( - "openclaw_to_hermes", _OPENCLAW_SCRIPT - ) - if spec is None or spec.loader is None: + mod = _load_openclaw_migration_module() + if mod is None: print_warning("Could not load migration script.") return False + except Exception as e: + print_warning(f"Could not load migration script: {e}") + logger.debug("OpenClaw migration module load error", exc_info=True) + return False - mod = importlib.util.module_from_spec(spec) - # Register in sys.modules so @dataclass can resolve the module - # (Python 3.11+ requires this for dynamically loaded modules) - import sys as _sys - _sys.modules[spec.name] = mod - try: - spec.loader.exec_module(mod) - except Exception: - _sys.modules.pop(spec.name, None) - raise - - # Run migration with the "full" preset, execute mode, no overwrite + # ── Phase 1: Dry-run preview ── + try: selected = mod.resolve_selected_options(None, None, preset="full") + dry_migrator = mod.Migrator( + source_root=openclaw_dir.resolve(), + target_root=hermes_home.resolve(), + execute=False, # dry-run — no files modified + workspace_target=None, + overwrite=True, # show everything including conflicts + migrate_secrets=True, + output_dir=None, + selected_options=selected, + preset_name="full", + ) + preview_report = dry_migrator.migrate() + except Exception as e: + print_warning(f"Migration preview failed: {e}") + logger.debug("OpenClaw migration preview error", exc_info=True) + return False + + # Display the full preview + preview_summary = preview_report.get("summary", {}) + preview_count = preview_summary.get("migrated", 0) + + if preview_count == 0: + print() + print_info("Nothing to import from OpenClaw.") + return False + + print() + print_header(f"Migration Preview — {preview_count} item(s) would be imported") + print_info("No changes have been made yet. Review the list below:") + print() + _print_migration_preview(preview_report) + + # ── Phase 2: Confirm and execute ── + if not prompt_yes_no("Proceed with migration?", default=False): + print_info( + "Migration cancelled. You can run it later with: hermes claw migrate" + ) + print_info( + "Use --dry-run to preview again, or --preset minimal for a lighter import." + ) + return False + + # Execute the migration — overwrite=False so existing Hermes configs are + # preserved. The user saw the preview; conflicts are skipped by default. + try: migrator = mod.Migrator( source_root=openclaw_dir.resolve(), target_root=hermes_home.resolve(), execute=True, workspace_target=None, - overwrite=True, + overwrite=False, # preserve existing Hermes config migrate_secrets=True, output_dir=None, selected_options=selected, @@ -2640,7 +2788,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: logger.debug("OpenClaw migration error", exc_info=True) return False - # Print summary + # Print final summary summary = report.get("summary", {}) migrated = summary.get("migrated", 0) skipped = summary.get("skipped", 0) @@ -2651,7 +2799,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: if migrated: print_success(f"Imported {migrated} item(s) from OpenClaw.") if conflicts: - print_info(f"Skipped {conflicts} item(s) that already exist in Hermes.") + print_info(f"Skipped {conflicts} item(s) that already exist in Hermes (use hermes claw migrate --overwrite to force).") if skipped: print_info(f"Skipped {skipped} item(s) (not found or unchanged).") if errors: diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index b956f1fe..fe802639 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -44,7 +44,7 @@ class TestOfferOpenclawMigration: assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False def test_runs_migration_when_user_accepts(self, tmp_path): - """Should dynamically load the script and run the Migrator.""" + """Should run dry-run preview first, then execute after confirmation.""" openclaw_dir = tmp_path / ".openclaw" openclaw_dir.mkdir() @@ -60,6 +60,7 @@ class TestOfferOpenclawMigration: fake_migrator = MagicMock() fake_migrator.migrate.return_value = { "summary": {"migrated": 3, "skipped": 1, "conflict": 0, "error": 0}, + "items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}], "output_dir": str(hermes_home / "migration"), } fake_mod.Migrator = MagicMock(return_value=fake_migrator) @@ -70,6 +71,7 @@ class TestOfferOpenclawMigration: with ( patch("hermes_cli.setup.Path.home", return_value=tmp_path), patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + # Both prompts answered Yes: preview offer + proceed confirmation patch.object(setup_mod, "prompt_yes_no", return_value=True), patch.object(setup_mod, "get_config_path", return_value=config_path), patch("importlib.util.spec_from_file_location") as mock_spec_fn, @@ -91,13 +93,75 @@ class TestOfferOpenclawMigration: fake_mod.resolve_selected_options.assert_called_once_with( None, None, preset="full" ) - fake_mod.Migrator.assert_called_once() - call_kwargs = fake_mod.Migrator.call_args[1] - assert call_kwargs["execute"] is True - assert call_kwargs["overwrite"] is True - assert call_kwargs["migrate_secrets"] is True - assert call_kwargs["preset_name"] == "full" - fake_migrator.migrate.assert_called_once() + # Migrator called twice: once for dry-run preview, once for execution + assert fake_mod.Migrator.call_count == 2 + + # First call: dry-run preview (execute=False, overwrite=True to show all) + preview_kwargs = fake_mod.Migrator.call_args_list[0][1] + assert preview_kwargs["execute"] is False + assert preview_kwargs["overwrite"] is True + assert preview_kwargs["migrate_secrets"] is True + assert preview_kwargs["preset_name"] == "full" + + # Second call: actual execution (execute=True, overwrite=False to preserve) + exec_kwargs = fake_mod.Migrator.call_args_list[1][1] + assert exec_kwargs["execute"] is True + assert exec_kwargs["overwrite"] is False + assert exec_kwargs["migrate_secrets"] is True + assert exec_kwargs["preset_name"] == "full" + + # migrate() called twice (once per Migrator instance) + assert fake_migrator.migrate.call_count == 2 + + def test_user_declines_after_preview(self, tmp_path): + """Should return False when user sees preview but declines to proceed.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("agent:\n max_turns: 90\n") + + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"}) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0}, + "items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}], + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + + # First prompt (preview): Yes, Second prompt (proceed): No + prompt_responses = iter([True, False]) + + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + patch.object(setup_mod, "prompt_yes_no", side_effect=prompt_responses), + patch.object(setup_mod, "get_config_path", return_value=config_path), + patch("importlib.util.spec_from_file_location") as mock_spec_fn, + ): + mock_spec = MagicMock() + mock_spec.loader = MagicMock() + mock_spec_fn.return_value = mock_spec + + def exec_module(mod): + mod.resolve_selected_options = fake_mod.resolve_selected_options + mod.Migrator = fake_mod.Migrator + + mock_spec.loader.exec_module = exec_module + + result = setup_mod._offer_openclaw_migration(hermes_home) + + assert result is False + # Only dry-run Migrator was created, not the execute one + assert fake_mod.Migrator.call_count == 1 + preview_kwargs = fake_mod.Migrator.call_args[1] + assert preview_kwargs["execute"] is False def test_handles_migration_error_gracefully(self, tmp_path): """Should catch exceptions and return False.""" From c6dba918b3b9ee605114be13e6a579c0b638c900 Mon Sep 17 00:00:00 2001 From: Dylan Socolobsky Date: Thu, 9 Apr 2026 17:17:06 -0300 Subject: [PATCH 12/51] fix(tests): fix several failing/flaky tests on main (#6777) * fix(tests): mock is_safe_url in tests that use example.com Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests. These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern. * fix(test): use case-insensitive lookup for model context length check DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model. * fix(test): patch is_linux in systemd gateway restart test The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail. * fix(test): use non-blocklisted env var in docker forward_env tests GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work. * fix(test): fully isolate _has_any_provider_configured from host env _has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test. Clear all registry vars and mock get_auth_status so host credentials don't interfere. * fix(test): correct path to hermes_base_env.py in tool parser tests Path(__file__).parent.parent resolved to tests/, not the project root. The file lives at environments/hermes_base_env.py so we need one more parent level. * fix(test): accept optional HTML fields in Matrix send payload _send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead. * fix(test): add config.yaml to codex vision requirements test The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client. * fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail. * fix(test): add get_rate_limit_state to agent mock in usage report tests _show_usage now calls agent.get_rate_limit_state() for rate limit display. The SimpleNamespace mock was missing this method. * fix(test): update expected Camofox config version from 12 to 13 * fix(test): mock _get_enabled_platforms in nous managed defaults test Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults runs twice: the first call sets config values, the second sees them as already configured and returns an empty set, causing the assertion to fail. --- tests/cli/test_cli_status_bar.py | 1 + tests/conftest.py | 2 ++ tests/gateway/test_media_download_retry.py | 24 ++++++++++-------- tests/gateway/test_wecom.py | 5 ++-- tests/hermes_cli/test_api_key_providers.py | 25 +++++++++++++++---- tests/hermes_cli/test_tools_config.py | 8 ++++++ .../hermes_cli/test_update_gateway_restart.py | 3 +++ tests/tools/test_browser_camofox_state.py | 2 +- tests/tools/test_docker_environment.py | 18 +++++++------ .../tools/test_managed_server_tool_support.py | 4 +-- .../test_send_message_missing_platforms.py | 4 ++- tests/tools/test_vision_tools.py | 20 ++++++++++++--- tests/tools/test_web_tools_tavily.py | 2 ++ 13 files changed, 85 insertions(+), 33 deletions(-) diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index e728328b..a884c421 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -41,6 +41,7 @@ def _attach_agent( session_completion_tokens=completion_tokens, session_total_tokens=total_tokens, session_api_calls=api_calls, + get_rate_limit_state=lambda: None, context_compressor=SimpleNamespace( last_prompt_tokens=context_tokens, context_length=context_length, diff --git a/tests/conftest.py b/tests/conftest.py index 313a3cec..02114046 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,8 @@ def _isolate_hermes_home(tmp_path, monkeypatch): monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + # Avoid making real calls during tests if this key is set in the env files + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) @pytest.fixture() diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index 8f135a05..f0147dfb 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -38,10 +38,11 @@ def _make_timeout_error() -> httpx.TimeoutException: # cache_image_from_url (base.py) # --------------------------------------------------------------------------- +@patch("tools.url_safety.is_safe_url", return_value=True) class TestCacheImageFromUrl: """Tests for gateway.platforms.base.cache_image_from_url""" - def test_success_on_first_attempt(self, tmp_path, monkeypatch): + def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch): """A clean 200 response caches the image and returns a path.""" monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img") @@ -65,7 +66,7 @@ class TestCacheImageFromUrl: assert path.endswith(".jpg") mock_client.get.assert_called_once() - def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch): + def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch): """A timeout on the first attempt is retried; second attempt succeeds.""" monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img") @@ -95,7 +96,7 @@ class TestCacheImageFromUrl: assert mock_client.get.call_count == 2 mock_sleep.assert_called_once() - def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch): + def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch): """A 429 response on the first attempt is retried; second attempt succeeds.""" monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img") @@ -122,7 +123,7 @@ class TestCacheImageFromUrl: assert path.endswith(".jpg") assert mock_client.get.call_count == 2 - def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch): + def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch): """Timeout on every attempt raises after all retries are consumed.""" monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img") @@ -145,7 +146,7 @@ class TestCacheImageFromUrl: # 3 total calls: initial + 2 retries assert mock_client.get.call_count == 3 - def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch): + def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch): """A 404 (non-retryable) is raised immediately without any retry.""" monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img") @@ -175,10 +176,11 @@ class TestCacheImageFromUrl: # cache_audio_from_url (base.py) # --------------------------------------------------------------------------- +@patch("tools.url_safety.is_safe_url", return_value=True) class TestCacheAudioFromUrl: """Tests for gateway.platforms.base.cache_audio_from_url""" - def test_success_on_first_attempt(self, tmp_path, monkeypatch): + def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch): """A clean 200 response caches the audio and returns a path.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") @@ -202,7 +204,7 @@ class TestCacheAudioFromUrl: assert path.endswith(".ogg") mock_client.get.assert_called_once() - def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch): + def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch): """A timeout on the first attempt is retried; second attempt succeeds.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") @@ -232,7 +234,7 @@ class TestCacheAudioFromUrl: assert mock_client.get.call_count == 2 mock_sleep.assert_called_once() - def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch): + def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch): """A 429 response on the first attempt is retried; second attempt succeeds.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") @@ -259,7 +261,7 @@ class TestCacheAudioFromUrl: assert path.endswith(".ogg") assert mock_client.get.call_count == 2 - def test_retries_on_500_then_succeeds(self, tmp_path, monkeypatch): + def test_retries_on_500_then_succeeds(self, _mock_safe, tmp_path, monkeypatch): """A 500 response on the first attempt is retried; second attempt succeeds.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") @@ -286,7 +288,7 @@ class TestCacheAudioFromUrl: assert path.endswith(".ogg") assert mock_client.get.call_count == 2 - def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch): + def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch): """Timeout on every attempt raises after all retries are consumed.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") @@ -309,7 +311,7 @@ class TestCacheAudioFromUrl: # 3 total calls: initial + 2 retries assert mock_client.get.call_count == 3 - def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch): + def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch): """A 404 (non-retryable) is raised immediately without any retry.""" monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio") diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py index a7101c69..418a4b62 100644 --- a/tests/gateway/test_wecom.py +++ b/tests/gateway/test_wecom.py @@ -4,7 +4,7 @@ import base64 import os from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -355,7 +355,8 @@ class TestMediaUpload: assert calls[3][1]["chunk_index"] == 2 @pytest.mark.asyncio - async def test_download_remote_bytes_rejects_large_content_length(self): + @patch("tools.url_safety.is_safe_url", return_value=True) + async def test_download_remote_bytes_rejects_large_content_length(self, _mock_safe): from gateway.platforms.wecom import WeComAdapter class FakeResponse: diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index ee86507a..d97b0c1f 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -628,14 +628,21 @@ class TestHasAnyProviderConfigured: def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path): """Claude Code credentials should NOT skip the wizard when Hermes is unconfigured.""" from hermes_cli import config as config_module + from hermes_cli.auth import PROVIDER_REGISTRY hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) # Clear all provider env vars so earlier checks don't short-circuit - for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + _all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} + for pconfig in PROVIDER_REGISTRY.values(): + if pconfig.auth_type == "api_key": + _all_vars.update(pconfig.api_key_env_vars) + for var in _all_vars: monkeypatch.delenv(var, raising=False) + # Prevent gh-cli / copilot auth fallback from leaking in + monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {}) # Simulate valid Claude Code credentials monkeypatch.setattr( "agent.anthropic_adapter.read_claude_code_credentials", @@ -710,6 +717,7 @@ class TestHasAnyProviderConfigured: """config.yaml model dict with empty default and no creds stays false.""" import yaml from hermes_cli import config as config_module + from hermes_cli.auth import PROVIDER_REGISTRY hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_file = hermes_home / "config.yaml" @@ -719,9 +727,15 @@ class TestHasAnyProviderConfigured: monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + _all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} + for pconfig in PROVIDER_REGISTRY.values(): + if pconfig.auth_type == "api_key": + _all_vars.update(pconfig.api_key_env_vars) + for var in _all_vars: monkeypatch.delenv(var, raising=False) + # Prevent gh-cli / copilot auth fallback from leaking in + monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {}) from hermes_cli.main import _has_any_provider_configured assert _has_any_provider_configured() is False @@ -941,9 +955,10 @@ class TestHuggingFaceModels: """Every HF model should have a context length entry.""" from hermes_cli.models import _PROVIDER_MODELS from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS + lower_keys = {k.lower() for k in DEFAULT_CONTEXT_LENGTHS} hf_models = _PROVIDER_MODELS["huggingface"] for model in hf_models: - assert model in DEFAULT_CONTEXT_LENGTHS, ( + assert model.lower() in lower_keys, ( f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS" ) diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 7371c89d..830bad8d 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -354,6 +354,14 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"}, ) monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None) + # Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run + # import) from adding extra platforms. The loop in tools_command runs + # apply_nous_managed_defaults per platform; a second iteration sees values + # set by the first as "explicit" and skips them. + monkeypatch.setattr( + "hermes_cli.tools_config._get_enabled_platforms", + lambda: ["cli"], + ) monkeypatch.setattr( "hermes_cli.nous_subscription.get_nous_auth_status", lambda: {"logged_in": True}, diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 9366c06c..e4c8e922 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -368,6 +368,9 @@ class TestCmdUpdateLaunchdRestart: monkeypatch.setattr( gateway_cli, "is_macos", lambda: False, ) + monkeypatch.setattr( + gateway_cli, "is_linux", lambda: True, + ) mock_run.side_effect = _make_run_side_effect( commit_count="3", diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 7fe4c3d4..b1f128cc 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults: from hermes_cli.config import DEFAULT_CONFIG # managed_persistence is auto-merged by _deep_merge, no version bump needed - assert DEFAULT_CONFIG["_config_version"] == 12 + assert DEFAULT_CONFIG["_config_version"] == 13 diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 498ef9d5..e19229a7 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -258,28 +258,30 @@ def _make_execute_only_env(forward_env=None): def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch): """_build_init_env_args picks up forwarded env vars from .env file at init time.""" - env = _make_execute_only_env(["GITHUB_TOKEN"]) + # Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN + # is in the copilot provider's api_key_env_vars and gets stripped). + env = _make_execute_only_env(["DATABASE_URL"]) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"}) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"}) args = env._build_init_env_args() args_str = " ".join(args) - assert "GITHUB_TOKEN=value_from_dotenv" in args_str + assert "DATABASE_URL=value_from_dotenv" in args_str def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch): """Shell env vars take priority over .env file values in init env args.""" - env = _make_execute_only_env(["GITHUB_TOKEN"]) + env = _make_execute_only_env(["DATABASE_URL"]) - monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell") - monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"}) + monkeypatch.setenv("DATABASE_URL", "value_from_shell") + monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"}) args = env._build_init_env_args() args_str = " ".join(args) - assert "GITHUB_TOKEN=value_from_shell" in args_str + assert "DATABASE_URL=value_from_shell" in args_str assert "value_from_dotenv" not in args_str diff --git a/tests/tools/test_managed_server_tool_support.py b/tests/tools/test_managed_server_tool_support.py index 92cf83f5..5b917f3d 100644 --- a/tests/tools/test_managed_server_tool_support.py +++ b/tests/tools/test_managed_server_tool_support.py @@ -147,7 +147,7 @@ class TestBaseEnvCompatibility: """Hermes wires parser selection through ServerManager.tool_parser.""" import ast - base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py" + base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py" source = base_env_path.read_text() tree = ast.parse(source) @@ -171,7 +171,7 @@ class TestBaseEnvCompatibility: def test_hermes_base_env_uses_config_tool_call_parser(self): """Verify hermes_base_env uses the config field rather than a local parser instance.""" - base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py" + base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py" source = base_env_path.read_text() assert 'tool_call_parser: str = Field(' in source diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index 881ae33d..a6741e16 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -125,7 +125,9 @@ class TestSendMatrix: url = call_kwargs[0][0] assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/") assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok" - assert call_kwargs[1]["json"] == {"msgtype": "m.text", "body": "hello matrix"} + payload = call_kwargs[1]["json"] + assert payload["msgtype"] == "m.text" + assert payload["body"] == "hello matrix" def test_http_error(self): resp = _make_aiohttp_resp(403, text_data="Forbidden") diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 97ee57a1..6612f0e8 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -30,7 +30,10 @@ class TestValidateImageUrl: """Tests for URL validation, including urlparse-based netloc check.""" def test_valid_https_url(self): - assert _validate_image_url("https://example.com/image.jpg") is True + with patch("tools.url_safety.socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("93.184.216.34", 0)), + ]): + assert _validate_image_url("https://example.com/image.jpg") is True def test_valid_http_url(self): with patch("tools.url_safety.socket.getaddrinfo", return_value=[ @@ -56,10 +59,16 @@ class TestValidateImageUrl: assert _validate_image_url("http://localhost:8080/image.png") is False def test_valid_url_with_port(self): - assert _validate_image_url("http://example.com:8080/image.png") is True + with patch("tools.url_safety.socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("93.184.216.34", 0)), + ]): + assert _validate_image_url("http://example.com:8080/image.png") is True def test_valid_url_with_path_only(self): - assert _validate_image_url("https://example.com/") is True + with patch("tools.url_safety.socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("93.184.216.34", 0)), + ]): + assert _validate_image_url("https://example.com/") is True def test_rejects_empty_string(self): assert _validate_image_url("") is False @@ -441,6 +450,11 @@ class TestVisionRequirements: (tmp_path / "auth.json").write_text( '{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}' ) + # config.yaml must reference the codex provider so vision auto-detect + # falls back to the active provider via _read_main_provider(). + (tmp_path / "config.yaml").write_text( + 'model:\n default: gpt-4o\n provider: openai-codex\n' + ) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) diff --git a/tests/tools/test_web_tools_tavily.py b/tests/tools/test_web_tools_tavily.py index 2e49b72f..aef39e8e 100644 --- a/tests/tools/test_web_tools_tavily.py +++ b/tests/tools/test_web_tools_tavily.py @@ -225,6 +225,7 @@ class TestWebCrawlTavily: patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \ patch("tools.web_tools.httpx.post", return_value=mock_response), \ patch("tools.web_tools.check_website_access", return_value=None), \ + patch("tools.web_tools.is_safe_url", return_value=True), \ patch("tools.interrupt.is_interrupted", return_value=False): from tools.web_tools import web_crawl_tool result = json.loads(asyncio.get_event_loop().run_until_complete( @@ -244,6 +245,7 @@ class TestWebCrawlTavily: patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \ patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \ patch("tools.web_tools.check_website_access", return_value=None), \ + patch("tools.web_tools.is_safe_url", return_value=True), \ patch("tools.interrupt.is_interrupted", return_value=False): from tools.web_tools import web_crawl_tool asyncio.get_event_loop().run_until_complete( From c6974fd10851a6c8dd9aba1df5fd700d673c3861 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 13:23:43 -0700 Subject: [PATCH 13/51] fix: allow custom endpoint users to use main model for auxiliary tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of _resolve_auto() explicitly excluded 'custom' providers, forcing custom endpoint users through the fragile fallback chain instead of using their known-working main model credentials. This caused silent compression failures for users on local OpenAI- compatible endpoints — the summary generation would fail, middle turns would be silently dropped, and the agent would lose all conversation context. Remove 'custom' from the exclusion list so custom endpoint users get the same main-model-first treatment as DeepSeek, Anthropic, Gemini, and other direct providers. --- agent/auxiliary_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2f3a64a6..a757f426 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1137,7 +1137,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: main_model = _read_main_model() if (main_provider and main_model and main_provider not in _AGGREGATOR_PROVIDERS - and main_provider not in ("auto", "custom", "")): + and main_provider not in ("auto", "")): client, resolved = resolve_provider_client(main_provider, main_model) if client is not None: logger.info("Auxiliary auto-detect: using main provider %s (%s)", From ab7b40722451c85424ab7c4ac009bff5bd9808a5 Mon Sep 17 00:00:00 2001 From: aaronagent <1115117931@qq.com> Date: Fri, 10 Apr 2026 00:20:35 +0800 Subject: [PATCH 14/51] fix: atomic Slack approval guard, safe JSON deserialization fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. gateway/platforms/slack.py: Replace check-then-set TOCTOU race on _approval_resolved with atomic dict.pop(). Two concurrent button clicks could both pass the guard before either set it to True, causing double resolve_gateway_approval — which can resolve the WRONG queued approval when multiple are pending for the same session. 2. hermes_state.py: Add WARNING log and proper fallbacks when json.loads fails on tool_calls (→ []), reasoning_details (→ None), and codex_reasoning_items (→ None). Previously, failures were silently swallowed: tool_calls stayed as a raw string (iterating yields characters, not objects), and reasoning fields were simply missing from the dict. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- gateway/platforms/slack.py | 8 +++----- hermes_state.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 26184b7e..afd1a8aa 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1239,10 +1239,9 @@ class SlackAdapter(BasePlatformAdapter): } choice = choice_map.get(action_id, "deny") - # Prevent double-clicks - if self._approval_resolved.get(msg_ts, False): + # Prevent double-clicks — atomic pop; first caller gets False, others get True (default) + if self._approval_resolved.pop(msg_ts, True): return - self._approval_resolved[msg_ts] = True # Update the message to show the decision and remove buttons label_map = { @@ -1297,8 +1296,7 @@ class SlackAdapter(BasePlatformAdapter): except Exception as exc: logger.error("Failed to resolve gateway approval from Slack button: %s", exc) - # Clean up stale approval state - self._approval_resolved.pop(msg_ts, None) + # (approval state already consumed by atomic pop above) # ----- Thread context fetching ----- diff --git a/hermes_state.py b/hermes_state.py index a845dbb9..c6825a3e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -944,7 +944,8 @@ class SessionDB: try: msg["tool_calls"] = json.loads(msg["tool_calls"]) except (json.JSONDecodeError, TypeError): - pass + logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []") + msg["tool_calls"] = [] result.append(msg) return result @@ -972,7 +973,8 @@ class SessionDB: try: msg["tool_calls"] = json.loads(row["tool_calls"]) except (json.JSONDecodeError, TypeError): - pass + logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []") + msg["tool_calls"] = [] # Restore reasoning fields on assistant messages so providers # that replay reasoning (OpenRouter, OpenAI, Nous) receive # coherent multi-turn reasoning context. @@ -983,12 +985,14 @@ class SessionDB: try: msg["reasoning_details"] = json.loads(row["reasoning_details"]) except (json.JSONDecodeError, TypeError): - pass + logger.warning("Failed to deserialize reasoning_details, falling back to None") + msg["reasoning_details"] = None if row["codex_reasoning_items"]: try: msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"]) except (json.JSONDecodeError, TypeError): - pass + logger.warning("Failed to deserialize codex_reasoning_items, falling back to None") + msg["codex_reasoning_items"] = None messages.append(msg) return messages From 997e219c14968ff7a63e78b2373d878773461997 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 13:26:37 -0700 Subject: [PATCH 15/51] fix(security): enforce user authorization on approval button clicks Approval button clicks (Block Kit actions in Slack, CallbackQuery in Telegram) bypass the normal message authorization flow in gateway/run.py. Any workspace/group member who can see the approval message could click Approve to authorize dangerous commands. Read SLACK_ALLOWED_USERS / TELEGRAM_ALLOWED_USERS env vars directly in the approval handlers. When an allowlist is configured and the clicking user is not in it, the click is silently ignored (Slack) or answered with an error (Telegram). Wildcard '*' permits all users. When no allowlist is configured, behavior is unchanged (open access). Based on the idea from PR #6735 by maymuneth, reimplemented to use the existing env-var-based authorization system rather than a nonexistent _allowed_user_ids adapter attribute. --- gateway/platforms/slack.py | 14 ++++++++++++++ gateway/platforms/telegram.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index afd1a8aa..49890170 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1229,6 +1229,20 @@ class SlackAdapter(BasePlatformAdapter): msg_ts = message.get("ts", "") channel_id = body.get("channel", {}).get("id", "") user_name = body.get("user", {}).get("name", "unknown") + user_id = body.get("user", {}).get("id", "") + + # Only authorized users may click approval buttons. Button clicks + # bypass the normal message auth flow in gateway/run.py, so we must + # check here as well. + allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip() + if allowed_csv: + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + if "*" not in allowed_ids and user_id not in allowed_ids: + logger.warning( + "[Slack] Unauthorized approval click by %s (%s) — ignoring", + user_name, user_id, + ) + return # Map action_id to approval choice choice_map = { diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 85b8afc9..e127841b 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1398,6 +1398,15 @@ class TelegramAdapter(BasePlatformAdapter): await query.answer(text="Invalid approval data.") return + # Only authorized users may click approval buttons. + caller_id = str(getattr(query.from_user, "id", "")) + allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + if allowed_csv: + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + if "*" not in allowed_ids and caller_id not in allowed_ids: + await query.answer(text="⛔ You are not authorized to approve commands.") + return + session_key = self._approval_state.pop(approval_id, None) if not session_key: await query.answer(text="This approval has already been resolved.") From 7d499c75db948a76b3c54eca1781a6e0ff1c5735 Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Thu, 9 Apr 2026 13:31:36 -0700 Subject: [PATCH 16/51] feat(slack): add require_mention and free_response_channels config support Port the mention gating pattern from Telegram, Discord, WhatsApp, and Matrix adapters to the Slack platform adapter. - Add _slack_require_mention() with explicit-false parsing and env var fallback (SLACK_REQUIRE_MENTION) - Add _slack_free_response_channels() with env var fallback (SLACK_FREE_RESPONSE_CHANNELS) - Replace hardcoded mention check with configurable gating logic - Bridge slack config.yaml settings to env vars - Bridge free_response_channels through the generic platform bridging loop - Add 26 tests covering config parsing, env fallback, gating logic Config usage: slack: require_mention: false free_response_channels: - "C0AQWDLHY9M" Default behavior unchanged: channels require @mention (backward compatible). Based on PR #5885 by dorukardahan, cherry-picked and adapted to current main. --- gateway/config.py | 13 ++ gateway/platforms/slack.py | 68 ++++-- tests/gateway/test_slack_mention.py | 312 ++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+), 17 deletions(-) create mode 100644 tests/gateway/test_slack_mention.py diff --git a/gateway/config.py b/gateway/config.py index 96ee8317..47b779eb 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -532,6 +532,8 @@ def load_gateway_config() -> GatewayConfig: bridged["reply_prefix"] = platform_cfg["reply_prefix"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] + if "free_response_channels" in platform_cfg: + bridged["free_response_channels"] = platform_cfg["free_response_channels"] if "mention_patterns" in platform_cfg: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if not bridged: @@ -546,6 +548,17 @@ def load_gateway_config() -> GatewayConfig: plat_data["extra"] = extra extra.update(bridged) + # Slack settings → env vars (env vars take precedence) + slack_cfg = yaml_cfg.get("slack", {}) + if isinstance(slack_cfg, dict): + if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"): + os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() + frc = slack_cfg.get("free_response_channels") + if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) + # Discord settings → env vars (env vars take precedence) discord_cfg = yaml_cfg.get("discord", {}) if isinstance(discord_cfg, dict): diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 49890170..6a5471d5 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -961,6 +961,8 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = event.get("thread_ts") or ts # ts fallback for channels # In channels, respond if: + # 0. Channel is in free_response_channels, OR require_mention is + # disabled — always process regardless of mention. # 1. The bot is @mentioned in this message, OR # 2. The message is a reply in a thread the bot started/participated in, OR # 3. The message is in a thread where the bot was previously @mentioned, OR @@ -970,24 +972,29 @@ class SlackAdapter(BasePlatformAdapter): event_thread_ts = event.get("thread_ts") is_thread_reply = bool(event_thread_ts and event_thread_ts != ts) - if not is_dm and bot_uid and not is_mentioned: - reply_to_bot_thread = ( - is_thread_reply and event_thread_ts in self._bot_message_ts - ) - in_mentioned_thread = ( - event_thread_ts is not None - and event_thread_ts in self._mentioned_threads - ) - has_session = ( - is_thread_reply - and self._has_active_session_for_thread( - channel_id=channel_id, - thread_ts=event_thread_ts, - user_id=user_id, + if not is_dm and bot_uid: + if channel_id in self._slack_free_response_channels(): + pass # Free-response channel — always process + elif not self._slack_require_mention(): + pass # Mention requirement disabled globally for Slack + elif not is_mentioned: + reply_to_bot_thread = ( + is_thread_reply and event_thread_ts in self._bot_message_ts ) - ) - if not reply_to_bot_thread and not in_mentioned_thread and not has_session: - return + in_mentioned_thread = ( + event_thread_ts is not None + and event_thread_ts in self._mentioned_threads + ) + has_session = ( + is_thread_reply + and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ) + ) + if not reply_to_bot_thread and not in_mentioned_thread and not has_session: + return if is_mentioned: # Strip the bot mention from the text @@ -1527,3 +1534,30 @@ class SlackAdapter(BasePlatformAdapter): continue raise raise last_exc + + # ── Channel mention gating ───────────────────────────────────────────── + + def _slack_require_mention(self) -> bool: + """Return whether channel messages require an explicit bot mention. + + Uses explicit-false parsing (like Discord/Matrix) rather than + truthy parsing, since the safe default is True (gating on). + Unrecognised or empty values keep gating enabled. + """ + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + + def _slack_free_response_channels(self) -> set: + """Return channel IDs where no @mention is required.""" + raw = self.config.extra.get("free_response_channels") + if raw is None: + raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + if isinstance(raw, str) and raw.strip(): + return {part.strip() for part in raw.split(",") if part.strip()} + return set() diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py new file mode 100644 index 00000000..22e17443 --- /dev/null +++ b/tests/gateway/test_slack_mention.py @@ -0,0 +1,312 @@ +""" +Tests for Slack mention gating (require_mention / free_response_channels). + +Follows the same pattern as test_whatsapp_group_gating.py. +""" + +import sys +from unittest.mock import MagicMock + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Mock slack-bolt if not installed (same as test_slack.py) +# --------------------------------------------------------------------------- + +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + sys.modules.setdefault(name, mod) + + +_ensure_slack_mock() + +import gateway.platforms.slack as _slack_mod +_slack_mod.SLACK_AVAILABLE = True + +from gateway.platforms.slack import SlackAdapter # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +BOT_USER_ID = "U_BOT_123" +CHANNEL_ID = "C0AQWDLHY9M" +OTHER_CHANNEL_ID = "C9999999999" + + +def _make_adapter(require_mention=None, free_response_channels=None): + extra = {} + if require_mention is not None: + extra["require_mention"] = require_mention + if free_response_channels is not None: + extra["free_response_channels"] = free_response_channels + + adapter = object.__new__(SlackAdapter) + adapter.platform = Platform.SLACK + adapter.config = PlatformConfig(enabled=True, extra=extra) + adapter._bot_user_id = BOT_USER_ID + adapter._team_bot_user_ids = {} + return adapter + + +# --------------------------------------------------------------------------- +# Tests: _slack_require_mention +# --------------------------------------------------------------------------- + +def test_require_mention_defaults_to_true(monkeypatch): + monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False) + adapter = _make_adapter() + assert adapter._slack_require_mention() is True + + +def test_require_mention_false(): + adapter = _make_adapter(require_mention=False) + assert adapter._slack_require_mention() is False + + +def test_require_mention_true(): + adapter = _make_adapter(require_mention=True) + assert adapter._slack_require_mention() is True + + +def test_require_mention_string_true(): + adapter = _make_adapter(require_mention="true") + assert adapter._slack_require_mention() is True + + +def test_require_mention_string_false(): + adapter = _make_adapter(require_mention="false") + assert adapter._slack_require_mention() is False + + +def test_require_mention_string_no(): + adapter = _make_adapter(require_mention="no") + assert adapter._slack_require_mention() is False + + +def test_require_mention_string_yes(): + adapter = _make_adapter(require_mention="yes") + assert adapter._slack_require_mention() is True + + +def test_require_mention_empty_string_stays_true(): + """Empty/malformed strings keep gating ON (explicit-false parser).""" + adapter = _make_adapter(require_mention="") + assert adapter._slack_require_mention() is True + + +def test_require_mention_malformed_string_stays_true(): + """Unrecognised values keep gating ON (fail-closed).""" + adapter = _make_adapter(require_mention="maybe") + assert adapter._slack_require_mention() is True + + +def test_require_mention_env_var_fallback(monkeypatch): + monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false") + adapter = _make_adapter() # no config value -> falls back to env + assert adapter._slack_require_mention() is False + + +def test_require_mention_env_var_default_true(monkeypatch): + monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False) + adapter = _make_adapter() + assert adapter._slack_require_mention() is True + + +# --------------------------------------------------------------------------- +# Tests: _slack_free_response_channels +# --------------------------------------------------------------------------- + +def test_free_response_channels_default_empty(monkeypatch): + monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False) + adapter = _make_adapter() + assert adapter._slack_free_response_channels() == set() + + +def test_free_response_channels_list(): + adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID]) + result = adapter._slack_free_response_channels() + assert CHANNEL_ID in result + assert OTHER_CHANNEL_ID in result + + +def test_free_response_channels_csv_string(): + adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}") + result = adapter._slack_free_response_channels() + assert CHANNEL_ID in result + assert OTHER_CHANNEL_ID in result + + +def test_free_response_channels_empty_string(): + adapter = _make_adapter(free_response_channels="") + assert adapter._slack_free_response_channels() == set() + + +def test_free_response_channels_env_var_fallback(monkeypatch): + monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}") + adapter = _make_adapter() # no config value → falls back to env + result = adapter._slack_free_response_channels() + assert CHANNEL_ID in result + assert OTHER_CHANNEL_ID in result + + +# --------------------------------------------------------------------------- +# Tests: mention gating integration (simulating _handle_slack_message logic) +# --------------------------------------------------------------------------- + +def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID, + text="hello", mentioned=False, thread_reply=False, + active_session=False): + """Simulate the mention gating logic from _handle_slack_message. + + Returns True if the message would be processed, False if it would be + skipped (returned early). + """ + bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id) + if mentioned: + text = f"<@{bot_uid}> {text}" + is_mentioned = bot_uid and f"<@{bot_uid}>" in text + + if not is_dm: + if channel_id in adapter._slack_free_response_channels(): + return True + elif not adapter._slack_require_mention(): + return True + elif not is_mentioned: + if thread_reply and active_session: + return True + else: + return False + return True + + +def test_default_require_mention_channel_without_mention_ignored(): + adapter = _make_adapter() # default: require_mention=True + assert _would_process(adapter, text="hello everyone") is False + + +def test_require_mention_false_channel_without_mention_processed(): + adapter = _make_adapter(require_mention=False) + assert _would_process(adapter, text="hello everyone") is True + + +def test_channel_in_free_response_processed_without_mention(): + adapter = _make_adapter( + require_mention=True, + free_response_channels=[CHANNEL_ID], + ) + assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True + + +def test_other_channel_not_in_free_response_still_gated(): + adapter = _make_adapter( + require_mention=True, + free_response_channels=[CHANNEL_ID], + ) + assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False + + +def test_dm_always_processed_regardless_of_setting(): + adapter = _make_adapter(require_mention=True) + assert _would_process(adapter, is_dm=True, text="hello") is True + + +def test_mentioned_message_always_processed(): + adapter = _make_adapter(require_mention=True) + assert _would_process(adapter, mentioned=True, text="what's up") is True + + +def test_thread_reply_with_active_session_processed(): + adapter = _make_adapter(require_mention=True) + assert _would_process( + adapter, text="followup", + thread_reply=True, active_session=True, + ) is True + + +def test_thread_reply_without_active_session_ignored(): + adapter = _make_adapter(require_mention=True) + assert _would_process( + adapter, text="followup", + thread_reply=True, active_session=False, + ) is False + + +def test_bot_uid_none_processes_channel_message(): + """When bot_uid is None (before auth_test), channel messages pass through. + + This preserves the old behavior: the gating block is skipped entirely + when bot_uid is falsy, so messages are not silently dropped during + startup or for new workspaces. + """ + adapter = _make_adapter(require_mention=True) + adapter._bot_user_id = None + adapter._team_bot_user_ids = {} + + # With bot_uid=None, the `if not is_dm and bot_uid:` condition is False, + # so the gating block is skipped — message passes through. + bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id) + assert bot_uid is None + + # Simulate: gating block not entered when bot_uid is falsy + is_dm = False + if not is_dm and bot_uid: + result = False # would enter gating + else: + result = True # gating skipped, message processed + assert result is True + + +# --------------------------------------------------------------------------- +# Tests: config bridging +# --------------------------------------------------------------------------- + +def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path): + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "slack:\n" + " require_mention: false\n" + " free_response_channels:\n" + " - C0AQWDLHY9M\n" + " - C9999999999\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False) + + config = load_gateway_config() + + assert config is not None + slack_extra = config.platforms[Platform.SLACK].extra + assert slack_extra.get("require_mention") is False + assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"] + # Verify env vars were set by config bridging + import os as _os + assert _os.environ["SLACK_REQUIRE_MENTION"] == "false" + assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999" From 7f7b02b7640f9f7756bad297922ccc15145f729e Mon Sep 17 00:00:00 2001 From: dashed Date: Thu, 9 Apr 2026 13:33:05 -0700 Subject: [PATCH 17/51] =?UTF-8?q?fix(slack):=20comprehensive=20mrkdwn=20fo?= =?UTF-8?q?rmatting=20=E2=80=94=206=20bug=20fixes=20+=2052=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes blockquote > escaping, edit_message raw markdown, ***bold italic*** handling, HTML entity double-escaping (&amp;), Wikipedia URL parens truncation, and step numbering format. Also adds format_message to the tool-layer _send_to_platform for consistent formatting across all delivery paths. Changes: - Protect Slack entities (<@user>, , ) from escaping passes - Protect blockquote > markers before HTML entity escaping - Unescape-before-escape for idempotent HTML entity handling - ***bold italic*** → *_text_* conversion (before **bold** pass) - URL regex upgraded to handle balanced parentheses - mrkdwn:True flag on chat_postMessage payloads - format_message applied in edit_message and send_message_tool - 52 new tests (format, edit, streaming, splitting, tool chunking) - Use reversed(dict) idiom for placeholder restoration Based on PR #3715 by dashed, cherry-picked onto current main. --- gateway/platforms/slack.py | 55 +++- tests/gateway/test_slack.py | 373 ++++++++++++++++++++++++++ tests/tools/test_send_message_tool.py | 135 +++++++++- tools/send_message_tool.py | 10 +- 4 files changed, 556 insertions(+), 17 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 6a5471d5..d2f50907 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -281,6 +281,7 @@ class SlackAdapter(BasePlatformAdapter): kwargs = { "channel": chat_id, "text": chunk, + "mrkdwn": True, } if thread_ts: kwargs["thread_ts"] = thread_ts @@ -323,9 +324,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return SendResult(success=False, error="Not connected") try: - # Convert standard markdown → Slack mrkdwn formatted = self.format_message(content) - await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, @@ -457,13 +456,36 @@ class SlackAdapter(BasePlatformAdapter): text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) # 3) Convert markdown links [text](url) → + def _convert_markdown_link(m): + label = m.group(1) + url = m.group(2).strip() + if url.startswith('<') and url.endswith('>'): + url = url[1:-1].strip() + return _ph(f'<{url}|{label}>') + text = re.sub( - r'\[([^\]]+)\]\(([^)]+)\)', - lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'), + r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', + _convert_markdown_link, text, ) - # 4) Convert headers (## Title) → *Title* (bold) + # 4) Protect existing Slack entities/manual links so escaping and later + # formatting passes don't break them. + text = re.sub( + r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)', + lambda m: _ph(m.group(1)), + text, + ) + + # 5) Protect blockquote markers before escaping + text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) + + # 6) Escape Slack control characters in remaining plain text. + # Unescape first so already-escaped input doesn't get double-escaped. + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + + # 7) Convert headers (## Title) → *Title* (bold) def _convert_header(m): inner = m.group(1).strip() # Strip redundant bold markers inside a header @@ -474,34 +496,39 @@ class SlackAdapter(BasePlatformAdapter): r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE ) - # 5) Convert bold: **text** → *text* (Slack bold) + # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic) + text = re.sub( + r'\*\*\*(.+?)\*\*\*', + lambda m: _ph(f'*_{m.group(1)}_*'), + text, + ) + + # 9) Convert bold: **text** → *text* (Slack bold) text = re.sub( r'\*\*(.+?)\*\*', lambda m: _ph(f'*{m.group(1)}*'), text, ) - # 6) Convert italic: _text_ stays as _text_ (already Slack italic) - # Single *text* → _text_ (Slack italic) + # 10) Convert italic: _text_ stays as _text_ (already Slack italic) + # Single *text* → _text_ (Slack italic) text = re.sub( r'(? text → > text (same syntax, just ensure - # no extra escaping happens to the > character) - # Slack uses the same > prefix, so this is a no-op for content. + # 12) Blockquotes: > prefix is already protected by step 5 above. - # 9) Restore placeholders in reverse order - for key in reversed(list(placeholders.keys())): + # 13) Restore placeholders in reverse order + for key in reversed(placeholders): text = text.replace(key, placeholders[key]) return text diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 67c7cce1..983a7e99 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -619,6 +619,18 @@ class TestFormatMessage: result = adapter.format_message("[click here](https://example.com)") assert result == "" + def test_link_conversion_strips_markdown_angle_brackets(self, adapter): + result = adapter.format_message("[click here]()") + assert result == "" + + def test_escapes_control_characters(self, adapter): + result = adapter.format_message("AT&T < 5 > 3") + assert result == "AT&T < 5 > 3" + + def test_preserves_existing_slack_entities(self, adapter): + text = "Hey <@U123>, see and " + assert adapter.format_message(text) == text + def test_strikethrough(self, adapter): assert adapter.format_message("~~deleted~~") == "~deleted~" @@ -643,6 +655,325 @@ class TestFormatMessage: def test_none_passthrough(self, adapter): assert adapter.format_message(None) is None + def test_blockquote_preserved(self, adapter): + """Single-line blockquote > marker is preserved.""" + assert adapter.format_message("> quoted text") == "> quoted text" + + def test_multiline_blockquote(self, adapter): + """Multi-line blockquote preserves > on each line.""" + text = "> line one\n> line two" + assert adapter.format_message(text) == "> line one\n> line two" + + def test_blockquote_with_formatting(self, adapter): + """Blockquote containing bold text.""" + assert adapter.format_message("> **bold quote**") == "> *bold quote*" + + def test_nested_blockquote(self, adapter): + """Multiple > characters for nested quotes.""" + assert adapter.format_message(">> deeply quoted") == ">> deeply quoted" + + def test_blockquote_mixed_with_plain(self, adapter): + """Blockquote lines interleaved with plain text.""" + text = "normal\n> quoted\nnormal again" + result = adapter.format_message(text) + assert "> quoted" in result + assert "normal" in result + + def test_non_prefix_gt_still_escaped(self, adapter): + """Greater-than in mid-line is still escaped.""" + assert adapter.format_message("5 > 3") == "5 > 3" + + def test_blockquote_with_code(self, adapter): + """Blockquote containing inline code.""" + result = adapter.format_message("> use `fmt.Println`") + assert result.startswith(">") + assert "`fmt.Println`" in result + + def test_bold_italic_combined(self, adapter): + """Triple-star ***text*** converts to Slack bold+italic *_text_*.""" + assert adapter.format_message("***hello***") == "*_hello_*" + + def test_bold_italic_with_surrounding_text(self, adapter): + """Bold+italic in a sentence.""" + result = adapter.format_message("This is ***important*** stuff") + assert "*_important_*" in result + + def test_bold_italic_does_not_break_plain_bold(self, adapter): + """**bold** still works after adding ***bold italic*** support.""" + assert adapter.format_message("**bold**") == "*bold*" + + def test_bold_italic_does_not_break_plain_italic(self, adapter): + """*italic* still works after adding ***bold italic*** support.""" + assert adapter.format_message("*italic*") == "_italic_" + + def test_bold_italic_mixed_with_bold(self, adapter): + """Both ***bold italic*** and **bold** in the same message.""" + result = adapter.format_message("***important*** and **bold**") + assert "*_important_*" in result + assert "*bold*" in result + + def test_pre_escaped_ampersand_not_double_escaped(self, adapter): + """Already-escaped & must not become &amp;.""" + assert adapter.format_message("&") == "&" + + def test_pre_escaped_lt_not_double_escaped(self, adapter): + """Already-escaped < must not become &lt;.""" + assert adapter.format_message("<") == "<" + + def test_pre_escaped_gt_not_double_escaped(self, adapter): + """Already-escaped > in plain text must not become &gt;.""" + assert adapter.format_message("5 > 3") == "5 > 3" + + def test_mixed_raw_and_escaped_entities(self, adapter): + """Raw & and pre-escaped & coexist correctly.""" + result = adapter.format_message("AT&T and & entity") + assert result == "AT&T and & entity" + + def test_link_with_parentheses_in_url(self, adapter): + """Wikipedia-style URL with balanced parens is not truncated.""" + result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + assert result == "" + + def test_link_with_multiple_paren_pairs(self, adapter): + """URL with multiple balanced paren pairs.""" + result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))") + assert result == "" + + def test_link_without_parens_still_works(self, adapter): + """Normal URL without parens is unaffected by regex change.""" + result = adapter.format_message("[click](https://example.com/path?q=1)") + assert result == "" + + def test_link_with_angle_brackets_and_parens(self, adapter): + """Angle-bracket URL with parens (CommonMark syntax).""" + result = adapter.format_message("[Foo]()") + assert result == "" + + def test_escaping_is_idempotent(self, adapter): + """Formatting already-formatted text produces the same result.""" + original = "AT&T < 5 > 3" + once = adapter.format_message(original) + twice = adapter.format_message(once) + assert once == twice + + # --- Entity preservation (spec-compliance) --- + + def test_channel_mention_preserved(self, adapter): + """ special mention passes through unchanged.""" + assert adapter.format_message("Attention ") == "Attention " + + def test_everyone_mention_preserved(self, adapter): + """ special mention passes through unchanged.""" + assert adapter.format_message("Hey ") == "Hey " + + def test_subteam_mention_preserved(self, adapter): + """ user group mention passes through unchanged.""" + assert adapter.format_message("Paging ") == "Paging " + + def test_date_formatting_preserved(self, adapter): + """ formatting token passes through unchanged.""" + text = "Posted " + assert adapter.format_message(text) == text + + def test_channel_link_preserved(self, adapter): + """<#CHANNEL_ID> channel link passes through unchanged.""" + assert adapter.format_message("Join <#C12345>") == "Join <#C12345>" + + # --- Additional edge cases --- + + def test_message_only_code_block(self, adapter): + """Entire message is a fenced code block — no conversion.""" + code = "```python\nx = 1\n```" + assert adapter.format_message(code) == code + + def test_multiline_mixed_formatting(self, adapter): + """Multi-line message with headers, bold, links, code, and blockquotes.""" + text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`" + result = adapter.format_message(text) + assert result.startswith("*Title*") + assert "*bold*" in result + assert "" in result + assert "> quote" in result + assert "`code`" in result + + def test_markdown_unordered_list_with_asterisk(self, adapter): + """Asterisk list items must not trigger italic conversion.""" + text = "* item one\n* item two" + result = adapter.format_message(text) + assert "item one" in result + assert "item two" in result + + def test_nested_bold_in_link(self, adapter): + """Bold inside link label — label is stashed before bold pass.""" + result = adapter.format_message("[**bold**](https://example.com)") + assert "https://example.com" in result + assert "bold" in result + + def test_url_with_query_string_and_ampersand(self, adapter): + """Ampersand in URL query string must not be escaped.""" + result = adapter.format_message("[link](https://x.com?a=1&b=2)") + assert result == "" + + def test_emoji_shortcodes_passthrough(self, adapter): + """Emoji shortcodes like :smile: pass through unchanged.""" + assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:" + + +# --------------------------------------------------------------------------- +# TestEditMessage +# --------------------------------------------------------------------------- + + +class TestEditMessage: + """Verify that edit_message() applies mrkdwn formatting before sending.""" + + @pytest.mark.asyncio + async def test_edit_message_formats_bold(self, adapter): + """edit_message converts **bold** to Slack *bold*.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "**hello world**") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "*hello world*" + + @pytest.mark.asyncio + async def test_edit_message_formats_links(self, adapter): + """edit_message converts markdown links to Slack format.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "" + + @pytest.mark.asyncio + async def test_edit_message_preserves_blockquotes(self, adapter): + """edit_message preserves blockquote > markers.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "> quoted text") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "> quoted text" + + @pytest.mark.asyncio + async def test_edit_message_escapes_control_chars(self, adapter): + """edit_message escapes & < > in plain text.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "AT&T < 5 > 3" + + +# --------------------------------------------------------------------------- +# TestEditMessageStreamingPipeline +# --------------------------------------------------------------------------- + + +class TestEditMessageStreamingPipeline: + """E2E: verify that sequential streaming edits all go through format_message. + + Simulates the GatewayStreamConsumer pattern where edit_message is called + repeatedly with progressively longer accumulated text. Every call must + produce properly formatted mrkdwn in the chat_update payload. + """ + + @pytest.mark.asyncio + async def test_edit_message_formats_streaming_updates(self, adapter): + """Simulates streaming: multiple edits, each should be formatted.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + # First streaming update — bold + result1 = await adapter.edit_message("C123", "ts1", "**Processing**...") + assert result1.success is True + kwargs1 = adapter._app.client.chat_update.call_args.kwargs + assert kwargs1["text"] == "*Processing*..." + + # Second streaming update — bold + link + result2 = await adapter.edit_message( + "C123", "ts1", "**Done!** See [results](https://example.com)" + ) + assert result2.success is True + kwargs2 = adapter._app.client.chat_update.call_args.kwargs + assert kwargs2["text"] == "*Done!* See " + + @pytest.mark.asyncio + async def test_edit_message_formats_code_and_bold(self, adapter): + """Streaming update with code block and bold — code must be preserved.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + content = "**Result:**\n```python\nprint('hello')\n```" + result = await adapter.edit_message("C123", "ts1", content) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"].startswith("*Result:*") + assert "```python\nprint('hello')\n```" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_blockquote_in_stream(self, adapter): + """Streaming update with blockquote — '>' marker must survive.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + content = "> **Important:** do this\nnormal line" + result = await adapter.edit_message("C123", "ts1", content) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"].startswith("> *Important:*") + assert "normal line" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_progressive_accumulation(self, adapter): + """Simulate real streaming: text grows with each edit, all formatted.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + updates = [ + ("**Step 1**", "*Step 1*"), + ("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"), + ( + "**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)", + "*Step 1*\n*Step 2*\nSee ", + ), + ] + + for raw, expected in updates: + result = await adapter.edit_message("C123", "ts1", raw) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == expected, f"Failed for input: {raw!r}" + + # Total edit count should match number of updates + assert adapter._app.client.chat_update.call_count == len(updates) + + @pytest.mark.asyncio + async def test_edit_message_formats_bold_italic(self, adapter): + """Bold+italic ***text*** is formatted as *_text_* in edited messages.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "***important*** update") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "*_important_*" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_does_not_double_escape(self, adapter): + """Pre-escaped entities in edited messages must not get double-escaped.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "5 > 3 and & entity") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "&gt;" not in kwargs["text"] + assert "&amp;" not in kwargs["text"] + assert ">" in kwargs["text"] + assert "&" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_url_with_parens(self, adapter): + """Wikipedia-style URL with parens survives edit pipeline.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_not_connected(self, adapter): + """edit_message returns failure when adapter is not connected.""" + adapter._app = None + result = await adapter.edit_message("C123", "ts1", "**hello**") + assert result.success is False + assert "Not connected" in result.error + # --------------------------------------------------------------------------- # TestReactions @@ -1085,6 +1416,48 @@ class TestMessageSplitting: await adapter.send("C123", "hello world") assert adapter._app.client.chat_postMessage.call_count == 1 + @pytest.mark.asyncio + async def test_send_preserves_blockquote_formatting(self, adapter): + """Blockquote '>' markers must survive format → chunk → send pipeline.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "> quoted text\nnormal text") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + sent_text = kwargs["text"] + assert sent_text.startswith("> quoted text") + assert "normal text" in sent_text + + @pytest.mark.asyncio + async def test_send_formats_bold_italic(self, adapter): + """Bold+italic ***text*** is formatted as *_text_* in sent messages.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "***important*** update") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "*_important_*" in kwargs["text"] + + @pytest.mark.asyncio + async def test_send_explicitly_enables_mrkdwn(self, adapter): + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "**hello**") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert kwargs.get("mrkdwn") is True + + @pytest.mark.asyncio + async def test_send_does_not_double_escape_entities(self, adapter): + """Pre-escaped & in sent messages must not become &amp;.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "Use & for ampersand") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "&amp;" not in kwargs["text"] + assert "&" in kwargs["text"] + + @pytest.mark.asyncio + async def test_send_formats_url_with_parens(self, adapter): + """Wikipedia-style URL with parens survives send pipeline.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "" in kwargs["text"] + # --------------------------------------------------------------------------- # TestReplyBroadcast diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 34cea278..94370e4d 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot): monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) +def _ensure_slack_mock(monkeypatch): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + monkeypatch.setitem(sys.modules, name, mod) + + class TestSendMessageTool: def test_cron_duplicate_target_is_skipped_and_explained(self): home = SimpleNamespace(chat_id="-1001") @@ -426,7 +450,7 @@ class TestSendToPlatformChunking: result = asyncio.run( _send_to_platform( Platform.DISCORD, - SimpleNamespace(enabled=True, token="tok", extra={}), + SimpleNamespace(enabled=True, token="***", extra={}), "ch", long_msg, ) ) @@ -435,8 +459,115 @@ class TestSendToPlatformChunking: for call in send.await_args_list: assert len(call.args[2]) <= 2020 # each chunk fits the limit + def test_slack_messages_are_formatted_before_send(self, monkeypatch): + _ensure_slack_mock(monkeypatch) + + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "**hello** from [Hermes]()", + ) + ) + + assert result["success"] is True + send.assert_awaited_once_with( + "***", + "C123", + "*hello* from ", + ) + + def test_slack_bold_italic_formatted_before_send(self, monkeypatch): + """Bold+italic ***text*** survives tool-layer formatting.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "***important*** update", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "*_important_*" in sent_text + + def test_slack_blockquote_formatted_before_send(self, monkeypatch): + """Blockquote '>' markers must survive formatting (not escaped to '>').""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "> important quote\n\nnormal text & stuff", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert sent_text.startswith("> important quote") + assert "&" in sent_text # & is escaped + assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped + + def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): + """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "AT&T <tag> test", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "&amp;" not in sent_text + assert "&lt;" not in sent_text + assert "AT&T" in sent_text + + def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): + """Wikipedia-style URL with parens survives tool-layer formatting.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "" in sent_text + def test_telegram_media_attaches_to_last_chunk(self): - """When chunked, media files are sent only with the last chunk.""" + sent_calls = [] async def fake_send(token, chat_id, message, media_files=None, thread_id=None): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 76b3e158..4957609e 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -322,6 +322,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files = media_files or [] + if platform == Platform.SLACK and message: + try: + slack_adapter = SlackAdapter.__new__(SlackAdapter) + message = slack_adapter.format_message(message) + except Exception: + logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) + # Platform message length limits (from adapter class attributes) _MAX_LENGTHS = { Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, @@ -571,7 +578,8 @@ async def _send_slack(token, chat_id, message): url = "https://slack.com/api/chat.postMessage" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"channel": chat_id, "text": message}) as resp: + payload = {"channel": chat_id, "text": message, "mrkdwn": True} + async with session.post(url, headers=headers, json=payload) as resp: data = await resp.json() if data.get("ok"): return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} From 1773e3d647915deb4e225d19693ef88ddc7fb752 Mon Sep 17 00:00:00 2001 From: Mibayy Date: Thu, 9 Apr 2026 13:35:13 -0700 Subject: [PATCH 18/51] feat(slack): add allow_bots config for bot-to-bot communication Three modes: "none" (default, backward-compatible), "mentions" (accept bot messages only when they @mention us), "all" (accept all bot messages except our own, to prevent echo loops). Configurable via: slack: allow_bots: mentions Or env var: SLACK_ALLOW_BOTS=mentions Self-message guard always active regardless of mode. Based on PR #3200 by Mibayy, adapted to current main with config.yaml bridging support. --- gateway/config.py | 2 ++ gateway/platforms/slack.py | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 47b779eb..a50c9331 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -553,6 +553,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(slack_cfg, dict): if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"): os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() + if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): + os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() frc = slack_cfg.get("free_response_channels") if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index d2f50907..825cc251 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -941,9 +941,26 @@ class SlackAdapter(BasePlatformAdapter): if v > cutoff } - # Ignore bot messages (including our own) + # Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots): + # "none" — ignore all bot messages (default, backward-compatible) + # "mentions" — accept bot messages only when they @mention us + # "all" — accept all bot messages (except our own) if event.get("bot_id") or event.get("subtype") == "bot_message": - return + allow_bots = self.config.extra.get("allow_bots", "") + if not allow_bots: + allow_bots = os.getenv("SLACK_ALLOW_BOTS", "none") + allow_bots = str(allow_bots).lower().strip() + if allow_bots == "none": + return + elif allow_bots == "mentions": + text_check = event.get("text", "") + if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check: + return + # "all" falls through to process the message + # Always ignore our own messages to prevent echo loops + msg_user = event.get("user", "") + if msg_user and self._bot_user_id and msg_user == self._bot_user_id: + return # Ignore message edits and deletions subtype = event.get("subtype") From 18d8e91a5a0bd5237cfb7ca39eb0dda803198432 Mon Sep 17 00:00:00 2001 From: gunpowder-client-vm Date: Thu, 9 Apr 2026 13:36:04 -0700 Subject: [PATCH 19/51] fix(slack): treat group DMs (mpim) like DMs + smart reaction guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat mpim (multi-party IM / group DM) channels as DMs — no @mention required, continuous session like 1:1 DMs - Only add 👀/✅ reactions when bot is directly addressed (DM or @mention). In listen-all channels (require_mention=false) reacting to every message would be noisy. Based on PR #4633 by gunpowder-client-vm, adapted to current main. --- gateway/platforms/slack.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 825cc251..feb08e49 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -992,7 +992,7 @@ class SlackAdapter(BasePlatformAdapter): channel_type = event.get("channel_type", "") if not channel_type and channel_id.startswith("D"): channel_type = "im" - is_dm = channel_type == "im" + is_dm = channel_type in ("im", "mpim") # Both 1:1 and group DMs # Build thread_ts for session keying. # In channels: fall back to ts so each top-level @mention starts a @@ -1179,14 +1179,19 @@ class SlackAdapter(BasePlatformAdapter): reply_to_message_id=thread_ts if thread_ts != ts else None, ) - # Add 👀 reaction to acknowledge receipt - await self._add_reaction(channel_id, ts, "eyes") + # Only react when bot is directly addressed (DM or @mention). + # In listen-all channels (require_mention=false), reacting to every + # casual message would be noisy. + _should_react = is_dm or is_mentioned + + if _should_react: + await self._add_reaction(channel_id, ts, "eyes") await self.handle_message(msg_event) - # Replace 👀 with ✅ when done - await self._remove_reaction(channel_id, ts, "eyes") - await self._add_reaction(channel_id, ts, "white_check_mark") + if _should_react: + await self._remove_reaction(channel_id, ts, "eyes") + await self._add_reaction(channel_id, ts, "white_check_mark") # ----- Approval button support (Block Kit) ----- From 88845b99d2e01be8bf3d02e8bb638ef8b2550fb2 Mon Sep 17 00:00:00 2001 From: jarvisxyz Date: Thu, 9 Apr 2026 13:37:15 -0700 Subject: [PATCH 20/51] fix(slack): add rate-limit retry and TTL cache to thread context fetching - Add _ThreadContextCache dataclass for caching fetched context (60s TTL) - Add exponential backoff retry for conversations.replies 429 rate limits (Tier 3, ~50 req/min) - Only fetch context when no active session exists (guard at call site) to prevent duplication across turns - Hoist bot_uid lookup outside the per-message loop - Clearer header text for injected thread context Based on PR #6162 by jarvisxyz, cherry-picked onto current main. --- gateway/platforms/slack.py | 101 +++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index feb08e49..b4973bbb 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -14,6 +14,7 @@ import logging import os import re import time +from dataclasses import dataclass, field from typing import Dict, Optional, Any, Tuple try: @@ -45,6 +46,14 @@ from gateway.platforms.base import ( logger = logging.getLogger(__name__) +@dataclass +class _ThreadContextCache: + """Cache entry for fetched thread context.""" + content: str + fetched_at: float = field(default_factory=time.monotonic) + message_count: int = 0 + + def check_slack_requirements() -> bool: """Check if Slack dependencies are available.""" return SLACK_AVAILABLE @@ -101,6 +110,9 @@ class SlackAdapter(BasePlatformAdapter): # session + memory scoping. self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {} self._ASSISTANT_THREADS_MAX = 5000 + # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache + self._thread_context_cache: Dict[str, _ThreadContextCache] = {} + self._THREAD_CACHE_TTL = 60.0 async def connect(self) -> bool: """Connect to Slack via Socket Mode.""" @@ -1377,57 +1389,104 @@ class SlackAdapter(BasePlatformAdapter): """Fetch recent thread messages to provide context when the bot is mentioned mid-thread for the first time. - Returns a formatted string with thread history, or empty string on - failure or if the thread is empty (just the parent message). + This method is only called when there is NO active session for the + thread (guarded at the call site by _has_active_session_for_thread). + That guard ensures thread messages are prepended only on the very + first turn — after that the session history already holds them, so + there is no duplication across subsequent turns. + + Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid + hammering conversations.replies (Tier 3, ~50 req/min). + + Returns a formatted string with prior thread history, or empty string + on failure or if the thread has no prior messages. """ + cache_key = f"{channel_id}:{thread_ts}" + now = time.monotonic() + cached = self._thread_context_cache.get(cache_key) + if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL: + return cached.content + try: client = self._get_client(channel_id) - result = await client.conversations_replies( - channel=channel_id, - ts=thread_ts, - limit=limit + 1, # +1 because it includes the current message - inclusive=True, - ) + + # Retry with exponential backoff for Tier-3 rate limits (429). + result = None + for attempt in range(3): + try: + result = await client.conversations_replies( + channel=channel_id, + ts=thread_ts, + limit=limit + 1, # +1 because it includes the current message + inclusive=True, + ) + break + except Exception as exc: + # Check for rate-limit error from slack_sdk + err_str = str(exc).lower() + is_rate_limit = ( + "ratelimited" in err_str + or "429" in err_str + or "rate_limited" in err_str + ) + if is_rate_limit and attempt < 2: + retry_after = 1.0 * (2 ** attempt) # 1s, 2s + logger.warning( + "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)", + retry_after, attempt + 1, + ) + await asyncio.sleep(retry_after) + continue + raise + + if result is None: + return "" + messages = result.get("messages", []) if not messages: return "" + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) context_parts = [] for msg in messages: msg_ts = msg.get("ts", "") - # Skip the current message (the one that triggered this fetch) + # Exclude the current triggering message — it will be delivered + # as the user message itself, so including it here would duplicate it. if msg_ts == current_ts: continue - # Skip bot messages from ourselves + # Exclude our own bot messages to avoid circular context. if msg.get("bot_id") or msg.get("subtype") == "bot_message": continue - msg_user = msg.get("user", "unknown") msg_text = msg.get("text", "").strip() if not msg_text: continue # Strip bot mentions from context messages - bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) if bot_uid: msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip() - # Mark the thread parent + msg_user = msg.get("user", "unknown") is_parent = msg_ts == thread_ts prefix = "[thread parent] " if is_parent else "" - - # Resolve user name (cached) name = await self._resolve_user_name(msg_user, chat_id=channel_id) context_parts.append(f"{prefix}{name}: {msg_text}") - if not context_parts: - return "" + content = "" + if context_parts: + content = ( + "[Thread context — prior messages in this thread (not yet in conversation history):]\n" + + "\n".join(context_parts) + + "\n[End of thread context]\n\n" + ) - return ( - "[Thread context — previous messages in this thread:]\n" - + "\n".join(context_parts) - + "\n[End of thread context]\n\n" + self._thread_context_cache[cache_key] = _ThreadContextCache( + content=content, + fetched_at=now, + message_count=len(context_parts), ) + return content + except Exception as e: logger.warning("[Slack] Failed to fetch thread context: %s", e) return "" From 88dbbfe98282af31e337e5dad081a9a3f1885a88 Mon Sep 17 00:00:00 2001 From: Zheng Li Date: Thu, 9 Apr 2026 19:37:58 +0800 Subject: [PATCH 21/51] feat(gateway): unified proxy support for Discord and Telegram with macOS auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolve_proxy_url() to base.py — shared by all platform adapters - Check HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars first - Fall back to macOS system proxy via scutil --proxy (zero-config) - Pass proxy= to discord.py commands.Bot() for gateway connectivity - Refactor telegram_network.py to use shared resolver - Update test fixtures to accept proxy kwarg --- gateway/platforms/base.py | 55 +++++++++++++++++++++++++++ gateway/platforms/discord.py | 7 ++++ gateway/platforms/telegram_network.py | 8 ++-- tests/gateway/test_discord_connect.py | 6 +-- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index bd07459a..48fb1485 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -10,11 +10,66 @@ import logging import os import random import re +import subprocess +import sys import uuid from abc import ABC, abstractmethod from urllib.parse import urlsplit logger = logging.getLogger(__name__) + + +def _detect_macos_system_proxy() -> str | None: + """Read the macOS system HTTP(S) proxy via ``scutil --proxy``. + + Returns an ``http://host:port`` URL string if an HTTP or HTTPS proxy is + enabled, otherwise *None*. Falls back silently on non-macOS or on any + subprocess error. + """ + if sys.platform != "darwin": + return None + try: + out = subprocess.check_output( + ["scutil", "--proxy"], timeout=3, text=True, stderr=subprocess.DEVNULL, + ) + except Exception: + return None + + props: dict[str, str] = {} + for line in out.splitlines(): + line = line.strip() + if " : " in line: + key, _, val = line.partition(" : ") + props[key.strip()] = val.strip() + + # Prefer HTTPS, fall back to HTTP + for enable_key, host_key, port_key in ( + ("HTTPSEnable", "HTTPSProxy", "HTTPSPort"), + ("HTTPEnable", "HTTPProxy", "HTTPPort"), + ): + if props.get(enable_key) == "1": + host = props.get(host_key) + port = props.get(port_key) + if host and port: + return f"http://{host}:{port}" + return None + + +def resolve_proxy_url() -> str | None: + """Return an HTTP(S) proxy URL from env vars, or macOS system proxy. + + Check order: + 1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants) + 2. macOS system proxy via ``scutil --proxy`` (auto-detect) + + Returns *None* if no proxy is found. + """ + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy"): + value = (os.environ.get(key) or "").strip() + if value: + return value + return _detect_macos_system_proxy() from dataclasses import dataclass, field from datetime import datetime from pathlib import Path diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2ace06e7..2715400a 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -529,10 +529,17 @@ class DiscordAdapter(BasePlatformAdapter): intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) intents.voice_states = True + # Resolve HTTP proxy (env vars first, then macOS system proxy) + from gateway.platforms.base import resolve_proxy_url + proxy_url = resolve_proxy_url() + if proxy_url: + logger.info("[%s] Using HTTP proxy: %s", self.name, proxy_url) + # Create bot self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, + proxy=proxy_url, ) adapter_self = self # capture for closure diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index 9f6d8bb4..2b26ab91 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -45,11 +45,9 @@ _SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"] def _resolve_proxy_url() -> str | None: - for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): - value = (os.environ.get(key) or "").strip() - if value: - return value - return None + # Delegate to shared implementation (env vars + macOS system proxy detection) + from gateway.platforms.base import resolve_proxy_url + return resolve_proxy_url() class TelegramFallbackTransport(httpx.AsyncBaseTransport): diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 6809c443..dd594cf7 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -56,7 +56,7 @@ class FakeTree: class FakeBot: - def __init__(self, *, intents): + def __init__(self, *, intents, proxy=None): self.intents = intents self.user = SimpleNamespace(id=999, name="Hermes") self._events = {} @@ -95,7 +95,7 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all created = {} - def fake_bot_factory(*, command_prefix, intents): + def fake_bot_factory(*, command_prefix, intents, proxy=None): created["bot"] = FakeBot(intents=intents) return created["bot"] @@ -124,7 +124,7 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch): monkeypatch.setattr( discord_platform.commands, "Bot", - lambda **kwargs: FakeBot(intents=kwargs["intents"]), + lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")), ) async def fake_wait_for(awaitable, timeout): From 6f8e4262757e8127cefd582040e75917dffeefa8 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:16:39 -0700 Subject: [PATCH 22/51] fix: add SOCKS proxy support, DISCORD_PROXY env var, and send_message proxy coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up improvements on top of the shared resolver from PR #6562: - Add platform_env_var parameter to resolve_proxy_url() so DISCORD_PROXY takes priority over generic HTTPS_PROXY/ALL_PROXY env vars - Add SOCKS proxy support via aiohttp_socks.ProxyConnector with rdns=True (critical for GFW/Shadowrocket/Clash users — issue #6649) - proxy_kwargs_for_bot() returns connector= for SOCKS, proxy= for HTTP - proxy_kwargs_for_aiohttp() returns split (session_kw, request_kw) for standalone aiohttp sessions - Add proxy support to send_message_tool.py (Discord REST, Slack, SMS) for cron job delivery behind proxies (from PR #2208) - Add proxy support to Discord image/document downloads - Fix duplicate import sys in base.py --- gateway/platforms/base.py | 75 ++++++++++++++++++++++++++++++++++-- gateway/platforms/discord.py | 25 +++++++----- tools/send_message_tool.py | 21 +++++++--- 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 48fb1485..0a8390a7 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -55,28 +55,97 @@ def _detect_macos_system_proxy() -> str | None: return None -def resolve_proxy_url() -> str | None: - """Return an HTTP(S) proxy URL from env vars, or macOS system proxy. +def resolve_proxy_url(platform_env_var: str | None = None) -> str | None: + """Return a proxy URL from env vars, or macOS system proxy. Check order: + 0. *platform_env_var* (e.g. ``DISCORD_PROXY``) — highest priority 1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants) 2. macOS system proxy via ``scutil --proxy`` (auto-detect) Returns *None* if no proxy is found. """ + if platform_env_var: + value = (os.environ.get(platform_env_var) or "").strip() + if value: + return value for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): value = (os.environ.get(key) or "").strip() if value: return value return _detect_macos_system_proxy() + + +def proxy_kwargs_for_bot(proxy_url: str | None) -> dict: + """Build kwargs for ``commands.Bot()`` / ``discord.Client()`` with proxy. + + Returns: + - SOCKS URL → ``{"connector": ProxyConnector(..., rdns=True)}`` + - HTTP URL → ``{"proxy": url}`` + - *None* → ``{}`` + + ``rdns=True`` forces remote DNS resolution through the proxy — required + by many SOCKS implementations (Shadowrocket, Clash) and essential for + bypassing DNS pollution behind the GFW. + """ + if not proxy_url: + return {} + if proxy_url.lower().startswith("socks"): + try: + from aiohttp_socks import ProxyConnector + + connector = ProxyConnector.from_url(proxy_url, rdns=True) + return {"connector": connector} + except ImportError: + logger.warning( + "aiohttp_socks not installed — SOCKS proxy %s ignored. " + "Run: pip install aiohttp-socks", + proxy_url, + ) + return {} + return {"proxy": proxy_url} + + +def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: + """Build kwargs for standalone ``aiohttp.ClientSession`` with proxy. + + Returns ``(session_kwargs, request_kwargs)`` where: + - SOCKS → ``({"connector": ProxyConnector(...)}, {})`` + - HTTP → ``({}, {"proxy": url})`` + - None → ``({}, {})`` + + Usage:: + + sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy_url) + async with aiohttp.ClientSession(**sess_kw) as session: + async with session.get(url, **req_kw) as resp: + ... + """ + if not proxy_url: + return {}, {} + if proxy_url.lower().startswith("socks"): + try: + from aiohttp_socks import ProxyConnector + + connector = ProxyConnector.from_url(proxy_url, rdns=True) + return {"connector": connector}, {} + except ImportError: + logger.warning( + "aiohttp_socks not installed — SOCKS proxy %s ignored. " + "Run: pip install aiohttp-socks", + proxy_url, + ) + return {}, {} + return {}, {"proxy": proxy_url} + + from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple from enum import Enum -import sys from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2715400a..686d6061 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -529,17 +529,17 @@ class DiscordAdapter(BasePlatformAdapter): intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) intents.voice_states = True - # Resolve HTTP proxy (env vars first, then macOS system proxy) - from gateway.platforms.base import resolve_proxy_url - proxy_url = resolve_proxy_url() + # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy) + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_bot + proxy_url = resolve_proxy_url(platform_env_var="DISCORD_PROXY") if proxy_url: - logger.info("[%s] Using HTTP proxy: %s", self.name, proxy_url) + logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) - # Create bot + # Create bot — proxy= for HTTP, connector= for SOCKS self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, - proxy=proxy_url, + **proxy_kwargs_for_bot(proxy_url), ) adapter_self = self # capture for closure @@ -1314,8 +1314,11 @@ class DiscordAdapter(BasePlatformAdapter): # Download the image and send as a Discord file attachment # (Discord renders attachments inline, unlike plain URLs) - async with aiohttp.ClientSession() as session: - async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + async with aiohttp.ClientSession(**_sess_kw) as session: + async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp: if resp.status != 200: raise Exception(f"Failed to download image: HTTP {resp.status}") @@ -2398,10 +2401,14 @@ class DiscordAdapter(BasePlatformAdapter): else: try: import aiohttp - async with aiohttp.ClientSession() as session: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + async with aiohttp.ClientSession(**_sess_kw) as session: async with session.get( att.url, timeout=aiohttp.ClientTimeout(total=30), + **_req_kw, ) as resp: if resp.status != 200: raise Exception(f"HTTP {resp.status}") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 4957609e..2700231e 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -555,10 +555,13 @@ async def _send_discord(token, chat_id, message): except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} try: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) url = f"https://discord.com/api/v10/channels/{chat_id}/messages" headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"content": message}) as resp: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: + async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp: if resp.status not in (200, 201): body = await resp.text() return _error(f"Discord API error ({resp.status}): {body}") @@ -575,11 +578,14 @@ async def _send_slack(token, chat_id, message): except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} try: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url() + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) url = "https://slack.com/api/chat.postMessage" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: payload = {"channel": chat_id, "text": message, "mrkdwn": True} - async with session.post(url, headers=headers, json=payload) as resp: + async with session.post(url, headers=headers, json=payload, **_req_kw) as resp: data = await resp.json() if data.get("ok"): return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} @@ -712,18 +718,21 @@ async def _send_sms(auth_token, chat_id, message): message = message.strip() try: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url() + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) creds = f"{account_sid}:{auth_token}" encoded = base64.b64encode(creds.encode("ascii")).decode("ascii") url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" headers = {"Authorization": f"Basic {encoded}"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: form_data = aiohttp.FormData() form_data.add_field("From", from_number) form_data.add_field("To", chat_id) form_data.add_field("Body", message) - async with session.post(url, data=form_data, headers=headers) as resp: + async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp: body = await resp.json() if resp.status >= 400: error_msg = body.get("message", str(body)) From 775a46ce7522ee231e3d94c22f56360ba81c0d4c Mon Sep 17 00:00:00 2001 From: Greer Guthrie Date: Thu, 9 Apr 2026 11:06:39 -0500 Subject: [PATCH 23/51] fix: normalize reasoning effort ordering in UI --- batch_runner.py | 4 +-- cli.py | 6 ++-- gateway/platforms/discord.py | 2 +- gateway/run.py | 6 ++-- hermes_cli/commands.py | 2 +- hermes_cli/main.py | 5 ++- hermes_constants.py | 2 +- tests/hermes_cli/test_commands.py | 11 ++++++ .../hermes_cli/test_reasoning_effort_menu.py | 34 +++++++++++++++++++ 9 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 tests/hermes_cli/test_reasoning_effort_menu.py diff --git a/batch_runner.py b/batch_runner.py index 32cd203b..195452c0 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -1158,7 +1158,7 @@ def main( providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google") provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only) max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set) - reasoning_effort (str): OpenRouter reasoning effort level: "xhigh", "high", "medium", "low", "minimal", "none" (default: "medium") + reasoning_effort (str): OpenRouter reasoning effort level: "none", "minimal", "low", "medium", "high", "xhigh" (default: "medium") reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False) prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts) max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set) @@ -1227,7 +1227,7 @@ def main( print("🧠 Reasoning: DISABLED (effort=none)") elif reasoning_effort: # Use specified effort level - valid_efforts = ["xhigh", "high", "medium", "low", "minimal", "none"] + valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"] if reasoning_effort not in valid_efforts: print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}") return diff --git a/cli.py b/cli.py index db956766..30e43b6e 100644 --- a/cli.py +++ b/cli.py @@ -5259,7 +5259,7 @@ class HermesCLI: Usage: /reasoning Show current effort level and display state - /reasoning Set reasoning effort (none, low, medium, high, xhigh) + /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh) /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output """ @@ -5277,7 +5277,7 @@ class HermesCLI: display_state = "on ✓" if self.show_reasoning else "off" _cprint(f" {_GOLD}Reasoning effort: {level}{_RST}") _cprint(f" {_GOLD}Reasoning display: {display_state}{_RST}") - _cprint(f" {_DIM}Usage: /reasoning {_RST}") + _cprint(f" {_DIM}Usage: /reasoning {_RST}") return arg = parts[1].strip().lower() @@ -5303,7 +5303,7 @@ class HermesCLI: parsed = _parse_reasoning_config(arg) if parsed is None: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Valid levels: none, low, minimal, medium, high, xhigh{_RST}") + _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}") _cprint(f" {_DIM}Display: show, hide{_RST}") return diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 686d6061..a19b6d66 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1595,7 +1595,7 @@ class DiscordAdapter(BasePlatformAdapter): await self._run_simple_slash(interaction, f"/model {name}".strip()) @tree.command(name="reasoning", description="Show or change reasoning effort") - @discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.") + @discord.app_commands.describe(effort="Reasoning effort: none, minimal, low, medium, high, or xhigh.") async def slash_reasoning(interaction: discord.Interaction, effort: str = ""): await self._run_simple_slash(interaction, f"/reasoning {effort}".strip()) diff --git a/gateway/run.py b/gateway/run.py index 339954f5..abdb3324 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4840,7 +4840,7 @@ class GatewayRunner: Usage: /reasoning Show current effort level and display state - /reasoning Set reasoning effort (none, low, medium, high, xhigh) + /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh) /reasoning show|on Show model reasoning in responses /reasoning hide|off Hide model reasoning from responses """ @@ -4885,7 +4885,7 @@ class GatewayRunner: "🧠 **Reasoning Settings**\n\n" f"**Effort:** `{level}`\n" f"**Display:** {display_state}\n\n" - "_Usage:_ `/reasoning `" + "_Usage:_ `/reasoning `" ) # Display toggle @@ -4908,7 +4908,7 @@ class GatewayRunner: else: return ( f"⚠️ Unknown argument: `{effort}`\n\n" - "**Valid levels:** none, low, minimal, medium, high, xhigh\n" + "**Valid levels:** none, minimal, low, medium, high, xhigh\n" "**Display:** show, hide" ) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index ac0f44d7..5231dccb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -99,7 +99,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]", - subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("voice", "Toggle voice mode", "Configuration", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a6d616e6..1e7be054 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1811,7 +1811,10 @@ def _set_reasoning_effort(config, effort: str) -> None: def _prompt_reasoning_effort_selection(efforts, current_effort=""): """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" - ordered = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip())) + deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip())) + canonical_order = ("minimal", "low", "medium", "high", "xhigh") + ordered = [effort for effort in canonical_order if effort in deduped] + ordered.extend(effort for effort in deduped if effort not in canonical_order) if not ordered: return None diff --git a/hermes_constants.py b/hermes_constants.py index c28f6dc8..eded659e 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -78,7 +78,7 @@ VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal") def parse_reasoning_effort(effort: str) -> dict | None: """Parse a reasoning effort level into a config dict. - Valid levels: "xhigh", "high", "medium", "low", "minimal", "none". + Valid levels: "none", "minimal", "low", "medium", "high", "xhigh". Returns None when the input is empty or unrecognized (caller uses default). Returns {"enabled": False} for "none". Returns {"enabled": True, "effort": } for valid effort levels. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 98a4b2ef..29996fe1 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -68,6 +68,17 @@ class TestCommandRegistry: for cmd in COMMAND_REGISTRY: assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'" + def test_reasoning_subcommands_are_in_logical_order(self): + reasoning = next(cmd for cmd in COMMAND_REGISTRY if cmd.name == "reasoning") + assert reasoning.subcommands[:6] == ( + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ) + def test_cli_only_and_gateway_only_are_mutually_exclusive(self): for cmd in COMMAND_REGISTRY: assert not (cmd.cli_only and cmd.gateway_only), \ diff --git a/tests/hermes_cli/test_reasoning_effort_menu.py b/tests/hermes_cli/test_reasoning_effort_menu.py new file mode 100644 index 00000000..3d360a4f --- /dev/null +++ b/tests/hermes_cli/test_reasoning_effort_menu.py @@ -0,0 +1,34 @@ +import sys +import types + + +from hermes_cli.main import _prompt_reasoning_effort_selection + + +class _FakeTerminalMenu: + last_choices = None + + def __init__(self, choices, **kwargs): + _FakeTerminalMenu.last_choices = choices + self._cursor_index = kwargs.get("cursor_index") + + def show(self): + return self._cursor_index + + +def test_reasoning_menu_orders_minimal_before_low(monkeypatch): + fake_module = types.SimpleNamespace(TerminalMenu=_FakeTerminalMenu) + monkeypatch.setitem(sys.modules, "simple_term_menu", fake_module) + + selected = _prompt_reasoning_effort_selection( + ["low", "minimal", "medium", "high"], + current_effort="medium", + ) + + assert selected == "medium" + assert _FakeTerminalMenu.last_choices[:4] == [ + " minimal", + " low", + " medium ← currently in use", + " high", + ] From 1780ad24b1279e63027a42c5cdb75f2ea7324fea Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 13:27:02 -0700 Subject: [PATCH 24/51] fix: normalize remaining reasoning effort orderings and add missing 'minimal' Follow-up to cherry-picked PR #6698. Fixes spots the original PR missed: - hermes_constants.py: VALID_REASONING_EFFORTS tuple ordering - gateway/run.py: _load_reasoning_config docstring + validation tuple - configuration.md and batch-processing.md: docs ordering - hermes-agent skill: /reasoning usage hint was missing 'minimal' --- gateway/run.py | 6 +++--- hermes_constants.py | 2 +- skills/autonomous-ai-agents/hermes-agent/SKILL.md | 2 +- website/docs/user-guide/configuration.md | 2 +- website/docs/user-guide/features/batch-processing.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index abdb3324..b75b0e1f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -925,8 +925,8 @@ class GatewayRunner: def _load_reasoning_config() -> dict | None: """Load reasoning effort from config.yaml. - Reads agent.reasoning_effort from config.yaml. Valid: "xhigh", - "high", "medium", "low", "minimal", "none". Returns None to use + Reads agent.reasoning_effort from config.yaml. Valid: "none", + "minimal", "low", "medium", "high", "xhigh". Returns None to use default (medium). """ from hermes_constants import parse_reasoning_effort @@ -4903,7 +4903,7 @@ class GatewayRunner: effort = args.strip() if effort == "none": parsed = {"enabled": False} - elif effort in ("xhigh", "high", "medium", "low", "minimal"): + elif effort in ("minimal", "low", "medium", "high", "xhigh"): parsed = {"enabled": True, "effort": effort} else: return ( diff --git a/hermes_constants.py b/hermes_constants.py index eded659e..638d36a3 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -72,7 +72,7 @@ def display_hermes_home() -> str: return str(home) -VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal") +VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh") def parse_reasoning_effort(effort: str) -> dict | None: diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 74445c26..6d8cd1c6 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -250,7 +250,7 @@ Type these during an interactive chat session. /model [name] Show or change model /provider Show provider info /personality [name] Set personality -/reasoning [level] Set reasoning (none|low|medium|high|xhigh|show|hide) +/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) /verbose Cycle: off → new → all → verbose /voice [on|off|tts] Voice mode /yolo Toggle approval bypass diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 0ac24db1..819a379e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -747,7 +747,7 @@ Control how much "thinking" the model does before responding: ```yaml agent: - reasoning_effort: "" # empty = medium (default). Options: xhigh (max), high, medium, low, minimal, none + reasoning_effort: "" # empty = medium (default). Options: none, minimal, low, medium, high, xhigh (max) ``` When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency. diff --git a/website/docs/user-guide/features/batch-processing.md b/website/docs/user-guide/features/batch-processing.md index 3cab1eba..59554e34 100644 --- a/website/docs/user-guide/features/batch-processing.md +++ b/website/docs/user-guide/features/batch-processing.md @@ -79,7 +79,7 @@ Entries can optionally include: | Parameter | Description | |-----------|-------------| -| `--reasoning_effort` | Effort level: `xhigh`, `high`, `medium`, `low`, `minimal`, `none` | +| `--reasoning_effort` | Effort level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` | | `--reasoning_disabled` | Completely disable reasoning/thinking tokens | ### Advanced Options From 127b4caf0d96f03b74be1379778bfa74f1c6e820 Mon Sep 17 00:00:00 2001 From: spideystreet Date: Thu, 9 Apr 2026 12:51:55 -0700 Subject: [PATCH 25/51] feat(skills): migrate google-workspace to gws CLI backend Migrate the google-workspace skill from custom Python API wrappers (google-api-python-client) to Google's official Rust CLI gws (googleworkspace/cli). Add gws_bridge.py for headless-compatible token refresh. Fix partial OAuth scope handling. Co-authored-by: spideystreet Cherry-picked from PR #6713 --- skills/productivity/google-workspace/SKILL.md | 164 +++---- .../google-workspace/scripts/google_api.py | 435 +++++------------- .../google-workspace/scripts/gws_bridge.py | 82 ++++ .../google-workspace/scripts/setup.py | 48 +- 4 files changed, 303 insertions(+), 426 deletions(-) create mode 100755 skills/productivity/google-workspace/scripts/gws_bridge.py diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 60b9693d..c94014a1 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,7 +1,7 @@ --- name: google-workspace -description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via Python. Uses OAuth2 with automatic token refresh. No external binaries needed — runs entirely with Google's Python client libraries in the Hermes venv. -version: 1.0.0 +description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via gws CLI (googleworkspace/cli). Uses OAuth2 with automatic token refresh via bridge script. Requires gws binary. +version: 2.0.0 author: Nous Research license: MIT required_credential_files: @@ -11,14 +11,25 @@ required_credential_files: description: Google OAuth2 client credentials (downloaded from Google Cloud Console) metadata: hermes: - tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth] + tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws] homepage: https://github.com/NousResearch/hermes-agent related_skills: [himalaya] --- # Google Workspace -Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python scripts in this skill. No external binaries to install. +Gmail, Calendar, Drive, Contacts, Sheets, and Docs — powered by `gws` (Google's official Rust CLI). The skill provides a backward-compatible Python wrapper that handles OAuth token refresh and delegates to `gws`. + +## Architecture + +``` +google_api.py → gws_bridge.py → gws CLI +(argparse compat) (token refresh) (Google APIs) +``` + +- `setup.py` handles OAuth2 (headless-compatible, works on CLI/Telegram/Discord) +- `gws_bridge.py` refreshes the Hermes token and injects it into `gws` via `GOOGLE_WORKSPACE_CLI_TOKEN` +- `google_api.py` provides the same CLI interface as v1 but delegates to `gws` ## References @@ -27,7 +38,19 @@ Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python script ## Scripts - `scripts/setup.py` — OAuth2 setup (run once to authorize) -- `scripts/google_api.py` — API wrapper CLI (agent uses this for all operations) +- `scripts/gws_bridge.py` — Token refresh bridge to gws CLI +- `scripts/google_api.py` — Backward-compatible API wrapper (delegates to gws) + +## Prerequisites + +Install `gws`: + +```bash +cargo install google-workspace-cli +# or via npm: npm install -g @anthropic/google-workspace-cli +``` + +Verify: `gws --version` ## First-Time Setup @@ -56,42 +79,29 @@ If it prints `AUTHENTICATED`, skip to Usage — setup is already done. ### Step 1: Triage — ask the user what they need -Before starting OAuth setup, ask the user TWO questions: - **Question 1: "What Google services do you need? Just email, or also Calendar/Drive/Sheets/Docs?"** -- **Email only** → They don't need this skill at all. Use the `himalaya` skill - instead — it works with a Gmail App Password (Settings → Security → App - Passwords) and takes 2 minutes to set up. No Google Cloud project needed. - Load the himalaya skill and follow its setup instructions. +- **Email only** → Use the `himalaya` skill instead — simpler setup. +- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below. -- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue with this - skill's OAuth setup below. +**Partial scopes**: Users can authorize only a subset of services. The setup +script accepts partial scopes and warns about missing ones. -**Question 2: "Does your Google account use Advanced Protection (hardware -security keys required to sign in)? If you're not sure, you probably don't -— it's something you would have explicitly enrolled in."** +**Question 2: "Does your Google account use Advanced Protection?"** -- **No / Not sure** → Normal setup. Continue below. -- **Yes** → Their Workspace admin must add the OAuth client ID to the org's - allowed apps list before Step 4 will work. Let them know upfront. +- **No / Not sure** → Normal setup. +- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first. ### Step 2: Create OAuth credentials (one-time, ~5 minutes) Tell the user: -> You need a Google Cloud OAuth client. This is a one-time setup: -> > 1. Go to https://console.cloud.google.com/apis/credentials > 2. Create a project (or use an existing one) -> 3. Click "Enable APIs" and enable: Gmail API, Google Calendar API, -> Google Drive API, Google Sheets API, Google Docs API, People API -> 4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID -> 5. Application type: "Desktop app" → Create -> 6. Click "Download JSON" and tell me the file path - -Once they provide the path: +> 3. Enable the APIs you need (Gmail, Calendar, Drive, Sheets, Docs, People) +> 4. Credentials → Create Credentials → OAuth 2.0 Client ID → Desktop app +> 5. Download JSON and tell me the file path ```bash $GSETUP --client-secret /path/to/client_secret.json @@ -103,20 +113,10 @@ $GSETUP --client-secret /path/to/client_secret.json $GSETUP --auth-url ``` -This prints a URL. **Send the URL to the user** and tell them: - -> Open this link in your browser, sign in with your Google account, and -> authorize access. After authorizing, you'll be redirected to a page that -> may show an error — that's expected. Copy the ENTIRE URL from your -> browser's address bar and paste it back to me. +Send the URL to the user. After authorizing, they paste back the redirect URL or code. ### Step 4: Exchange the code -The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...` -or just the code string. Either works. The `--auth-url` step stores a temporary -pending OAuth session locally so `--auth-code` can complete the PKCE exchange -later, even on headless systems: - ```bash $GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" ``` @@ -127,18 +127,11 @@ $GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" $GSETUP --check ``` -Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on. - -### Notes - -- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes. -- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes. -- Hermes now refuses to overwrite a full Google Workspace token with a narrower re-auth token missing Gmail scopes, so one profile's partial consent cannot silently break email actions later. -- To revoke: `$GSETUP --revoke` +Should print `AUTHENTICATED`. Token refreshes automatically from now on. ## Usage -All commands go through the API script. Set `GAPI` as a shorthand: +All commands go through the API script: ```bash HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" @@ -153,40 +146,21 @@ GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py" ### Gmail ```bash -# Search (returns JSON array with id, from, subject, date, snippet) $GAPI gmail search "is:unread" --max 10 -$GAPI gmail search "from:boss@company.com newer_than:1d" -$GAPI gmail search "has:attachment filename:pdf newer_than:7d" - -# Read full message (returns JSON with body text) $GAPI gmail get MESSAGE_ID - -# Send $GAPI gmail send --to user@example.com --subject "Hello" --body "Message text" -$GAPI gmail send --to user@example.com --subject "Report" --body "

Q4

Details...

" --html - -# Reply (automatically threads and sets In-Reply-To) +$GAPI gmail send --to user@example.com --subject "Report" --body "

Q4

" --html $GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." - -# Labels $GAPI gmail labels $GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID -$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD ``` ### Calendar ```bash -# List events (defaults to next 7 days) $GAPI calendar list -$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z - -# Create event (ISO 8601 with timezone required) -$GAPI calendar create --summary "Team Standup" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00 -$GAPI calendar create --summary "Lunch" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location "Cafe" -$GAPI calendar create --summary "Review" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees "alice@co.com,bob@co.com" - -# Delete event +$GAPI calendar create --summary "Standup" --start 2026-03-01T10:00:00+01:00 --end 2026-03-01T10:30:00+01:00 +$GAPI calendar create --summary "Review" --start ... --end ... --attendees "alice@co.com,bob@co.com" $GAPI calendar delete EVENT_ID ``` @@ -206,13 +180,8 @@ $GAPI contacts list --max 20 ### Sheets ```bash -# Read $GAPI sheets get SHEET_ID "Sheet1!A1:D10" - -# Write $GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]' - -# Append rows $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' ``` @@ -222,37 +191,40 @@ $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' $GAPI docs get DOC_ID ``` +### Direct gws access (advanced) + +For operations not covered by the wrapper, use `gws_bridge.py` directly: + +```bash +GBRIDGE="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/gws_bridge.py" +$GBRIDGE calendar +agenda --today --format table +$GBRIDGE gmail +triage --labels --format json +$GBRIDGE drive +upload ./report.pdf +$GBRIDGE sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:D10" +``` + ## Output Format -All commands return JSON. Parse with `jq` or read directly. Key fields: - -- **Gmail search**: `[{id, threadId, from, to, subject, date, snippet, labels}]` -- **Gmail get**: `{id, threadId, from, to, subject, date, labels, body}` -- **Gmail send/reply**: `{status: "sent", id, threadId}` -- **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]` -- **Calendar create**: `{status: "created", id, summary, htmlLink}` -- **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]` -- **Contacts list**: `[{name, emails: [...], phones: [...]}]` -- **Sheets get**: `[[cell, cell, ...], ...]` +All commands return JSON via `gws --format json`. Output structure varies by `gws` helper. ## Rules -1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval. -2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup. -3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view("google-workspace", file_path="references/gmail-search-syntax.md")`. -4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`). -5. **Respect rate limits** — avoid rapid-fire sequential API calls. Batch reads when possible. +1. **Never send email or create/delete events without confirming with the user first.** +2. **Check auth before first use** — run `setup.py --check`. +3. **Use the Gmail search syntax reference** for complex queries. +4. **Calendar times must include timezone** — ISO 8601 with offset or UTC. +5. **Respect rate limits** — avoid rapid-fire sequential API calls. ## Troubleshooting | Problem | Fix | |---------|-----| -| `NOT_AUTHENTICATED` | Run setup Steps 2-5 above | -| `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 | -| `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 | -| `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console | -| `ModuleNotFoundError` | Run `$GSETUP --install-deps` | -| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID | +| `NOT_AUTHENTICATED` | Run setup Steps 2-5 | +| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 | +| `gws: command not found` | Install: `cargo install google-workspace-cli` | +| `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 | +| `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console | +| Advanced Protection blocks auth | Admin must allowlist the OAuth client ID | ## Revoking Access diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index ece0c3ea..e288ec1a 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 """Google Workspace API CLI for Hermes Agent. -A thin CLI wrapper around Google's Python client libraries. -Authenticates using the token stored by setup.py. +Thin wrapper that delegates to gws (googleworkspace/cli) via gws_bridge.py. +Maintains the same CLI interface for backward compatibility with Hermes skills. Usage: python google_api.py gmail search "is:unread" [--max 10] python google_api.py gmail get MESSAGE_ID python google_api.py gmail send --to user@example.com --subject "Hi" --body "Hello" python google_api.py gmail reply MESSAGE_ID --body "Thanks" - python google_api.py calendar list [--from DATE] [--to DATE] [--calendar primary] + python google_api.py calendar list [--start DATE] [--end DATE] [--calendar primary] python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME + python google_api.py calendar delete EVENT_ID python google_api.py drive search "budget report" [--max 10] python google_api.py contacts list [--max 20] python google_api.py sheets get SHEET_ID RANGE @@ -20,386 +21,178 @@ Usage: """ import argparse -import base64 import json +import os +import subprocess import sys -from datetime import datetime, timedelta, timezone -from email.mime.text import MIMEText from pathlib import Path -try: - from hermes_constants import display_hermes_home, get_hermes_home -except ModuleNotFoundError: - HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4] - if HERMES_AGENT_ROOT.exists(): - sys.path.insert(0, str(HERMES_AGENT_ROOT)) - from hermes_constants import display_hermes_home, get_hermes_home - -HERMES_HOME = get_hermes_home() -TOKEN_PATH = HERMES_HOME / "google_token.json" - -SCOPES = [ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/contacts.readonly", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/documents.readonly", -] +BRIDGE = Path(__file__).parent / "gws_bridge.py" +PYTHON = sys.executable -def _missing_scopes() -> list[str]: - try: - payload = json.loads(TOKEN_PATH.read_text()) - except Exception: - return [] - raw = payload.get("scopes") or payload.get("scope") - if not raw: - return [] - granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()} - return sorted(scope for scope in SCOPES if scope not in granted) +def gws(*args: str) -> None: + """Call gws via the bridge and exit with its return code.""" + result = subprocess.run( + [PYTHON, str(BRIDGE)] + list(args), + env={**os.environ, "HERMES_HOME": os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))}, + ) + sys.exit(result.returncode) -def get_credentials(): - """Load and refresh credentials from token file.""" - if not TOKEN_PATH.exists(): - print("Not authenticated. Run the setup script first:", file=sys.stderr) - print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr) - sys.exit(1) - - from google.oauth2.credentials import Credentials - from google.auth.transport.requests import Request - - creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES) - if creds.expired and creds.refresh_token: - creds.refresh(Request()) - TOKEN_PATH.write_text(creds.to_json()) - if not creds.valid: - print("Token is invalid. Re-run setup.", file=sys.stderr) - sys.exit(1) - - missing_scopes = _missing_scopes() - if missing_scopes: - print( - "Token is valid but missing Google Workspace scopes required by this skill.", - file=sys.stderr, - ) - for scope in missing_scopes: - print(f" - {scope}", file=sys.stderr) - print( - f"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.", - file=sys.stderr, - ) - sys.exit(1) - return creds - - -def build_service(api, version): - from googleapiclient.discovery import build - return build(api, version, credentials=get_credentials()) - - -# ========================================================================= -# Gmail -# ========================================================================= +# -- Gmail -- def gmail_search(args): - service = build_service("gmail", "v1") - results = service.users().messages().list( - userId="me", q=args.query, maxResults=args.max - ).execute() - messages = results.get("messages", []) - if not messages: - print("No messages found.") - return - - output = [] - for msg_meta in messages: - msg = service.users().messages().get( - userId="me", id=msg_meta["id"], format="metadata", - metadataHeaders=["From", "To", "Subject", "Date"], - ).execute() - headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} - output.append({ - "id": msg["id"], - "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), - "snippet": msg.get("snippet", ""), - "labels": msg.get("labelIds", []), - }) - print(json.dumps(output, indent=2, ensure_ascii=False)) - + cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"] + gws(*cmd) def gmail_get(args): - service = build_service("gmail", "v1") - msg = service.users().messages().get( - userId="me", id=args.message_id, format="full" - ).execute() - - headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} - - # Extract body text - body = "" - payload = msg.get("payload", {}) - if payload.get("body", {}).get("data"): - body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace") - elif payload.get("parts"): - for part in payload["parts"]: - if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"): - body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") - break - if not body: - for part in payload["parts"]: - if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"): - body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") - break - - result = { - "id": msg["id"], - "threadId": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), - "labels": msg.get("labelIds", []), - "body": body, - } - print(json.dumps(result, indent=2, ensure_ascii=False)) - + gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json") def gmail_send(args): - service = build_service("gmail", "v1") - message = MIMEText(args.body, "html" if args.html else "plain") - message["to"] = args.to - message["subject"] = args.subject + cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"] if args.cc: - message["cc"] = args.cc - - raw = base64.urlsafe_b64encode(message.as_bytes()).decode() - body = {"raw": raw} - - if args.thread_id: - body["threadId"] = args.thread_id - - result = service.users().messages().send(userId="me", body=body).execute() - print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) - + cmd += ["--cc", args.cc] + if args.html: + cmd.append("--html") + gws(*cmd) def gmail_reply(args): - service = build_service("gmail", "v1") - # Fetch original to get thread ID and headers - original = service.users().messages().get( - userId="me", id=args.message_id, format="metadata", - metadataHeaders=["From", "Subject", "Message-ID"], - ).execute() - headers = {h["name"]: h["value"] for h in original.get("payload", {}).get("headers", [])} - - subject = headers.get("Subject", "") - if not subject.startswith("Re:"): - subject = f"Re: {subject}" - - message = MIMEText(args.body) - message["to"] = headers.get("From", "") - message["subject"] = subject - if headers.get("Message-ID"): - message["In-Reply-To"] = headers["Message-ID"] - message["References"] = headers["Message-ID"] - - raw = base64.urlsafe_b64encode(message.as_bytes()).decode() - body = {"raw": raw, "threadId": original["threadId"]} - - result = service.users().messages().send(userId="me", body=body).execute() - print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) - + gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json") def gmail_labels(args): - service = build_service("gmail", "v1") - results = service.users().labels().list(userId="me").execute() - labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] - print(json.dumps(labels, indent=2)) - + gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json") def gmail_modify(args): - service = build_service("gmail", "v1") body = {} if args.add_labels: body["addLabelIds"] = args.add_labels.split(",") if args.remove_labels: body["removeLabelIds"] = args.remove_labels.split(",") - result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute() - print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) + gws( + "gmail", "users", "messages", "modify", + "--params", json.dumps({"userId": "me", "id": args.message_id}), + "--json", json.dumps(body), + "--format", "json", + ) -# ========================================================================= -# Calendar -# ========================================================================= +# -- Calendar -- def calendar_list(args): - service = build_service("calendar", "v3") - now = datetime.now(timezone.utc) - time_min = args.start or now.isoformat() - time_max = args.end or (now + timedelta(days=7)).isoformat() - - # Ensure timezone info - for val in [time_min, time_max]: - if "T" in val and "Z" not in val and "+" not in val and "-" not in val[11:]: - val += "Z" - - results = service.events().list( - calendarId=args.calendar, timeMin=time_min, timeMax=time_max, - maxResults=args.max, singleEvents=True, orderBy="startTime", - ).execute() - - events = [] - for e in results.get("items", []): - events.append({ - "id": e["id"], - "summary": e.get("summary", "(no title)"), - "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")), - "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")), - "location": e.get("location", ""), - "description": e.get("description", ""), - "status": e.get("status", ""), - "htmlLink": e.get("htmlLink", ""), - }) - print(json.dumps(events, indent=2, ensure_ascii=False)) - + cmd = ["calendar", "+agenda", "--format", "json"] + if args.start and args.end: + # Calculate days between start and end for --days flag + cmd += ["--days", "7"] + else: + cmd += ["--days", "7"] + if args.calendar != "primary": + cmd += ["--calendar", args.calendar] + gws(*cmd) def calendar_create(args): - service = build_service("calendar", "v3") - event = { - "summary": args.summary, - "start": {"dateTime": args.start}, - "end": {"dateTime": args.end}, - } + cmd = [ + "calendar", "+insert", + "--summary", args.summary, + "--start", args.start, + "--end", args.end, + "--format", "json", + ] if args.location: - event["location"] = args.location + cmd += ["--location", args.location] if args.description: - event["description"] = args.description + cmd += ["--description", args.description] if args.attendees: - event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",")] - - result = service.events().insert(calendarId=args.calendar, body=event).execute() - print(json.dumps({ - "status": "created", - "id": result["id"], - "summary": result.get("summary", ""), - "htmlLink": result.get("htmlLink", ""), - }, indent=2)) - + for email in args.attendees.split(","): + cmd += ["--attendee", email.strip()] + if args.calendar != "primary": + cmd += ["--calendar", args.calendar] + gws(*cmd) def calendar_delete(args): - service = build_service("calendar", "v3") - service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute() - print(json.dumps({"status": "deleted", "eventId": args.event_id})) + gws( + "calendar", "events", "delete", + "--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}), + "--format", "json", + ) -# ========================================================================= -# Drive -# ========================================================================= +# -- Drive -- def drive_search(args): - service = build_service("drive", "v3") - query = f"fullText contains '{args.query}'" if not args.raw_query else args.query - results = service.files().list( - q=query, pageSize=args.max, fields="files(id, name, mimeType, modifiedTime, webViewLink)", - ).execute() - files = results.get("files", []) - print(json.dumps(files, indent=2, ensure_ascii=False)) + query = args.query if args.raw_query else f"fullText contains '{args.query}'" + gws( + "drive", "files", "list", + "--params", json.dumps({ + "q": query, + "pageSize": args.max, + "fields": "files(id,name,mimeType,modifiedTime,webViewLink)", + }), + "--format", "json", + ) -# ========================================================================= -# Contacts -# ========================================================================= +# -- Contacts -- def contacts_list(args): - service = build_service("people", "v1") - results = service.people().connections().list( - resourceName="people/me", - pageSize=args.max, - personFields="names,emailAddresses,phoneNumbers", - ).execute() - contacts = [] - for person in results.get("connections", []): - names = person.get("names", [{}]) - emails = person.get("emailAddresses", []) - phones = person.get("phoneNumbers", []) - contacts.append({ - "name": names[0].get("displayName", "") if names else "", - "emails": [e.get("value", "") for e in emails], - "phones": [p.get("value", "") for p in phones], - }) - print(json.dumps(contacts, indent=2, ensure_ascii=False)) + gws( + "people", "people", "connections", "list", + "--params", json.dumps({ + "resourceName": "people/me", + "pageSize": args.max, + "personFields": "names,emailAddresses,phoneNumbers", + }), + "--format", "json", + ) -# ========================================================================= -# Sheets -# ========================================================================= +# -- Sheets -- def sheets_get(args): - service = build_service("sheets", "v4") - result = service.spreadsheets().values().get( - spreadsheetId=args.sheet_id, range=args.range, - ).execute() - print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) - + gws( + "sheets", "+read", + "--spreadsheet", args.sheet_id, + "--range", args.range, + "--format", "json", + ) def sheets_update(args): - service = build_service("sheets", "v4") values = json.loads(args.values) - body = {"values": values} - result = service.spreadsheets().values().update( - spreadsheetId=args.sheet_id, range=args.range, - valueInputOption="USER_ENTERED", body=body, - ).execute() - print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) - + gws( + "sheets", "spreadsheets", "values", "update", + "--params", json.dumps({ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + }), + "--json", json.dumps({"values": values}), + "--format", "json", + ) def sheets_append(args): - service = build_service("sheets", "v4") values = json.loads(args.values) - body = {"values": values} - result = service.spreadsheets().values().append( - spreadsheetId=args.sheet_id, range=args.range, - valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body, - ).execute() - print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) + gws( + "sheets", "+append", + "--spreadsheet", args.sheet_id, + "--json-values", json.dumps(values), + "--format", "json", + ) -# ========================================================================= -# Docs -# ========================================================================= +# -- Docs -- def docs_get(args): - service = build_service("docs", "v1") - doc = service.documents().get(documentId=args.doc_id).execute() - # Extract plain text from the document structure - text_parts = [] - for element in doc.get("body", {}).get("content", []): - paragraph = element.get("paragraph", {}) - for pe in paragraph.get("elements", []): - text_run = pe.get("textRun", {}) - if text_run.get("content"): - text_parts.append(text_run["content"]) - result = { - "title": doc.get("title", ""), - "documentId": doc.get("documentId", ""), - "body": "".join(text_parts), - } - print(json.dumps(result, indent=2, ensure_ascii=False)) + gws( + "docs", "documents", "get", + "--params", json.dumps({"documentId": args.doc_id}), + "--format", "json", + ) -# ========================================================================= -# CLI parser -# ========================================================================= +# -- CLI parser (backward-compatible interface) -- def main(): - parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent") + parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)") sub = parser.add_subparsers(dest="service", required=True) # --- Gmail --- @@ -421,7 +214,7 @@ def main(): p.add_argument("--body", required=True) p.add_argument("--cc", default="") p.add_argument("--html", action="store_true", help="Send body as HTML") - p.add_argument("--thread-id", default="", help="Thread ID for threading") + p.add_argument("--thread-id", default="", help="Thread ID (unused with gws, kept for compat)") p.set_defaults(func=gmail_send) p = gmail_sub.add_parser("reply") diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py new file mode 100755 index 00000000..7ee74fc9 --- /dev/null +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Bridge between Hermes OAuth token and gws CLI. + +Refreshes the token if expired, then executes gws with the valid access token. +""" +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def get_hermes_home() -> Path: + return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + + +def get_token_path() -> Path: + return get_hermes_home() / "google_token.json" + + +def refresh_token(token_data: dict) -> dict: + """Refresh the access token using the refresh token.""" + import urllib.parse + import urllib.request + + params = urllib.parse.urlencode({ + "client_id": token_data["client_id"], + "client_secret": token_data["client_secret"], + "refresh_token": token_data["refresh_token"], + "grant_type": "refresh_token", + }).encode() + + req = urllib.request.Request(token_data["token_uri"], data=params) + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + + token_data["token"] = result["access_token"] + token_data["expiry"] = datetime.fromtimestamp( + datetime.now(timezone.utc).timestamp() + result["expires_in"], + tz=timezone.utc, + ).isoformat() + + get_token_path().write_text(json.dumps(token_data, indent=2)) + return token_data + + +def get_valid_token() -> str: + """Return a valid access token, refreshing if needed.""" + token_path = get_token_path() + if not token_path.exists(): + print("ERROR: No Google token found. Run setup.py --auth-url first.", file=sys.stderr) + sys.exit(1) + + token_data = json.loads(token_path.read_text()) + + expiry = token_data.get("expiry", "") + if expiry: + exp_dt = datetime.fromisoformat(expiry.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if now >= exp_dt: + token_data = refresh_token(token_data) + + return token_data["token"] + + +def main(): + """Refresh token if needed, then exec gws with remaining args.""" + if len(sys.argv) < 2: + print("Usage: gws_bridge.py ", file=sys.stderr) + sys.exit(1) + + access_token = get_valid_token() + env = os.environ.copy() + env["GOOGLE_WORKSPACE_CLI_TOKEN"] = access_token + + result = subprocess.run(["gws"] + sys.argv[1:], env=env) + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 5e4924f9..0cc862bd 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -23,6 +23,7 @@ Agent workflow: import argparse import json +import os import subprocess import sys from pathlib import Path @@ -128,7 +129,11 @@ def check_auth(): from google.auth.transport.requests import Request try: - creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES) + # Don't pass scopes — user may have authorized only a subset. + # Passing scopes forces google-auth to validate them on refresh, + # which fails with invalid_scope if the token has fewer scopes + # than requested. + creds = Credentials.from_authorized_user_file(str(TOKEN_PATH)) except Exception as e: print(f"TOKEN_CORRUPT: {e}") return False @@ -137,8 +142,7 @@ def check_auth(): if creds.valid: missing_scopes = _missing_scopes_from_payload(payload) if missing_scopes: - print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}") - return False + print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes") print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") return True @@ -148,8 +152,7 @@ def check_auth(): TOKEN_PATH.write_text(creds.to_json()) missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH)) if missing_scopes: - print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}") - return False + print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes") print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: @@ -272,16 +275,33 @@ def exchange_auth_code(code: str): _ensure_deps() from google_auth_oauthlib.flow import Flow + from urllib.parse import parse_qs, urlparse + + # Extract granted scopes from the callback URL if present + if returned_state and "scope" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith("http") else {}): + granted_scopes = parse_qs(urlparse(code).query)["scope"][0].split() + else: + # Try to extract from code_or_url parameter + if isinstance(code, str) and code.startswith("http"): + params = parse_qs(urlparse(code).query) + if "scope" in params: + granted_scopes = params["scope"][0].split() + else: + granted_scopes = SCOPES + else: + granted_scopes = SCOPES flow = Flow.from_client_secrets_file( str(CLIENT_SECRET_PATH), - scopes=SCOPES, + scopes=granted_scopes, redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI), state=pending_auth["state"], code_verifier=pending_auth["code_verifier"], ) try: + # Accept partial scopes — user may deselect some permissions in the consent screen + os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" flow.fetch_token(code=code) except Exception as e: print(f"ERROR: Token exchange failed: {e}") @@ -290,11 +310,21 @@ def exchange_auth_code(code: str): creds = flow.credentials token_payload = json.loads(creds.to_json()) + + # Store only the scopes actually granted by the user, not what was requested. + # creds.to_json() writes the requested scopes, which causes refresh to fail + # with invalid_scope if the user only authorized a subset. + actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else [] + if actually_granted: + token_payload["scopes"] = actually_granted + elif granted_scopes != SCOPES: + # granted_scopes was extracted from the callback URL + token_payload["scopes"] = granted_scopes + missing_scopes = _missing_scopes_from_payload(token_payload) if missing_scopes: - print(f"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}") - print(f"Existing token at {TOKEN_PATH} was left unchanged.") - sys.exit(1) + print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}") + print("Some services may not be available.") TOKEN_PATH.write_text(json.dumps(token_payload, indent=2)) PENDING_AUTH_PATH.unlink(missing_ok=True) From 73eb59db8dfa33c21453828d021f96b1135175a3 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 12:54:12 -0700 Subject: [PATCH 26/51] fix: follow-up fixes for google-workspace gws migration - Fix npm package name: @anthropic -> @googleworkspace/cli - Add Homebrew install option - Fix calendar_list to respect --start/--end args (uses raw Calendar API for date ranges, +agenda helper for default 7-day view) - Improve check_auth partial scope output (list missing scopes) - Add output format documentation with key JSON shapes - Use npm install in troubleshooting (no Rust toolchain needed) Follow-up to cherry-picked PR #6713 --- skills/productivity/google-workspace/SKILL.md | 21 +++++++++++-- .../google-workspace/scripts/google_api.py | 31 ++++++++++++++----- .../google-workspace/scripts/setup.py | 8 +++-- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index c94014a1..e4553e42 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -47,7 +47,10 @@ Install `gws`: ```bash cargo install google-workspace-cli -# or via npm: npm install -g @anthropic/google-workspace-cli +# or via npm (recommended, downloads prebuilt binary): +npm install -g @googleworkspace/cli +# or via Homebrew: +brew install googleworkspace-cli ``` Verify: `gws --version` @@ -205,7 +208,19 @@ $GBRIDGE sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:D10" ## Output Format -All commands return JSON via `gws --format json`. Output structure varies by `gws` helper. +All commands return JSON via `gws --format json`. Key output shapes: + +- **Gmail search/triage**: Array of message summaries (sender, subject, date, snippet) +- **Gmail get/read**: Message object with headers and body text +- **Gmail send/reply**: Confirmation with message ID +- **Calendar list/agenda**: Array of event objects (summary, start, end, location) +- **Calendar create**: Confirmation with event ID and htmlLink +- **Drive search**: Array of file objects (id, name, mimeType, webViewLink) +- **Sheets get/read**: 2D array of cell values +- **Docs get**: Full document JSON (use `body.content` for text extraction) +- **Contacts list**: Array of person objects with names, emails, phones + +Parse output with `jq` or read JSON directly. ## Rules @@ -221,7 +236,7 @@ All commands return JSON via `gws --format json`. Output structure varies by `gw |---------|-----| | `NOT_AUTHENTICATED` | Run setup Steps 2-5 | | `REFRESH_FAILED` | Token revoked — redo Steps 3-5 | -| `gws: command not found` | Install: `cargo install google-workspace-cli` | +| `gws: command not found` | Install: `npm install -g @googleworkspace/cli` | | `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 | | `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console | | Advanced Protection blocks auth | Admin must allowlist the OAuth client ID | diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index e288ec1a..ae8732f4 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -80,15 +80,30 @@ def gmail_modify(args): # -- Calendar -- def calendar_list(args): - cmd = ["calendar", "+agenda", "--format", "json"] - if args.start and args.end: - # Calculate days between start and end for --days flag - cmd += ["--days", "7"] + if args.start or args.end: + # Specific date range — use raw Calendar API for precise timeMin/timeMax + from datetime import datetime, timedelta, timezone as tz + now = datetime.now(tz.utc) + time_min = args.start or now.isoformat() + time_max = args.end or (now + timedelta(days=7)).isoformat() + gws( + "calendar", "events", "list", + "--params", json.dumps({ + "calendarId": args.calendar, + "timeMin": time_min, + "timeMax": time_max, + "maxResults": args.max, + "singleEvents": True, + "orderBy": "startTime", + }), + "--format", "json", + ) else: - cmd += ["--days", "7"] - if args.calendar != "primary": - cmd += ["--calendar", args.calendar] - gws(*cmd) + # No date range — use +agenda helper (defaults to 7 days) + cmd = ["calendar", "+agenda", "--days", "7", "--format", "json"] + if args.calendar != "primary": + cmd += ["--calendar", args.calendar] + gws(*cmd) def calendar_create(args): cmd = [ diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 0cc862bd..cb8c38cb 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -142,7 +142,9 @@ def check_auth(): if creds.valid: missing_scopes = _missing_scopes_from_payload(payload) if missing_scopes: - print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes") + print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:") + for s in missing_scopes: + print(f" - {s}") print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}") return True @@ -152,7 +154,9 @@ def check_auth(): TOKEN_PATH.write_text(creds.to_json()) missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH)) if missing_scopes: - print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes") + print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:") + for s in missing_scopes: + print(f" - {s}") print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}") return True except Exception as e: From c8bbd29aaed55ab14dd49dfea9ae320c4b4403ed Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 13:32:51 -0700 Subject: [PATCH 27/51] fix: update tests for gws migration - Rewrite test_google_workspace_api.py: test bridge token handling and calendar date range instead of removed get_credentials() - Update test_google_oauth_setup.py: partial scopes now accepted with warning instead of rejected with SystemExit --- tests/skills/test_google_oauth_setup.py | 21 +- tests/skills/test_google_workspace_api.py | 228 ++++++++++++++-------- 2 files changed, 155 insertions(+), 94 deletions(-) diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index a96e3d24..89612b7d 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -211,14 +211,15 @@ class TestExchangeAuthCode: assert setup_module.PENDING_AUTH_PATH.exists() assert not setup_module.TOKEN_PATH.exists() - def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys): + def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys): + """Partial scopes are accepted with a warning (gws migration: v2.0).""" setup_module.PENDING_AUTH_PATH.write_text( json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) ) - setup_module.TOKEN_PATH.write_text(json.dumps({"token": "existing-token", "scopes": setup_module.SCOPES})) + setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES})) FakeFlow.credentials_payload = { - "token": "narrow-token", - "refresh_token": "refresh-token", + "token": "***", + "refresh_token": "***", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "client-id", "client_secret": "client-secret", @@ -228,10 +229,12 @@ class TestExchangeAuthCode: ], } - with pytest.raises(SystemExit): - setup_module.exchange_auth_code("4/test-auth-code") + setup_module.exchange_auth_code("4/test-auth-code") out = capsys.readouterr().out - assert "refusing to save incomplete google workspace token" in out.lower() - assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "existing-token" - assert setup_module.PENDING_AUTH_PATH.exists() + assert "warning" in out.lower() + assert "missing" in out.lower() + # Token is saved (partial scopes accepted) + assert setup_module.TOKEN_PATH.exists() + # Pending auth is cleaned up + assert not setup_module.PENDING_AUTH_PATH.exists() diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 694bf492..034dd29c 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -1,117 +1,175 @@ -"""Regression tests for Google Workspace API credential validation.""" +"""Tests for Google Workspace gws bridge and CLI wrapper.""" import importlib.util import json +import os +import subprocess import sys import types +from datetime import datetime, timedelta, timezone from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -SCRIPT_PATH = ( +BRIDGE_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/gws_bridge.py" +) +API_PATH = ( Path(__file__).resolve().parents[2] / "skills/productivity/google-workspace/scripts/google_api.py" ) -class FakeAuthorizedCredentials: - def __init__(self, *, valid=True, expired=False, refresh_token="refresh-token"): - self.valid = valid - self.expired = expired - self.refresh_token = refresh_token - self.refresh_calls = 0 - - def refresh(self, _request): - self.refresh_calls += 1 - self.valid = True - self.expired = False - - def to_json(self): - return json.dumps({ - "token": "refreshed-token", - "refresh_token": self.refresh_token, - "token_uri": "https://oauth2.googleapis.com/token", - "client_id": "client-id", - "client_secret": "client-secret", - "scopes": [ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/contacts.readonly", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/documents.readonly", - ], - }) - - -class FakeCredentialsFactory: - creds = FakeAuthorizedCredentials() - - @classmethod - def from_authorized_user_file(cls, _path, _scopes): - return cls.creds - - @pytest.fixture -def google_api_module(monkeypatch, tmp_path): - google_module = types.ModuleType("google") - oauth2_module = types.ModuleType("google.oauth2") - credentials_module = types.ModuleType("google.oauth2.credentials") - credentials_module.Credentials = FakeCredentialsFactory - auth_module = types.ModuleType("google.auth") - transport_module = types.ModuleType("google.auth.transport") - requests_module = types.ModuleType("google.auth.transport.requests") - requests_module.Request = object +def bridge_module(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setitem(sys.modules, "google", google_module) - monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module) - monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module) - monkeypatch.setitem(sys.modules, "google.auth", auth_module) - monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module) - monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module) - - spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH) + spec = importlib.util.spec_from_file_location("gws_bridge_test", BRIDGE_PATH) module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) - - monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json") return module -def _write_token(path: Path, scopes): - path.write_text(json.dumps({ - "token": "access-token", - "refresh_token": "refresh-token", +@pytest.fixture +def api_module(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + spec = importlib.util.spec_from_file_location("gws_api_test", API_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _write_token(path: Path, *, token="ya29.test", expiry=None, **extra): + data = { + "token": token, + "refresh_token": "1//refresh", + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", "token_uri": "https://oauth2.googleapis.com/token", - "client_id": "client-id", - "client_secret": "client-secret", - "scopes": scopes, - })) + **extra, + } + if expiry is not None: + data["expiry"] = expiry + path.write_text(json.dumps(data)) -def test_get_credentials_rejects_missing_scopes(google_api_module, capsys): - FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True) - _write_token(google_api_module.TOKEN_PATH, [ - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/spreadsheets", - ]) +def test_bridge_returns_valid_token(bridge_module, tmp_path): + """Non-expired token is returned without refresh.""" + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + token_path = bridge_module.get_token_path() + _write_token(token_path, token="ya29.valid", expiry=future) + result = bridge_module.get_valid_token() + assert result == "ya29.valid" + + +def test_bridge_refreshes_expired_token(bridge_module, tmp_path): + """Expired token triggers a refresh via token_uri.""" + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + token_path = bridge_module.get_token_path() + _write_token(token_path, token="ya29.old", expiry=past) + + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({ + "access_token": "ya29.refreshed", + "expires_in": 3600, + }).encode() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + result = bridge_module.get_valid_token() + + assert result == "ya29.refreshed" + # Verify persisted + saved = json.loads(token_path.read_text()) + assert saved["token"] == "ya29.refreshed" + + +def test_bridge_exits_on_missing_token(bridge_module): + """Missing token file causes exit with code 1.""" with pytest.raises(SystemExit): - google_api_module.get_credentials() - - err = capsys.readouterr().err - assert "missing google workspace scopes" in err.lower() - assert "gmail.send" in err + bridge_module.get_valid_token() -def test_get_credentials_accepts_full_scope_token(google_api_module): - FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True) - _write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES)) +def test_bridge_main_injects_token_env(bridge_module, tmp_path): + """main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env.""" + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + token_path = bridge_module.get_token_path() + _write_token(token_path, token="ya29.injected", expiry=future) - creds = google_api_module.get_credentials() + captured = {} - assert creds is FakeCredentialsFactory.creds + def capture_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env", {}) + return MagicMock(returncode=0) + + with patch.object(sys, "argv", ["gws_bridge.py", "gmail", "+triage"]): + with patch.object(subprocess, "run", side_effect=capture_run): + with pytest.raises(SystemExit): + bridge_module.main() + + assert captured["env"]["GOOGLE_WORKSPACE_CLI_TOKEN"] == "ya29.injected" + assert captured["cmd"] == ["gws", "gmail", "+triage"] + + +def test_api_calendar_list_uses_agenda_by_default(api_module): + """calendar list without dates uses +agenda helper.""" + captured = {} + + def capture_run(cmd, **kwargs): + captured["cmd"] = cmd + return MagicMock(returncode=0) + + args = api_module.argparse.Namespace( + start="", end="", max=25, calendar="primary", func=api_module.calendar_list, + ) + + with patch.object(subprocess, "run", side_effect=capture_run): + with pytest.raises(SystemExit): + api_module.calendar_list(args) + + gws_args = captured["cmd"][2:] # skip python + bridge path + assert "calendar" in gws_args + assert "+agenda" in gws_args + assert "--days" in gws_args + + +def test_api_calendar_list_respects_date_range(api_module): + """calendar list with --start/--end uses raw events list API.""" + captured = {} + + def capture_run(cmd, **kwargs): + captured["cmd"] = cmd + return MagicMock(returncode=0) + + args = api_module.argparse.Namespace( + start="2026-04-01T00:00:00Z", + end="2026-04-07T23:59:59Z", + max=25, + calendar="primary", + func=api_module.calendar_list, + ) + + with patch.object(subprocess, "run", side_effect=capture_run): + with pytest.raises(SystemExit): + api_module.calendar_list(args) + + gws_args = captured["cmd"][2:] + assert "events" in gws_args + assert "list" in gws_args + params_idx = gws_args.index("--params") + params = json.loads(gws_args[params_idx + 1]) + assert params["timeMin"] == "2026-04-01T00:00:00Z" + assert params["timeMax"] == "2026-04-07T23:59:59Z" From e9168f917e49829ab1e327bfbd7b868933e63077 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:22:17 -0700 Subject: [PATCH 28/51] fix: handle HTTP errors gracefully in gws_bridge token refresh Instead of crashing with a raw urllib traceback on refresh failure, print a clean error message and suggest re-running setup.py. --- .../google-workspace/scripts/gws_bridge.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py index 7ee74fc9..adecd33a 100755 --- a/skills/productivity/google-workspace/scripts/gws_bridge.py +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -21,6 +21,7 @@ def get_token_path() -> Path: def refresh_token(token_data: dict) -> dict: """Refresh the access token using the refresh token.""" + import urllib.error import urllib.parse import urllib.request @@ -32,8 +33,14 @@ def refresh_token(token_data: dict) -> dict: }).encode() req = urllib.request.Request(token_data["token_uri"], data=params) - with urllib.request.urlopen(req) as resp: - result = json.loads(resp.read()) + try: + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + print(f"ERROR: Token refresh failed (HTTP {e.code}): {body}", file=sys.stderr) + print("Re-run setup.py to re-authenticate.", file=sys.stderr) + sys.exit(1) token_data["token"] = result["access_token"] token_data["expiry"] = datetime.fromtimestamp( From 97308707e91a34310531661cec89a23b11b14f2c Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:28:49 -0700 Subject: [PATCH 29/51] fix: insert static fallback when compression summary fails When _generate_summary() failed (no provider, timeout, model error), the compressor silently dropped all middle turns with just a debug log. The agent would then see head + tail with no explanation of the gap, causing total context amnesia (generic greetings instead of continuing the conversation). Now generates a static fallback marker that tells the model context was lost and to continue from the recent tail messages. The fallback flows through the same role-alternation logic as a real summary so message structure stays valid. --- agent/context_compressor.py | 62 +++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index c61cf2c5..eba2de3f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -691,33 +691,43 @@ Write only the summary body. Do not include any preamble or prefix.""" ) compressed.append(msg) - _merge_summary_into_tail = False - if summary: - last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user" - first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" - # Pick a role that avoids consecutive same-role with both neighbors. - # Priority: avoid colliding with head (already committed), then tail. - if last_head_role in ("assistant", "tool"): - summary_role = "user" - else: - summary_role = "assistant" - # If the chosen role collides with the tail AND flipping wouldn't - # collide with the head, flip it. - if summary_role == first_tail_role: - flipped = "assistant" if summary_role == "user" else "user" - if flipped != last_head_role: - summary_role = flipped - else: - # Both roles would create consecutive same-role messages - # (e.g. head=assistant, tail=user — neither role works). - # Merge the summary into the first tail message instead - # of inserting a standalone message that breaks alternation. - _merge_summary_into_tail = True - if not _merge_summary_into_tail: - compressed.append({"role": summary_role, "content": summary}) - else: + # If LLM summary failed, insert a static fallback so the model + # knows context was lost rather than silently dropping everything. + if not summary: if not self.quiet_mode: - logger.debug("No summary model available — middle turns dropped without summary") + logger.warning("Summary generation failed — inserting static fallback context marker") + n_dropped = compress_end - compress_start + summary = ( + f"{SUMMARY_PREFIX}\n" + f"Summary generation was unavailable. {n_dropped} conversation turns were " + f"removed to free context space but could not be summarized. The removed " + f"turns contained earlier work in this session. Continue based on the " + f"recent messages below and the current state of any files or resources." + ) + + _merge_summary_into_tail = False + last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user" + first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" + # Pick a role that avoids consecutive same-role with both neighbors. + # Priority: avoid colliding with head (already committed), then tail. + if last_head_role in ("assistant", "tool"): + summary_role = "user" + else: + summary_role = "assistant" + # If the chosen role collides with the tail AND flipping wouldn't + # collide with the head, flip it. + if summary_role == first_tail_role: + flipped = "assistant" if summary_role == "user" else "user" + if flipped != last_head_role: + summary_role = flipped + else: + # Both roles would create consecutive same-role messages + # (e.g. head=assistant, tail=user — neither role works). + # Merge the summary into the first tail message instead + # of inserting a standalone message that breaks alternation. + _merge_summary_into_tail = True + if not _merge_summary_into_tail: + compressed.append({"role": summary_role, "content": summary}) for i in range(compress_end, n_messages): msg = messages[i].copy() From c3854e0f852eaa8b326dfb406f06ac990d048f69 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:52:58 -0700 Subject: [PATCH 30/51] fix: /browser connect auto-launch uses dedicated profile dir Chrome auto-launch now passes --user-data-dir, --no-first-run, and --no-default-browser-check so the debug instance doesn't conflict with an already-running Chrome using the default profile. The profile dir lives at {hermes_home}/chrome-debug/. Also updates the fallback manual instructions to include the same flags and removes the stale 'close existing Chrome windows' hint. --- cli.py | 40 +++++++++++++++++++++++---- tests/cli/test_cli_browser_connect.py | 15 ++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index 30e43b6e..1ca28606 100644 --- a/cli.py +++ b/cli.py @@ -4988,6 +4988,9 @@ class HermesCLI: def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. + Uses a dedicated user-data-dir so the debug instance doesn't conflict + with an already-running Chrome using the default profile. + Returns True if a launch command was executed (doesn't guarantee success). """ import subprocess as _sp @@ -4997,10 +5000,20 @@ class HermesCLI: if not candidates: return False + # Dedicated profile dir so debug Chrome won't collide with normal Chrome + data_dir = str(_hermes_home / "chrome-debug") + os.makedirs(data_dir, exist_ok=True) + chrome = candidates[0] try: _sp.Popen( - [chrome, f"--remote-debugging-port={port}"], + [ + chrome, + f"--remote-debugging-port={port}", + f"--user-data-dir={data_dir}", + "--no-first-run", + "--no-default-browser-check", + ], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True, # detach from terminal @@ -5075,18 +5088,33 @@ class HermesCLI: print(f" ✓ Chrome launched and listening on port {_port}") else: print(f" ⚠ Chrome launched but port {_port} isn't responding yet") - print(" You may need to close existing Chrome windows first and retry") + print(" Try again in a few seconds — the debug instance may still be starting") else: print(" ⚠ Could not auto-launch Chrome") # Show manual instructions as fallback + _data_dir = str(_hermes_home / "chrome-debug") sys_name = _plat.system() if sys_name == "Darwin": - chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222' + chrome_cmd = ( + 'open -a "Google Chrome" --args' + f" --remote-debugging-port=9222" + f' --user-data-dir="{_data_dir}"' + " --no-first-run --no-default-browser-check" + ) elif sys_name == "Windows": - chrome_cmd = 'chrome.exe --remote-debugging-port=9222' + chrome_cmd = ( + f'chrome.exe --remote-debugging-port=9222' + f' --user-data-dir="{_data_dir}"' + f" --no-first-run --no-default-browser-check" + ) else: - chrome_cmd = "google-chrome --remote-debugging-port=9222" - print(f" Launch Chrome manually: {chrome_cmd}") + chrome_cmd = ( + f"google-chrome --remote-debugging-port=9222" + f' --user-data-dir="{_data_dir}"' + f" --no-first-run --no-default-browser-check" + ) + print(f" Launch Chrome manually:") + print(f" {chrome_cmd}") else: print(f" ⚠ Port {_port} is not reachable at {cdp_url}") diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py index f01475bf..e123afe1 100644 --- a/tests/cli/test_cli_browser_connect.py +++ b/tests/cli/test_cli_browser_connect.py @@ -6,6 +6,17 @@ from unittest.mock import patch from cli import HermesCLI +def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port): + """Verify the auto-launch command has all required flags.""" + assert cmd[0] == expected_chrome + assert f"--remote-debugging-port={expected_port}" in cmd + assert "--no-first-run" in cmd + assert "--no-default-browser-check" in cmd + user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")] + assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag" + assert "chrome-debug" in user_data_args[0] + + class TestChromeDebugLaunch: def test_windows_launch_uses_browser_found_on_path(self): captured = {} @@ -20,7 +31,7 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True - assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"] + _assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333) assert captured["kwargs"]["start_new_session"] is True def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch): @@ -43,4 +54,4 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True - assert captured["cmd"] == [installed, "--remote-debugging-port=9222"] + _assert_chrome_debug_cmd(captured["cmd"], installed, 9222) From 49d8c9557f143bcde2695f5dfb666532e7100fea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:54:07 -0700 Subject: [PATCH 31/51] fix: cleanup_all_camofox_sessions respects managed persistence (#6820) When managed_persistence is enabled, cleanup_all now only clears local tracking state without sending DELETE requests to the Camofox server. This prevents persistent browser profiles (cookies, logins, localStorage) from being destroyed during process-wide cleanup. Ephemeral sessions still get full server-side deletion as before. --- tools/browser_camofox.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 3a305bbc..d0e268a4 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -594,13 +594,20 @@ def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: # --------------------------------------------------------------------------- def cleanup_all_camofox_sessions() -> None: - """Close all active camofox sessions.""" + """Close all active camofox sessions. + + When managed persistence is enabled, only clears local tracking state + without destroying server-side browser profiles (cookies, logins, etc. + must survive). Ephemeral sessions are fully deleted on the server. + """ + managed = _managed_persistence_enabled() with _sessions_lock: sessions = list(_sessions.items()) - for task_id, session in sessions: - try: - _delete(f"/sessions/{session['user_id']}") - except Exception: - pass + if not managed: + for _task_id, session in sessions: + try: + _delete(f"/sessions/{session['user_id']}") + except Exception: + pass with _sessions_lock: _sessions.clear() From f91fffbe3360d019cf8e6af38e4820cda8e90bc6 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:54:26 -0700 Subject: [PATCH 32/51] Revert "fix: /browser connect auto-launch uses dedicated profile dir" This reverts commit c3854e0f852eaa8b326dfb406f06ac990d048f69. --- cli.py | 40 ++++----------------------- tests/cli/test_cli_browser_connect.py | 15 ++-------- 2 files changed, 8 insertions(+), 47 deletions(-) diff --git a/cli.py b/cli.py index 1ca28606..30e43b6e 100644 --- a/cli.py +++ b/cli.py @@ -4988,9 +4988,6 @@ class HermesCLI: def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. - Uses a dedicated user-data-dir so the debug instance doesn't conflict - with an already-running Chrome using the default profile. - Returns True if a launch command was executed (doesn't guarantee success). """ import subprocess as _sp @@ -5000,20 +4997,10 @@ class HermesCLI: if not candidates: return False - # Dedicated profile dir so debug Chrome won't collide with normal Chrome - data_dir = str(_hermes_home / "chrome-debug") - os.makedirs(data_dir, exist_ok=True) - chrome = candidates[0] try: _sp.Popen( - [ - chrome, - f"--remote-debugging-port={port}", - f"--user-data-dir={data_dir}", - "--no-first-run", - "--no-default-browser-check", - ], + [chrome, f"--remote-debugging-port={port}"], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True, # detach from terminal @@ -5088,33 +5075,18 @@ class HermesCLI: print(f" ✓ Chrome launched and listening on port {_port}") else: print(f" ⚠ Chrome launched but port {_port} isn't responding yet") - print(" Try again in a few seconds — the debug instance may still be starting") + print(" You may need to close existing Chrome windows first and retry") else: print(" ⚠ Could not auto-launch Chrome") # Show manual instructions as fallback - _data_dir = str(_hermes_home / "chrome-debug") sys_name = _plat.system() if sys_name == "Darwin": - chrome_cmd = ( - 'open -a "Google Chrome" --args' - f" --remote-debugging-port=9222" - f' --user-data-dir="{_data_dir}"' - " --no-first-run --no-default-browser-check" - ) + chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222' elif sys_name == "Windows": - chrome_cmd = ( - f'chrome.exe --remote-debugging-port=9222' - f' --user-data-dir="{_data_dir}"' - f" --no-first-run --no-default-browser-check" - ) + chrome_cmd = 'chrome.exe --remote-debugging-port=9222' else: - chrome_cmd = ( - f"google-chrome --remote-debugging-port=9222" - f' --user-data-dir="{_data_dir}"' - f" --no-first-run --no-default-browser-check" - ) - print(f" Launch Chrome manually:") - print(f" {chrome_cmd}") + chrome_cmd = "google-chrome --remote-debugging-port=9222" + print(f" Launch Chrome manually: {chrome_cmd}") else: print(f" ⚠ Port {_port} is not reachable at {cdp_url}") diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py index e123afe1..f01475bf 100644 --- a/tests/cli/test_cli_browser_connect.py +++ b/tests/cli/test_cli_browser_connect.py @@ -6,17 +6,6 @@ from unittest.mock import patch from cli import HermesCLI -def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port): - """Verify the auto-launch command has all required flags.""" - assert cmd[0] == expected_chrome - assert f"--remote-debugging-port={expected_port}" in cmd - assert "--no-first-run" in cmd - assert "--no-default-browser-check" in cmd - user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")] - assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag" - assert "chrome-debug" in user_data_args[0] - - class TestChromeDebugLaunch: def test_windows_launch_uses_browser_found_on_path(self): captured = {} @@ -31,7 +20,7 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True - _assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333) + assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"] assert captured["kwargs"]["start_new_session"] is True def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch): @@ -54,4 +43,4 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True - _assert_chrome_debug_cmd(captured["cmd"], installed, 9222) + assert captured["cmd"] == [installed, "--remote-debugging-port=9222"] From 6b437f7934e568b587f57c4731a6e93668f27343 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:55:45 -0700 Subject: [PATCH 33/51] fix: /browser connect auto-launch uses dedicated profile dir (#6821) Chrome auto-launch now passes --user-data-dir, --no-first-run, and --no-default-browser-check so the debug instance doesn't conflict with an already-running Chrome using the default profile. The profile dir lives at {hermes_home}/chrome-debug/. Also updates the fallback manual instructions to include the same flags and removes the stale 'close existing Chrome windows' hint. --- cli.py | 40 +++++++++++++++++++++++---- tests/cli/test_cli_browser_connect.py | 15 ++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index 30e43b6e..1ca28606 100644 --- a/cli.py +++ b/cli.py @@ -4988,6 +4988,9 @@ class HermesCLI: def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. + Uses a dedicated user-data-dir so the debug instance doesn't conflict + with an already-running Chrome using the default profile. + Returns True if a launch command was executed (doesn't guarantee success). """ import subprocess as _sp @@ -4997,10 +5000,20 @@ class HermesCLI: if not candidates: return False + # Dedicated profile dir so debug Chrome won't collide with normal Chrome + data_dir = str(_hermes_home / "chrome-debug") + os.makedirs(data_dir, exist_ok=True) + chrome = candidates[0] try: _sp.Popen( - [chrome, f"--remote-debugging-port={port}"], + [ + chrome, + f"--remote-debugging-port={port}", + f"--user-data-dir={data_dir}", + "--no-first-run", + "--no-default-browser-check", + ], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True, # detach from terminal @@ -5075,18 +5088,33 @@ class HermesCLI: print(f" ✓ Chrome launched and listening on port {_port}") else: print(f" ⚠ Chrome launched but port {_port} isn't responding yet") - print(" You may need to close existing Chrome windows first and retry") + print(" Try again in a few seconds — the debug instance may still be starting") else: print(" ⚠ Could not auto-launch Chrome") # Show manual instructions as fallback + _data_dir = str(_hermes_home / "chrome-debug") sys_name = _plat.system() if sys_name == "Darwin": - chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222' + chrome_cmd = ( + 'open -a "Google Chrome" --args' + f" --remote-debugging-port=9222" + f' --user-data-dir="{_data_dir}"' + " --no-first-run --no-default-browser-check" + ) elif sys_name == "Windows": - chrome_cmd = 'chrome.exe --remote-debugging-port=9222' + chrome_cmd = ( + f'chrome.exe --remote-debugging-port=9222' + f' --user-data-dir="{_data_dir}"' + f" --no-first-run --no-default-browser-check" + ) else: - chrome_cmd = "google-chrome --remote-debugging-port=9222" - print(f" Launch Chrome manually: {chrome_cmd}") + chrome_cmd = ( + f"google-chrome --remote-debugging-port=9222" + f' --user-data-dir="{_data_dir}"' + f" --no-first-run --no-default-browser-check" + ) + print(f" Launch Chrome manually:") + print(f" {chrome_cmd}") else: print(f" ⚠ Port {_port} is not reachable at {cdp_url}") diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py index f01475bf..e123afe1 100644 --- a/tests/cli/test_cli_browser_connect.py +++ b/tests/cli/test_cli_browser_connect.py @@ -6,6 +6,17 @@ from unittest.mock import patch from cli import HermesCLI +def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port): + """Verify the auto-launch command has all required flags.""" + assert cmd[0] == expected_chrome + assert f"--remote-debugging-port={expected_port}" in cmd + assert "--no-first-run" in cmd + assert "--no-default-browser-check" in cmd + user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")] + assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag" + assert "chrome-debug" in user_data_args[0] + + class TestChromeDebugLaunch: def test_windows_launch_uses_browser_found_on_path(self): captured = {} @@ -20,7 +31,7 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True - assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"] + _assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333) assert captured["kwargs"]["start_new_session"] is True def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch): @@ -43,4 +54,4 @@ class TestChromeDebugLaunch: patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True - assert captured["cmd"] == [installed, "--remote-debugging-port=9222"] + _assert_chrome_debug_cmd(captured["cmd"], installed, 9222) From aed9b90ae31a5c3ef608d5369614f4ac84ce45d5 Mon Sep 17 00:00:00 2001 From: dangelo352 Date: Thu, 9 Apr 2026 15:06:03 -0700 Subject: [PATCH 34/51] fix(stream_consumer): handle overflow when no message exists yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overflow split loop required _message_id to be set, but on the first streamed message (or after a segment break) _message_id is None. Oversized text fell through to _send_or_edit → adapter.send(), which split internally — but subsequent edits hit Telegram's 'message too long' and were silently truncated with '…', cutting off the response. Add a new code path for the _message_id is None case that uses truncate_message() (same as the non-streaming path) to split with proper word/code-fence boundaries and chunk indicators. Each chunk is sent as a new message via _send_new_chunk(). Properly handles got_done (returns immediately after sending chunks instead of continuing into an infinite loop) and got_segment_break. Original cherry-picked from PR #6816 by dangelo352. Fixes silent message truncation on Telegram for long streamed responses. --- gateway/stream_consumer.py | 57 +++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index cc3d64d1..ce6820ab 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -136,7 +136,34 @@ class GatewayStreamConsumer: if should_edit and self._accumulated: # Split overflow: if accumulated text exceeds the platform - # limit, finalize the current message and start a new one. + # limit, split into properly sized chunks. + if ( + len(self._accumulated) > _safe_limit + and self._message_id is None + ): + # No existing message to edit (first message or after a + # segment break). Use truncate_message — the same + # helper the non-streaming path uses — to split with + # proper word/code-fence boundaries and chunk + # indicators like "(1/2)". + chunks = self.adapter.truncate_message( + self._accumulated, _safe_limit + ) + for chunk in chunks: + await self._send_new_chunk(chunk, self._message_id) + self._accumulated = "" + self._last_sent_text = "" + self._last_edit_time = time.monotonic() + if got_done: + return + if got_segment_break: + self._message_id = None + self._fallback_final_send = False + self._fallback_prefix = "" + continue + + # Existing message: edit it with the first chunk, then + # start a new message for the overflow remainder. while ( len(self._accumulated) > _safe_limit and self._message_id is not None @@ -226,6 +253,34 @@ class GatewayStreamConsumer: # Strip trailing whitespace/newlines but preserve leading content return cleaned.rstrip() + async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]: + """Send a new message chunk, optionally threaded to a previous message. + + Returns the message_id so callers can thread subsequent chunks. + """ + text = self._clean_for_display(text) + if not text.strip(): + return reply_to_id + try: + meta = dict(self.metadata) if self.metadata else {} + result = await self.adapter.send( + chat_id=self.chat_id, + content=text, + reply_to=reply_to_id, + metadata=meta, + ) + if result.success and result.message_id: + self._message_id = str(result.message_id) + self._already_sent = True + self._last_sent_text = text + return str(result.message_id) + else: + self._edit_supported = False + return reply_to_id + except Exception as e: + logger.error("Stream send chunk error: %s", e) + return reply_to_id + def _visible_prefix(self) -> str: """Return the visible text already shown in the streamed message.""" prefix = self._last_sent_text or "" From 1789c2699afb00f84e70b15a4dbbb6092a357ad1 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:18:42 -0700 Subject: [PATCH 35/51] feat(nix): shared-state permission model for interactive CLI users (#6796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(nix): shared-state permission model for interactive CLI users Enable interactive CLI users in the hermes group to share full read-write state (sessions, memories, logs, cron) with the gateway service via a setgid + group-writable permission model. Changes: nix/nixosModules.nix: - Directories use setgid 2770 (was 0750) so new files inherit the hermes group. home/ stays 0750 (no interactive write needed). - Activation script creates HERMES_HOME subdirs (cron, sessions, logs, memories) — previously Python created them but managed mode now skips mkdir. - Activation migrates existing runtime files to group-writable (chmod g+rw). Nix-managed files (config.yaml, .env, .managed) stay 0640/0644. - Gateway systemd unit gets UMask=0007 so files it creates are 0660. hermes_cli/config.py: - ensure_hermes_home() splits into managed/unmanaged paths. Managed mode verifies dirs exist (raises RuntimeError if not) instead of creating them. Scoped umask(0o007) ensures SOUL.md is created as 0660. hermes_logging.py: - _ManagedRotatingFileHandler subclass applies chmod 0660 after log rotation in managed mode. RotatingFileHandler.doRollover() creates new files via open() which uses the process umask (0022 → 0644), not the scoped umask from ensure_hermes_home(). Verified with a 13-subtest NixOS VM integration test covering setgid, interactive writes, file ownership, migration, and gateway coexistence. Refs: #6044 * Fix managed log file mode on initial open Co-authored-by: Siddharth Balyan * refactor: simplify managed file handler and merge activation loops - Cache is_managed() result in handler __init__ instead of lazy-importing on every _open()/_chmod_if_managed() call. Avoids repeated stat+env checks on log rotation. - Merge two for-loops over the same subdir list in activation script into a single loop (mkdir + chown + chmod + find in one pass). --------- Co-authored-by: Cursor Agent Co-authored-by: Siddharth Balyan --- hermes_cli/config.py | 40 ++++++++++++++++++++++---- hermes_logging.py | 35 ++++++++++++++++++++++- nix/nixosModules.nix | 30 +++++++++++++++++--- tests/test_hermes_logging.py | 54 ++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a981b1bb..80dce6c0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -197,14 +197,44 @@ def _ensure_default_soul_md(home: Path) -> None: def ensure_hermes_home(): - """Ensure ~/.hermes directory structure exists with secure permissions.""" + """Ensure ~/.hermes directory structure exists with secure permissions. + + In managed mode (NixOS), dirs are created by the activation script with + setgid + group-writable (2770). We skip mkdir and set umask(0o007) so + any files created (e.g. SOUL.md) are group-writable (0660). + """ home = get_hermes_home() - home.mkdir(parents=True, exist_ok=True) - _secure_dir(home) + if is_managed(): + old_umask = os.umask(0o007) + try: + _ensure_hermes_home_managed(home) + finally: + os.umask(old_umask) + else: + home.mkdir(parents=True, exist_ok=True) + _secure_dir(home) + for subdir in ("cron", "sessions", "logs", "memories"): + d = home / subdir + d.mkdir(parents=True, exist_ok=True) + _secure_dir(d) + _ensure_default_soul_md(home) + + +def _ensure_hermes_home_managed(home: Path): + """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" + if not home.is_dir(): + raise RuntimeError( + f"HERMES_HOME {home} does not exist. " + "Run 'sudo nixos-rebuild switch' first." + ) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir - d.mkdir(parents=True, exist_ok=True) - _secure_dir(d) + if not d.is_dir(): + raise RuntimeError( + f"{d} does not exist. " + "Run 'sudo nixos-rebuild switch' first." + ) + # Inside umask(0o007) scope — SOUL.md will be created as 0660 _ensure_default_soul_md(home) diff --git a/hermes_logging.py b/hermes_logging.py index 6d8f4fa7..5d71590c 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -13,6 +13,7 @@ secrets are never written to disk. """ import logging +import os from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Optional @@ -177,6 +178,38 @@ def setup_verbose_logging() -> None: # Internal helpers # --------------------------------------------------------------------------- +class _ManagedRotatingFileHandler(RotatingFileHandler): + """RotatingFileHandler that ensures group-writable perms in managed mode. + + In managed mode (NixOS), the stateDir uses setgid (2770) so new files + inherit the hermes group. However, both _open() (initial creation) and + doRollover() create files via open(), which uses the process umask — + typically 0022, producing 0644. This subclass applies chmod 0660 after + both operations so the gateway and interactive users can share log files. + """ + + def __init__(self, *args, **kwargs): + from hermes_cli.config import is_managed + self._managed = is_managed() + super().__init__(*args, **kwargs) + + def _chmod_if_managed(self): + if self._managed: + try: + os.chmod(self.baseFilename, 0o660) + except OSError: + pass + + def _open(self): + stream = super()._open() + self._chmod_if_managed() + return stream + + def doRollover(self): + super().doRollover() + self._chmod_if_managed() + + def _add_rotating_handler( logger: logging.Logger, path: Path, @@ -198,7 +231,7 @@ def _add_rotating_handler( return # already attached path.parent.mkdir(parents=True, exist_ok=True) - handler = RotatingFileHandler( + handler = _ManagedRotatingFileHandler( str(path), maxBytes=max_bytes, backupCount=backup_count, ) handler.setLevel(level) diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 948f7df8..b1be031d 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -560,10 +560,14 @@ # ── Directories ─────────────────────────────────────────────────── { systemd.tmpfiles.rules = [ - "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/cron 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -" ]; } @@ -575,7 +579,21 @@ mkdir -p ${cfg.stateDir}/home mkdir -p ${cfg.workingDirectory} chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory} - chmod 0750 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory} + chmod 2770 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.workingDirectory} + chmod 0750 ${cfg.stateDir}/home + + # Create subdirs, set setgid + group-writable, migrate existing files. + # Nix-managed files (config.yaml, .env, .managed) stay 0640/0644. + find ${cfg.stateDir}/.hermes -maxdepth 1 \ + \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \ + -exec chmod g+rw {} + 2>/dev/null || true + for _subdir in cron sessions logs memories; do + mkdir -p "${cfg.stateDir}/.hermes/$_subdir" + chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir" + chmod 2770 "${cfg.stateDir}/.hermes/$_subdir" + find "${cfg.stateDir}/.hermes/$_subdir" -type f \ + -exec chmod g+rw {} + 2>/dev/null || true + done # Merge Nix settings into existing config.yaml. # Preserves user-added keys (skills, streaming, etc.); Nix keys win. @@ -662,6 +680,10 @@ HERMES_NIX_ENV_EOF Restart = cfg.restart; RestartSec = cfg.restartSec; + # Shared-state: files created by the gateway should be group-writable + # so interactive users in the hermes group can read/write them. + UMask = "0007"; + # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 5b40e632..80a23dc6 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -2,6 +2,7 @@ import logging import os +import stat from logging.handlers import RotatingFileHandler from pathlib import Path from unittest.mock import patch @@ -300,6 +301,59 @@ class TestAddRotatingHandler: logger.removeHandler(h) h.close() + def test_managed_mode_initial_open_sets_group_writable(self, tmp_path): + log_path = tmp_path / "managed-open.log" + logger = logging.getLogger("_test_rotating_managed_open") + formatter = logging.Formatter("%(message)s") + + old_umask = os.umask(0o022) + try: + with patch("hermes_cli.config.is_managed", return_value=True): + hermes_logging._add_rotating_handler( + logger, log_path, + level=logging.INFO, max_bytes=1024, backup_count=1, + formatter=formatter, + ) + finally: + os.umask(old_umask) + + assert log_path.exists() + assert stat.S_IMODE(log_path.stat().st_mode) == 0o660 + + for h in list(logger.handlers): + if isinstance(h, RotatingFileHandler): + logger.removeHandler(h) + h.close() + + def test_managed_mode_rollover_sets_group_writable(self, tmp_path): + log_path = tmp_path / "managed-rollover.log" + logger = logging.getLogger("_test_rotating_managed_rollover") + formatter = logging.Formatter("%(message)s") + + old_umask = os.umask(0o022) + try: + with patch("hermes_cli.config.is_managed", return_value=True): + hermes_logging._add_rotating_handler( + logger, log_path, + level=logging.INFO, max_bytes=1, backup_count=1, + formatter=formatter, + ) + handler = next( + h for h in logger.handlers if isinstance(h, RotatingFileHandler) + ) + logger.info("a" * 256) + handler.flush() + finally: + os.umask(old_umask) + + assert log_path.exists() + assert stat.S_IMODE(log_path.stat().st_mode) == 0o660 + + for h in list(logger.handlers): + if isinstance(h, RotatingFileHandler): + logger.removeHandler(h) + h.close() + class TestReadLoggingConfig: """_read_logging_config() reads from config.yaml.""" From e053433c844133340c367032e2461b92dd14b2ad Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Fri, 10 Apr 2026 01:16:11 +0300 Subject: [PATCH 36/51] fix(error_classifier): disambiguate usage-limit patterns in _classify_by_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _classify_by_message had no handling for _USAGE_LIMIT_PATTERNS, so messages like 'usage limit exceeded, try again in 5 minutes' arriving without an HTTP status code fell through to FailoverReason.unknown instead of rate_limit. Apply the same billing/rate-limit disambiguation that _classify_402 already uses: USAGE_LIMIT_PATTERNS + transient signal → rate_limit, USAGE_LIMIT_PATTERNS alone → billing. Add 4 tests covering the no-status-code usage-limit path. --- agent/error_classifier.py | 21 ++++++++++++++++++ tests/agent/test_error_classifier.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 0f145011..1f6b48a0 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -677,6 +677,27 @@ def _classify_by_message( should_compress=True, ) + # Usage-limit patterns need the same disambiguation as 402: some providers + # surface "usage limit" errors without an HTTP status code. A transient + # signal ("try again", "resets at", …) means it's a periodic quota, not + # billing exhaustion. + has_usage_limit = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) + if has_usage_limit: + has_transient_signal = any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS) + if has_transient_signal: + return result_fn( + FailoverReason.rate_limit, + retryable=True, + should_rotate_credential=True, + should_fallback=True, + ) + return result_fn( + FailoverReason.billing, + retryable=False, + should_rotate_credential=True, + should_fallback=True, + ) + # Billing patterns if any(p in error_msg for p in _BILLING_PATTERNS): return result_fn( diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index c5973558..44e891f0 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -480,6 +480,39 @@ class TestClassifyApiError: result = classify_api_error(e) assert result.reason == FailoverReason.context_overflow + # ── Message-only usage limit disambiguation (no status code) ── + + def test_message_usage_limit_transient_is_rate_limit(self): + """'usage limit' + 'try again' with no status code → rate_limit, not billing.""" + e = Exception("usage limit exceeded, try again in 5 minutes") + result = classify_api_error(e) + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + assert result.should_rotate_credential is True + assert result.should_fallback is True + + def test_message_usage_limit_no_retry_signal_is_billing(self): + """'usage limit' with no transient signal and no status code → billing.""" + e = Exception("usage limit reached") + result = classify_api_error(e) + assert result.reason == FailoverReason.billing + assert result.retryable is False + assert result.should_rotate_credential is True + + def test_message_quota_with_reset_window_is_rate_limit(self): + """'quota' + 'resets at' with no status code → rate_limit.""" + e = Exception("quota exceeded, resets at midnight UTC") + result = classify_api_error(e) + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + + def test_message_limit_exceeded_with_wait_is_rate_limit(self): + """'limit exceeded' + 'wait' with no status code → rate_limit.""" + e = Exception("key limit exceeded, please wait before retrying") + result = classify_api_error(e) + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + # ── Unknown / fallback ── def test_generic_exception_is_unknown(self): From e79cc8898517cf4b06b0490b828e79a1ee1ede59 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:48:25 +0200 Subject: [PATCH 37/51] feat: add tested Termux install path and EOF-aware gh auth --- README.md | 4 +- constraints-termux.txt | 15 ++ hermes_cli/doctor.py | 28 ++- pyproject.toml | 11 + scripts/install.sh | 187 +++++++++++++- tests/hermes_cli/test_doctor.py | 17 ++ tests/tools/test_process_registry.py | 58 +++++ .../tools/test_terminal_tool_pty_fallback.py | 91 +++++++ tools/process_registry.py | 33 ++- tools/terminal_tool.py | 30 ++- website/docs/getting-started/installation.md | 24 +- website/docs/getting-started/quickstart.md | 6 +- website/docs/getting-started/termux.md | 228 ++++++++++++++++++ website/docs/reference/faq.md | 14 ++ website/sidebars.ts | 1 + 15 files changed, 724 insertions(+), 23 deletions(-) create mode 100644 constraints-termux.txt create mode 100644 tests/tools/test_terminal_tool_pty_fallback.py create mode 100644 website/docs/getting-started/termux.md diff --git a/README.md b/README.md index fde4cae3..b77cd620 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,10 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` -Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git. +Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you. +> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. +> > **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above. After installation: diff --git a/constraints-termux.txt b/constraints-termux.txt new file mode 100644 index 00000000..dcc1becf --- /dev/null +++ b/constraints-termux.txt @@ -0,0 +1,15 @@ +# Termux / Android dependency constraints for Hermes Agent. +# +# Usage: +# python -m pip install -e '.[termux]' -c constraints-termux.txt +# +# These pins keep the tested Android install path stable when upstream packages +# move faster than Termux-compatible wheels / sdists. + +ipython<10 +jedi>=0.18.1,<0.20 +parso>=0.8.4,<0.9 +stack-data>=0.6,<0.7 +pexpect>4.3,<5 +matplotlib-inline>=0.1.7,<0.2 +asttokens>=2.1,<3 diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 361e81d2..6bdfd123 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -54,6 +54,23 @@ _PROVIDER_ENV_HINTS = ( ) +def _is_termux() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + +def _python_install_cmd() -> str: + return "python -m pip install" if _is_termux() else "uv pip install" + + +def _system_package_install_cmd(pkg: str) -> str: + if _is_termux(): + return f"pkg install {pkg}" + if sys.platform == "darwin": + return f"brew install {pkg}" + return f"sudo apt install {pkg}" + + def _has_provider_env_config(content: str) -> bool: """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" return any(key in content for key in _PROVIDER_ENV_HINTS) @@ -200,7 +217,7 @@ def run_doctor(args): check_ok(name) except ImportError: check_fail(name, "(missing)") - issues.append(f"Install {name}: uv pip install {module}") + issues.append(f"Install {name}: {_python_install_cmd()} {module}") for module, name in optional_packages: try: @@ -503,7 +520,7 @@ def run_doctor(args): check_ok("ripgrep (rg)", "(faster file search)") else: check_warn("ripgrep (rg) not found", "(file search uses grep fallback)") - check_info("Install for faster search: sudo apt install ripgrep") + check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}") # Docker (optional) terminal_env = os.getenv("TERMINAL_ENV", "local") @@ -577,6 +594,8 @@ def run_doctor(args): check_warn("agent-browser not installed", "(run: npm install)") else: check_warn("Node.js not found", "(optional, needed for browser tools)") + if _is_termux(): + check_info("Install Node.js on Termux with: pkg install nodejs") # npm audit for all Node.js packages if shutil.which("npm"): @@ -739,8 +758,9 @@ def run_doctor(args): __import__("tinker_atropos") check_ok("tinker-atropos", "(RL training backend)") except ImportError: - check_warn("tinker-atropos found but not installed", "(run: uv pip install -e ./tinker-atropos)") - issues.append("Install tinker-atropos: uv pip install -e ./tinker-atropos") + install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos" + check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})") + issues.append(f"Install tinker-atropos: {install_cmd}") else: check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})") else: diff --git a/pyproject.toml b/pyproject.toml index de0e6106..8e637d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,17 @@ homeassistant = ["aiohttp>=3.9.0,<4"] sms = ["aiohttp>=3.9.0,<4"] acp = ["agent-client-protocol>=0.9.0,<1.0"] mistral = ["mistralai>=2.3.0,<3"] +termux = [ + # Tested Android / Termux path: keeps the core CLI feature-rich while + # avoiding extras that currently depend on non-Android wheels (notably + # faster-whisper -> ctranslate2 via the voice extra). + "hermes-agent[cron]", + "hermes-agent[cli]", + "hermes-agent[pty]", + "hermes-agent[mcp]", + "hermes-agent[honcho]", + "hermes-agent[acp]", +] dingtalk = ["dingtalk-stream>=0.1.0,<1"] feishu = ["lark-oapi>=1.5.3,<2"] rl = [ diff --git a/scripts/install.sh b/scripts/install.sh index c04dc4a9..2b52b039 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,8 +2,8 @@ # ============================================================================ # Hermes Agent Installer # ============================================================================ -# Installation script for Linux and macOS. -# Uses uv for fast Python provisioning and package management. +# Installation script for Linux, macOS, and Android/Termux. +# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux. # # Usage: # curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash @@ -117,6 +117,10 @@ log_error() { echo -e "${RED}✗${NC} $1" } +is_termux() { + [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] +} + # ============================================================================ # System detection # ============================================================================ @@ -124,12 +128,17 @@ log_error() { detect_os() { case "$(uname -s)" in Linux*) - OS="linux" - if [ -f /etc/os-release ]; then - . /etc/os-release - DISTRO="$ID" + if is_termux; then + OS="android" + DISTRO="termux" else - DISTRO="unknown" + OS="linux" + if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO="$ID" + else + DISTRO="unknown" + fi fi ;; Darwin*) @@ -158,6 +167,12 @@ detect_os() { # ============================================================================ install_uv() { + if [ "$DISTRO" = "termux" ]; then + log_info "Termux detected — using Python's stdlib venv + pip instead of uv" + UV_CMD="" + return 0 + fi + log_info "Checking for uv package manager..." # Check common locations for uv @@ -209,6 +224,25 @@ install_uv() { } check_python() { + if [ "$DISTRO" = "termux" ]; then + log_info "Checking Termux Python..." + if command -v python >/dev/null 2>&1; then + PYTHON_PATH="$(command -v python)" + if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then + PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) + log_success "Python found: $PYTHON_FOUND_VERSION" + return 0 + fi + fi + + log_info "Installing Python via pkg..." + pkg install -y python >/dev/null + PYTHON_PATH="$(command -v python)" + PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) + log_success "Python installed: $PYTHON_FOUND_VERSION" + return 0 + fi + log_info "Checking Python $PYTHON_VERSION..." # Let uv handle Python — it can download and manage Python versions @@ -243,6 +277,17 @@ check_git() { fi log_error "Git not found" + + if [ "$DISTRO" = "termux" ]; then + log_info "Installing Git via pkg..." + pkg install -y git >/dev/null + if command -v git >/dev/null 2>&1; then + GIT_VERSION=$(git --version | awk '{print $3}') + log_success "Git $GIT_VERSION installed" + return 0 + fi + fi + log_info "Please install Git:" case "$OS" in @@ -262,6 +307,9 @@ check_git() { ;; esac ;; + android) + log_info " pkg install git" + ;; macos) log_info " xcode-select --install" log_info " Or: brew install git" @@ -290,11 +338,29 @@ check_node() { return 0 fi - log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..." + if [ "$DISTRO" = "termux" ]; then + log_info "Node.js not found — installing Node.js via pkg..." + else + log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..." + fi install_node } install_node() { + if [ "$DISTRO" = "termux" ]; then + log_info "Installing Node.js via pkg..." + if pkg install -y nodejs >/dev/null; then + local installed_ver + installed_ver=$(node --version 2>/dev/null) + log_success "Node.js $installed_ver installed via pkg" + HAS_NODE=true + else + log_warn "Failed to install Node.js via pkg" + HAS_NODE=false + fi + return 0 + fi + local arch=$(uname -m) local node_arch case "$arch" in @@ -413,6 +479,30 @@ install_system_packages() { need_ffmpeg=true fi + # Termux always needs the Android build toolchain for the tested pip path, + # even when ripgrep/ffmpeg are already present. + if [ "$DISTRO" = "termux" ]; then + local termux_pkgs=(clang rust make pkg-config libffi openssl) + if [ "$need_ripgrep" = true ]; then + termux_pkgs+=("ripgrep") + fi + if [ "$need_ffmpeg" = true ]; then + termux_pkgs+=("ffmpeg") + fi + + log_info "Installing Termux packages: ${termux_pkgs[*]}" + if pkg install -y "${termux_pkgs[@]}" >/dev/null; then + [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" + [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" + log_success "Termux build dependencies installed" + return 0 + fi + + log_warn "Could not auto-install all Termux packages" + log_info "Install manually: pkg install ${termux_pkgs[*]}" + return 0 + fi + # Nothing to install — done if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then return 0 @@ -550,6 +640,9 @@ show_manual_install_hint() { *) log_info " Use your package manager or visit the project homepage" ;; esac ;; + android) + log_info " pkg install $pkg" + ;; macos) log_info " brew install $pkg" ;; esac } @@ -646,6 +739,19 @@ setup_venv() { return 0 fi + if [ "$DISTRO" = "termux" ]; then + log_info "Creating virtual environment with Termux Python..." + + if [ -d "venv" ]; then + log_info "Virtual environment already exists, recreating..." + rm -rf venv + fi + + "$PYTHON_PATH" -m venv venv + log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))" + return 0 + fi + log_info "Creating virtual environment with Python $PYTHON_VERSION..." if [ -d "venv" ]; then @@ -662,6 +768,46 @@ setup_venv() { install_deps() { log_info "Installing dependencies..." + if [ "$DISTRO" = "termux" ]; then + if [ "$USE_VENV" = true ]; then + export VIRTUAL_ENV="$INSTALL_DIR/venv" + PIP_PYTHON="$INSTALL_DIR/venv/bin/python" + else + PIP_PYTHON="$PYTHON_PATH" + fi + + if [ -z "${ANDROID_API_LEVEL:-}" ]; then + ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)" + if [ -z "$ANDROID_API_LEVEL" ]; then + ANDROID_API_LEVEL=24 + fi + export ANDROID_API_LEVEL + log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds" + fi + + "$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null + if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then + log_warn "Termux feature install (.[termux]) failed, trying base install..." + if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then + log_error "Package installation failed on Termux." + log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl" + log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt" + exit 1 + fi + fi + + log_success "Main package installed" + log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps." + + if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then + log_info "tinker-atropos submodule found — skipping install (optional, for RL training)" + log_info " To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\"" + fi + + log_success "All dependencies installed" + return 0 + fi + if [ "$USE_VENV" = true ]; then # Tell uv to install into our venv (no need to activate) export VIRTUAL_ENV="$INSTALL_DIR/venv" @@ -743,7 +889,11 @@ setup_path() { if [ ! -x "$HERMES_BIN" ]; then log_warn "hermes entry point not found at $HERMES_BIN" log_info "This usually means the pip install didn't complete successfully." - log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'" + if [ "$DISTRO" = "termux" ]; then + log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt" + else + log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'" + fi return 0 fi @@ -878,6 +1028,13 @@ install_node_deps() { return 0 fi + if [ "$DISTRO" = "termux" ]; then + log_info "Skipping automatic Node/browser dependency setup on Termux" + log_info "Browser automation and WhatsApp bridge are not part of the tested Termux install path yet." + log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install" + return 0 + fi + if [ -f "$INSTALL_DIR/package.json" ]; then log_info "Installing Node.js dependencies (browser tools)..." cd "$INSTALL_DIR" @@ -1090,7 +1247,11 @@ print_success() { echo -e "${YELLOW}" echo "Note: Node.js could not be installed automatically." echo "Browser tools need Node.js. Install manually:" - echo " https://nodejs.org/en/download/" + if [ "$DISTRO" = "termux" ]; then + echo " pkg install nodejs" + else + echo " https://nodejs.org/en/download/" + fi echo -e "${NC}" fi @@ -1099,7 +1260,11 @@ print_success() { echo -e "${YELLOW}" echo "Note: ripgrep (rg) was not found. File search will use" echo "grep as a fallback. For faster search in large codebases," - echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)" + if [ "$DISTRO" = "termux" ]; then + echo "install ripgrep: pkg install ripgrep" + else + echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)" + fi echo -e "${NC}" fi } diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index f30fb483..1378ad32 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -14,6 +14,23 @@ from hermes_cli import doctor as doctor_mod from hermes_cli.doctor import _has_provider_env_config +class TestDoctorPlatformHints: + def test_termux_package_hint(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + assert doctor._is_termux() is True + assert doctor._python_install_cmd() == "python -m pip install" + assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep" + + def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch): + monkeypatch.delenv("TERMUX_VERSION", raising=False) + monkeypatch.setenv("PREFIX", "/usr") + monkeypatch.setattr(sys, "platform", "linux") + assert doctor._is_termux() is False + assert doctor._python_install_cmd() == "uv pip install" + assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep" + + class TestProviderEnvDetection: def test_detects_openai_api_key(self): content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***" diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 44e3a1bd..6b2d3819 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -135,6 +135,64 @@ class TestReadLog: assert "5 lines" in result["showing"] +# ========================================================================= +# Stdin helpers +# ========================================================================= + +class TestStdinHelpers: + def test_close_stdin_not_found(self, registry): + result = registry.close_stdin("nonexistent") + assert result["status"] == "not_found" + + def test_close_stdin_pipe_mode(self, registry): + proc = MagicMock() + proc.stdin = MagicMock() + s = _make_session() + s.process = proc + registry._running[s.id] = s + + result = registry.close_stdin(s.id) + + proc.stdin.close.assert_called_once() + assert result["status"] == "ok" + + def test_close_stdin_pty_mode(self, registry): + pty = MagicMock() + s = _make_session() + s._pty = pty + registry._running[s.id] = s + + result = registry.close_stdin(s.id) + + pty.sendeof.assert_called_once() + assert result["status"] == "ok" + + def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path): + session = registry.spawn_local( + 'python3 -c "import sys; print(sys.stdin.read().strip())"', + cwd=str(tmp_path), + use_pty=False, + ) + + try: + time.sleep(0.5) + assert registry.submit_stdin(session.id, "hello")["status"] == "ok" + assert registry.close_stdin(session.id)["status"] == "ok" + + deadline = time.time() + 5 + while time.time() < deadline: + poll = registry.poll(session.id) + if poll["status"] == "exited": + assert poll["exit_code"] == 0 + assert "hello" in poll["output_preview"] + return + time.sleep(0.2) + + pytest.fail("process did not exit after stdin was closed") + finally: + registry.kill_process(session.id) + + # ========================================================================= # List sessions # ========================================================================= diff --git a/tests/tools/test_terminal_tool_pty_fallback.py b/tests/tools/test_terminal_tool_pty_fallback.py new file mode 100644 index 00000000..75ef7218 --- /dev/null +++ b/tests/tools/test_terminal_tool_pty_fallback.py @@ -0,0 +1,91 @@ +import json +from types import SimpleNamespace + +import tools.terminal_tool as terminal_tool_module +from tools import process_registry as process_registry_module + + +def _base_config(tmp_path): + return { + "env_type": "local", + "docker_image": "", + "singularity_image": "", + "modal_image": "", + "daytona_image": "", + "cwd": str(tmp_path), + "timeout": 30, + } + + +def test_command_requires_pipe_stdin_detects_gh_with_token(): + assert terminal_tool_module._command_requires_pipe_stdin( + "gh auth login --hostname github.com --git-protocol https --with-token" + ) is True + assert terminal_tool_module._command_requires_pipe_stdin( + "gh auth login --web" + ) is False + + +def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path): + config = _base_config(tmp_path) + dummy_env = SimpleNamespace(env={}) + captured = {} + + def fake_spawn_local(**kwargs): + captured.update(kwargs) + return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False) + + monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config) + monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True}) + monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local) + monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env) + monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0) + + try: + result = json.loads( + terminal_tool_module.terminal_tool( + command="gh auth login --hostname github.com --git-protocol https --with-token", + background=True, + pty=True, + ) + ) + finally: + terminal_tool_module._active_environments.pop("default", None) + terminal_tool_module._last_activity.pop("default", None) + + assert captured["use_pty"] is False + assert result["session_id"] == "proc_test" + assert "PTY disabled" in result["pty_note"] + + +def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path): + config = _base_config(tmp_path) + dummy_env = SimpleNamespace(env={}) + captured = {} + + def fake_spawn_local(**kwargs): + captured.update(kwargs) + return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False) + + monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config) + monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True}) + monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local) + monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env) + monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0) + + try: + result = json.loads( + terminal_tool_module.terminal_tool( + command="python3 -c \"print(input())\"", + background=True, + pty=True, + ) + ) + finally: + terminal_tool_module._active_environments.pop("default", None) + terminal_tool_module._last_activity.pop("default", None) + + assert captured["use_pty"] is True + assert "pty_note" not in result diff --git a/tools/process_registry.py b/tools/process_registry.py index b935f49c..c954378b 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -700,6 +700,29 @@ class ProcessRegistry: """Send data + newline to a running process's stdin (like pressing Enter).""" return self.write_stdin(session_id, data + "\n") + def close_stdin(self, session_id: str) -> dict: + """Close a running process's stdin / send EOF without killing the process.""" + session = self.get(session_id) + if session is None: + return {"status": "not_found", "error": f"No process with ID {session_id}"} + if session.exited: + return {"status": "already_exited", "error": "Process has already finished"} + + if hasattr(session, '_pty') and session._pty: + try: + session._pty.sendeof() + return {"status": "ok", "message": "EOF sent"} + except Exception as e: + return {"status": "error", "error": str(e)} + + if not session.process or not session.process.stdin: + return {"status": "error", "error": "Process stdin not available (non-local backend or stdin closed)"} + try: + session.process.stdin.close() + return {"status": "ok", "message": "stdin closed"} + except Exception as e: + return {"status": "error", "error": str(e)} + def list_sessions(self, task_id: str = None) -> list: """List all running and recently-finished processes.""" with self._lock: @@ -915,14 +938,14 @@ PROCESS_SCHEMA = { "Actions: 'list' (show all), 'poll' (check status + new output), " "'log' (full output with pagination), 'wait' (block until done or timeout), " "'kill' (terminate), 'write' (send raw stdin data without newline), " - "'submit' (send data + Enter, for answering prompts)." + "'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF)." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["list", "poll", "log", "wait", "kill", "write", "submit"], + "enum": ["list", "poll", "log", "wait", "kill", "write", "submit", "close"], "description": "Action to perform on background processes" }, "session_id": { @@ -962,7 +985,7 @@ def _handle_process(args, **kw): if action == "list": return _json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False) - elif action in ("poll", "log", "wait", "kill", "write", "submit"): + elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"): if not session_id: return tool_error(f"session_id is required for {action}") if action == "poll": @@ -978,7 +1001,9 @@ def _handle_process(args, **kw): return _json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) elif action == "submit": return _json.dumps(process_registry.submit_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) - return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit") + elif action == "close": + return _json.dumps(process_registry.close_stdin(session_id), ensure_ascii=False) + return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit, close") registry.register( diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 0dc0fd58..af35771c 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1112,6 +1112,21 @@ def _interpret_exit_code(command: str, exit_code: int) -> str | None: return None +def _command_requires_pipe_stdin(command: str) -> bool: + """Return True when PTY mode would break stdin-driven commands. + + Some CLIs change behavior when stdin is a TTY. In particular, + `gh auth login --with-token` expects the token to arrive via piped stdin and + waits for EOF; when we launch it under a PTY, `process.submit()` only sends a + newline, so the command appears to hang forever with no visible progress. + """ + normalized = " ".join(command.lower().split()) + return ( + normalized.startswith("gh auth login") + and "--with-token" in normalized + ) + + def terminal_tool( command: str, background: bool = False, @@ -1332,6 +1347,17 @@ def terminal_tool( }, ensure_ascii=False) # Prepare command for execution + pty_disabled_reason = None + effective_pty = pty + if pty and _command_requires_pipe_stdin(command): + effective_pty = False + pty_disabled_reason = ( + "PTY disabled for this command because it expects piped stdin/EOF " + "(for example gh auth login --with-token). For local background " + "processes, call process(action='close') after writing so it receives " + "EOF." + ) + if background: # Spawn a tracked background process via the process registry. # For local backends: uses subprocess.Popen with output buffering. @@ -1349,7 +1375,7 @@ def terminal_tool( task_id=effective_task_id, session_key=session_key, env_vars=env.env if hasattr(env, 'env') else None, - use_pty=pty, + use_pty=effective_pty, ) else: proc_session = process_registry.spawn_via_env( @@ -1369,6 +1395,8 @@ def terminal_tool( } if approval_note: result_data["approval"] = approval_note + if pty_disabled_reason: + result_data["pty_note"] = pty_disabled_reason # Transparent timeout clamping note max_timeout = effective_timeout diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index e3282fa8..5bdb6809 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -1,7 +1,7 @@ --- sidebar_position: 2 title: "Installation" -description: "Install Hermes Agent on Linux, macOS, or WSL2" +description: "Install Hermes Agent on Linux, macOS, WSL2, or Android via Termux" --- # Installation @@ -16,6 +16,23 @@ Get Hermes Agent up and running in under two minutes with the one-line installer curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` +### Android / Termux + +Hermes now ships a Termux-aware installer path too: + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +The installer detects Termux automatically and switches to a tested Android flow: +- uses Termux `pkg` for system dependencies (`git`, `python`, `nodejs`, `ripgrep`, `ffmpeg`, build tools) +- creates the virtualenv with `python -m venv` +- exports `ANDROID_API_LEVEL` automatically for Android wheel builds +- installs a curated `.[termux]` extra with `pip` +- skips the untested browser / WhatsApp bootstrap by default + +If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md). + :::warning Windows Native Windows is **not supported**. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes Agent from there. The install command above works inside WSL2. ::: @@ -125,6 +142,7 @@ uv pip install -e "." | `tts-premium` | ElevenLabs premium voices | `uv pip install -e ".[tts-premium]"` | | `voice` | CLI microphone input + audio playback | `uv pip install -e ".[voice]"` | | `pty` | PTY terminal support | `uv pip install -e ".[pty]"` | +| `termux` | Tested Android / Termux bundle (`cron`, `cli`, `pty`, `mcp`, `honcho`, `acp`) | `python -m pip install -e ".[termux]" -c constraints-termux.txt` | | `honcho` | AI-native memory (Honcho integration) | `uv pip install -e ".[honcho]"` | | `mcp` | Model Context Protocol support | `uv pip install -e ".[mcp]"` | | `homeassistant` | Home Assistant integration | `uv pip install -e ".[homeassistant]"` | @@ -134,6 +152,10 @@ uv pip install -e "." You can combine extras: `uv pip install -e ".[messaging,cron]"` +:::tip Termux users +`.[all]` is not currently available on Android because the `voice` extra pulls `faster-whisper`, which depends on `ctranslate2` wheels that are not published for Android. Use `.[termux]` for the tested mobile install path, then add individual extras only as needed. +::: + ### Step 4: Install Optional Submodules (if needed) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 7ed83e81..bd26f1ee 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -13,10 +13,14 @@ This guide walks you through installing Hermes Agent, setting up a provider, and Run the one-line installer: ```bash -# Linux / macOS / WSL2 +# Linux / macOS / WSL2 / Android (Termux) curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` +:::tip Android / Termux +If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations. +::: + :::tip Windows Users Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) first, then run the command above inside your WSL2 terminal. ::: diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md new file mode 100644 index 00000000..e980b507 --- /dev/null +++ b/website/docs/getting-started/termux.md @@ -0,0 +1,228 @@ +--- +sidebar_position: 3 +title: "Android / Termux" +description: "Run Hermes Agent directly on an Android phone with Termux" +--- + +# Hermes on Android with Termux + +This is the tested path for running Hermes Agent directly on an Android phone through [Termux](https://termux.dev/). + +It gives you a working local CLI on the phone, plus the core extras that are currently known to install cleanly on Android. + +## What is supported in the tested path? + +The tested Termux bundle installs: +- the Hermes CLI +- cron support +- PTY/background terminal support +- MCP support +- Honcho memory support +- ACP support + +Concretely, it maps to: + +```bash +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +## What is not part of the tested path yet? + +A few features still need desktop/server-style dependencies that are not published for Android, or have not been validated on phones yet: + +- `.[all]` is not supported on Android today +- the `voice` extra is blocked by `faster-whisper -> ctranslate2`, and `ctranslate2` does not publish Android wheels +- automatic browser / Playwright bootstrap is skipped in the Termux installer +- Docker-based terminal isolation is not available inside Termux + +That does not stop Hermes from working well as a phone-native CLI agent — it just means the recommended mobile install is intentionally narrower than the desktop/server install. + +--- + +## Option 1: One-line installer + +Hermes now ships a Termux-aware installer path: + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +On Termux, the installer automatically: +- uses `pkg` for system packages +- creates the venv with `python -m venv` +- installs `.[termux]` with `pip` +- skips the untested browser / WhatsApp bootstrap + +If you want the explicit commands or need to debug a failed install, use the manual path below. + +--- + +## Option 2: Manual install (fully explicit) + +### 1. Update Termux and install system packages + +```bash +pkg update +pkg install -y git python clang rust make pkg-config libffi openssl nodejs ripgrep ffmpeg +``` + +Why these packages? +- `python` — runtime + venv support +- `git` — clone/update the repo +- `clang`, `rust`, `make`, `pkg-config`, `libffi`, `openssl` — needed to build a few Python dependencies on Android +- `nodejs` — optional Node runtime for experiments beyond the tested core path +- `ripgrep` — fast file search +- `ffmpeg` — media / TTS conversions + +### 2. Clone Hermes + +```bash +git clone --recurse-submodules https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +### 3. Create a virtual environment + +```bash +python -m venv venv +source venv/bin/activate +export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)" +python -m pip install --upgrade pip setuptools wheel +``` + +`ANDROID_API_LEVEL` is important for Rust / maturin-based packages such as `jiter`. + +### 4. Install the tested Termux bundle + +```bash +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +If you only want the minimal core agent, this also works: + +```bash +python -m pip install -e '.' -c constraints-termux.txt +``` + +### 5. Verify the install + +```bash +hermes version +hermes doctor +``` + +### 6. Start Hermes + +```bash +hermes +``` + +--- + +## Recommended follow-up setup + +### Configure a model + +```bash +hermes model +``` + +Or set keys directly in `~/.hermes/.env`. + +### Re-run the full interactive setup wizard later + +```bash +hermes setup +``` + +### Install optional Node dependencies manually + +The tested Termux path skips Node/browser bootstrap on purpose. If you want to experiment later: + +```bash +npm install +``` + +Treat browser / WhatsApp tooling on Android as experimental until documented otherwise. + +--- + +## Troubleshooting + +### `No solution found` when installing `.[all]` + +Use the tested Termux bundle instead: + +```bash +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +The blocker is currently the `voice` extra: +- `voice` pulls `faster-whisper` +- `faster-whisper` depends on `ctranslate2` +- `ctranslate2` does not publish Android wheels + +### `uv pip install` fails on Android + +Use the Termux path with the stdlib venv + `pip` instead: + +```bash +python -m venv venv +source venv/bin/activate +export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)" +python -m pip install --upgrade pip setuptools wheel +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +### `jiter` / `maturin` complains about `ANDROID_API_LEVEL` + +Set the API level explicitly before installing: + +```bash +export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)" +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +### `hermes doctor` says ripgrep or Node is missing + +Install them with Termux packages: + +```bash +pkg install ripgrep nodejs +``` + +### Build failures while installing Python packages + +Make sure the build toolchain is installed: + +```bash +pkg install clang rust make pkg-config libffi openssl +``` + +Then retry: + +```bash +python -m pip install -e '.[termux]' -c constraints-termux.txt +``` + +--- + +## Known limitations on phones + +- Docker backend is unavailable +- local voice transcription via `faster-whisper` is unavailable in the tested path +- browser automation setup is intentionally skipped by the installer +- some optional extras may work, but only `.[termux]` is currently documented as the tested Android bundle + +If you hit a new Android-specific issue, please open a GitHub issue with: +- your Android version +- `termux-info` +- `python --version` +- `hermes doctor` +- the exact install command and full error output diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index e8e6fe43..0ec0abd4 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -36,6 +36,20 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` +### Does it work on Android / Termux? + +Yes — Hermes now has a tested Termux install path for Android phones. + +Quick install: + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +For the fully explicit manual steps, supported extras, and current limitations, see the [Termux guide](../getting-started/termux.md). + +Important caveat: the full `.[all]` extra is not currently available on Android because the `voice` extra depends on `faster-whisper` → `ctranslate2`, and `ctranslate2` does not publish Android wheels. Use the tested `.[termux]` extra instead. + ### Is my data sent anywhere? API calls go **only to the LLM provider you configure** (e.g., OpenRouter, your local Ollama instance). Hermes Agent does not collect telemetry, usage data, or analytics. Your conversations, memory, and skills are stored locally in `~/.hermes/`. diff --git a/website/sidebars.ts b/website/sidebars.ts index 39b60d88..720ccafd 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = { items: [ 'getting-started/quickstart', 'getting-started/installation', + 'getting-started/termux', 'getting-started/nix-setup', 'getting-started/updating', 'getting-started/learning-path', From 122925a6f22d6a9939ed334a0a882551d399a59f Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:22:55 +0200 Subject: [PATCH 38/51] fix(termux): honor temp dirs for local temp artifacts --- tests/tools/test_local_tempdir.py | 51 +++++++++++++++++++++++++ tests/tools/test_tool_result_storage.py | 35 +++++++++++++++++ tools/environments/base.py | 14 ++++++- tools/environments/local.py | 27 +++++++++++++ tools/tool_result_storage.py | 31 ++++++++++++--- 5 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/tools/test_local_tempdir.py diff --git a/tests/tools/test_local_tempdir.py b/tests/tools/test_local_tempdir.py new file mode 100644 index 00000000..5bbf3f26 --- /dev/null +++ b/tests/tools/test_local_tempdir.py @@ -0,0 +1,51 @@ +from unittest.mock import patch + +from tools.environments.local import LocalEnvironment + + +class TestLocalTempDir: + def test_uses_os_tmpdir_for_session_artifacts(self, monkeypatch): + monkeypatch.setenv("TMPDIR", "/data/data/com.termux/files/usr/tmp") + monkeypatch.delenv("TMP", raising=False) + monkeypatch.delenv("TEMP", raising=False) + + with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None): + env = LocalEnvironment(cwd=".", timeout=10) + + assert env.get_temp_dir() == "/data/data/com.termux/files/usr/tmp" + assert env._snapshot_path == f"/data/data/com.termux/files/usr/tmp/hermes-snap-{env._session_id}.sh" + assert env._cwd_file == f"/data/data/com.termux/files/usr/tmp/hermes-cwd-{env._session_id}.txt" + + def test_prefers_backend_env_tmpdir_override(self, monkeypatch): + monkeypatch.delenv("TMPDIR", raising=False) + monkeypatch.delenv("TMP", raising=False) + monkeypatch.delenv("TEMP", raising=False) + + with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None): + env = LocalEnvironment( + cwd=".", + timeout=10, + env={"TMPDIR": "/data/data/com.termux/files/home/.cache/hermes-tmp/"}, + ) + + assert env.get_temp_dir() == "/data/data/com.termux/files/home/.cache/hermes-tmp" + assert env._snapshot_path == ( + f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-snap-{env._session_id}.sh" + ) + assert env._cwd_file == ( + f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-cwd-{env._session_id}.txt" + ) + + def test_falls_back_to_tempfile_when_tmp_missing(self, monkeypatch): + monkeypatch.delenv("TMPDIR", raising=False) + monkeypatch.delenv("TMP", raising=False) + monkeypatch.delenv("TEMP", raising=False) + + with patch("tools.environments.local.os.path.isdir", return_value=False), \ + patch("tools.environments.local.os.access", return_value=False), \ + patch("tools.environments.local.tempfile.gettempdir", return_value="/cache/tmp"), \ + patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None): + env = LocalEnvironment(cwd=".", timeout=10) + assert env.get_temp_dir() == "/cache/tmp" + assert env._snapshot_path == f"/cache/tmp/hermes-snap-{env._session_id}.sh" + assert env._cwd_file == f"/cache/tmp/hermes-cwd-{env._session_id}.txt" diff --git a/tests/tools/test_tool_result_storage.py b/tests/tools/test_tool_result_storage.py index 4e51fe7b..f95b5dc0 100644 --- a/tests/tools/test_tool_result_storage.py +++ b/tests/tools/test_tool_result_storage.py @@ -16,6 +16,7 @@ from tools.tool_result_storage import ( STORAGE_DIR, _build_persisted_message, _heredoc_marker, + _resolve_storage_dir, _write_to_sandbox, enforce_turn_budget, generate_preview, @@ -115,6 +116,24 @@ class TestWriteToSandbox: _write_to_sandbox("content", "/tmp/hermes-results/abc.txt", env) assert env.execute.call_args[1]["timeout"] == 30 + def test_uses_parent_dir_of_remote_path(self): + env = MagicMock() + env.execute.return_value = {"output": "", "returncode": 0} + remote_path = "/data/data/com.termux/files/usr/tmp/hermes-results/abc.txt" + _write_to_sandbox("content", remote_path, env) + cmd = env.execute.call_args[0][0] + assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd + + +class TestResolveStorageDir: + def test_defaults_to_storage_dir_without_env(self): + assert _resolve_storage_dir(None) == STORAGE_DIR + + def test_uses_env_temp_dir_when_available(self): + env = MagicMock() + env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp" + assert _resolve_storage_dir(env) == "/data/data/com.termux/files/usr/tmp/hermes-results" + # ── _build_persisted_message ────────────────────────────────────────── @@ -341,6 +360,22 @@ class TestMaybePersistToolResult: ) assert "DISTINCTIVE_START_MARKER" in result + def test_env_temp_dir_changes_persisted_path(self): + env = MagicMock() + env.execute.return_value = {"output": "", "returncode": 0} + env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp" + content = "x" * 60_000 + result = maybe_persist_tool_result( + content=content, + tool_name="terminal", + tool_use_id="tc_termux", + env=env, + threshold=30_000, + ) + assert "/data/data/com.termux/files/usr/tmp/hermes-results/tc_termux.txt" in result + cmd = env.execute.call_args[0][0] + assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd + def test_threshold_zero_forces_persist(self): env = MagicMock() env.execute.return_value = {"output": "", "returncode": 0} diff --git a/tools/environments/base.py b/tools/environments/base.py index 31ce0e17..d2963e4a 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -226,14 +226,24 @@ class BaseEnvironment(ABC): # Snapshot creation timeout (override for slow cold-starts). _snapshot_timeout: int = 30 + def get_temp_dir(self) -> str: + """Return the backend temp directory used for session artifacts. + + Most sandboxed backends use ``/tmp`` inside the target environment. + LocalEnvironment overrides this on platforms like Termux where ``/tmp`` + may be missing and ``TMPDIR`` is the portable writable location. + """ + return "/tmp" + def __init__(self, cwd: str, timeout: int, env: dict = None): self.cwd = cwd self.timeout = timeout self.env = env or {} self._session_id = uuid.uuid4().hex[:12] - self._snapshot_path = f"/tmp/hermes-snap-{self._session_id}.sh" - self._cwd_file = f"/tmp/hermes-cwd-{self._session_id}.txt" + temp_dir = self.get_temp_dir().rstrip("/") or "/" + self._snapshot_path = f"{temp_dir}/hermes-snap-{self._session_id}.sh" + self._cwd_file = f"{temp_dir}/hermes-cwd-{self._session_id}.txt" self._cwd_marker = _cwd_marker(self._session_id) self._snapshot_ready = False self._last_sync_time: float | None = ( diff --git a/tools/environments/local.py b/tools/environments/local.py index d3bb3448..bf5b37f9 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -5,6 +5,7 @@ import platform import shutil import signal import subprocess +import tempfile from tools.environments.base import BaseEnvironment, _pipe_stdin @@ -209,6 +210,32 @@ class LocalEnvironment(BaseEnvironment): super().__init__(cwd=cwd or os.getcwd(), timeout=timeout, env=env) self.init_session() + def get_temp_dir(self) -> str: + """Return a shell-safe writable temp dir for local execution. + + Termux does not provide /tmp by default, but exposes a POSIX TMPDIR. + Prefer POSIX-style env vars when available, keep using /tmp on regular + Unix systems, and only fall back to tempfile.gettempdir() when it also + resolves to a POSIX path. + + Check the environment configured for this backend first so callers can + override the temp root explicitly (for example via terminal.env or a + custom TMPDIR), then fall back to the host process environment. + """ + for env_var in ("TMPDIR", "TMP", "TEMP"): + candidate = self.env.get(env_var) or os.environ.get(env_var) + if candidate and candidate.startswith("/"): + return candidate.rstrip("/") or "/" + + if os.path.isdir("/tmp") and os.access("/tmp", os.W_OK | os.X_OK): + return "/tmp" + + candidate = tempfile.gettempdir() + if candidate.startswith("/"): + return candidate.rstrip("/") or "/" + + return "/tmp" + def _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120, stdin_data: str | None = None) -> subprocess.Popen: diff --git a/tools/tool_result_storage.py b/tools/tool_result_storage.py index 076d37ae..a8ec5440 100644 --- a/tools/tool_result_storage.py +++ b/tools/tool_result_storage.py @@ -9,9 +9,11 @@ Defense against context-window overflow operates at three levels: 2. **Per-result persistence** (maybe_persist_tool_result): After a tool returns, if its output exceeds the tool's registered threshold (registry.get_max_result_size), the full output is written INTO THE - SANDBOX at /tmp/hermes-results/{tool_use_id}.txt via env.execute(). - The in-context content is replaced with a preview + file path reference. - The model can read_file to access the full output on any backend. + SANDBOX temp dir (for example /tmp/hermes-results/{tool_use_id}.txt on + standard Linux, or $TMPDIR/hermes-results/{tool_use_id}.txt on Termux) + via env.execute(). The in-context content is replaced with a preview + + file path reference. The model can read_file to access the full output + on any backend. 3. **Per-turn aggregate budget** (enforce_turn_budget): After all tool results in a single assistant turn are collected, if the total exceeds @@ -21,6 +23,7 @@ Defense against context-window overflow operates at three levels: """ import logging +import os import uuid from tools.budget_config import ( @@ -37,6 +40,22 @@ HEREDOC_MARKER = "HERMES_PERSIST_EOF" _BUDGET_TOOL_NAME = "__budget_enforcement__" +def _resolve_storage_dir(env) -> str: + """Return the best temp-backed storage dir for this environment.""" + if env is not None: + get_temp_dir = getattr(env, "get_temp_dir", None) + if callable(get_temp_dir): + try: + temp_dir = get_temp_dir() + except Exception as exc: + logger.debug("Could not resolve env temp dir: %s", exc) + else: + if temp_dir: + temp_dir = temp_dir.rstrip("/") or "/" + return f"{temp_dir}/hermes-results" + return STORAGE_DIR + + def generate_preview(content: str, max_chars: int = DEFAULT_PREVIEW_SIZE_CHARS) -> tuple[str, bool]: """Truncate at last newline within max_chars. Returns (preview, has_more).""" if len(content) <= max_chars: @@ -58,8 +77,9 @@ def _heredoc_marker(content: str) -> str: def _write_to_sandbox(content: str, remote_path: str, env) -> bool: """Write content into the sandbox via env.execute(). Returns True on success.""" marker = _heredoc_marker(content) + storage_dir = os.path.dirname(remote_path) cmd = ( - f"mkdir -p {STORAGE_DIR} && cat > {remote_path} << '{marker}'\n" + f"mkdir -p {storage_dir} && cat > {remote_path} << '{marker}'\n" f"{content}\n" f"{marker}" ) @@ -125,7 +145,8 @@ def maybe_persist_tool_result( if len(content) <= effective_threshold: return content - remote_path = f"{STORAGE_DIR}/{tool_use_id}.txt" + storage_dir = _resolve_storage_dir(env) + remote_path = f"{storage_dir}/{tool_use_id}.txt" preview, has_more = generate_preview(content, max_chars=config.preview_size) if env is not None: From 4e40e93b98184cbe145a20ac2f2698fa4621271c Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:08:33 +0200 Subject: [PATCH 39/51] fix(termux): improve status and install UX --- hermes_cli/doctor.py | 15 +++- hermes_cli/status.py | 27 ++++++- scripts/install.sh | 98 +++++++++++++++++++------- tests/hermes_cli/test_doctor.py | 22 ++++++ tests/hermes_cli/test_status.py | 30 ++++++++ website/docs/getting-started/termux.md | 13 +++- 6 files changed, 174 insertions(+), 31 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 6bdfd123..c2bba845 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -543,7 +543,10 @@ def run_doctor(args): if shutil.which("docker"): check_ok("docker", "(optional)") else: - check_warn("docker not found", "(optional)") + if _is_termux(): + check_info("Docker backend is not available inside Termux (expected on Android)") + else: + check_warn("docker not found", "(optional)") # SSH (if using ssh backend) if terminal_env == "ssh": @@ -591,11 +594,17 @@ def run_doctor(args): if agent_browser_path.exists(): check_ok("agent-browser (Node.js)", "(browser automation)") else: - check_warn("agent-browser not installed", "(run: npm install)") + if _is_termux(): + check_info("agent-browser is not installed (expected in the tested Termux path)") + check_info("Install it manually later with: npm install") + else: + check_warn("agent-browser not installed", "(run: npm install)") else: - check_warn("Node.js not found", "(optional, needed for browser tools)") if _is_termux(): + check_info("Node.js not found (browser tools are optional in the tested Termux path)") check_info("Install Node.js on Termux with: pkg install nodejs") + else: + check_warn("Node.js not found", "(optional, needed for browser tools)") # npm audit for all Node.js packages if shutil.which("npm"): diff --git a/hermes_cli/status.py b/hermes_cli/status.py index eed89885..a04d5701 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -79,6 +79,11 @@ def _effective_provider_label() -> str: return provider_label(effective) +def _is_termux() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + def show_status(args): """Show status of all Hermes Agent components.""" show_all = getattr(args, 'all', False) @@ -325,7 +330,25 @@ def show_status(args): print() print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) - if sys.platform.startswith('linux'): + if _is_termux(): + try: + from hermes_cli.gateway import find_gateway_pids + gateway_pids = find_gateway_pids() + except Exception: + gateway_pids = [] + is_running = bool(gateway_pids) + print(f" Status: {check_mark(is_running)} {'running' if is_running else 'stopped'}") + print(" Manager: Termux / manual process") + if gateway_pids: + rendered = ", ".join(str(pid) for pid in gateway_pids[:3]) + if len(gateway_pids) > 3: + rendered += ", ..." + print(f" PID(s): {rendered}") + else: + print(" Start with: hermes gateway") + print(" Note: Android may stop background jobs when Termux is suspended") + + elif sys.platform.startswith('linux'): try: from hermes_cli.gateway import get_service_name _gw_svc = get_service_name() @@ -339,7 +362,7 @@ def show_status(args): timeout=5 ) is_active = result.stdout.strip() == "active" - except subprocess.TimeoutExpired: + except (FileNotFoundError, subprocess.TimeoutExpired): is_active = False print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") print(" Manager: systemd (user)") diff --git a/scripts/install.sh b/scripts/install.sh index 2b52b039..0bb091ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -121,6 +121,32 @@ is_termux() { [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] } +get_command_link_dir() { + if is_termux && [ -n "${PREFIX:-}" ]; then + echo "$PREFIX/bin" + else + echo "$HOME/.local/bin" + fi +} + +get_command_link_display_dir() { + if is_termux && [ -n "${PREFIX:-}" ]; then + echo '$PREFIX/bin' + else + echo '~/.local/bin' + fi +} + +get_hermes_command_path() { + local link_dir + link_dir="$(get_command_link_dir)" + if [ -x "$link_dir/hermes" ]; then + echo "$link_dir/hermes" + else + echo "hermes" + fi +} + # ============================================================================ # System detection # ============================================================================ @@ -897,15 +923,27 @@ setup_path() { return 0 fi - # Create symlink in ~/.local/bin (standard user binary location, usually on PATH) - mkdir -p "$HOME/.local/bin" - ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes" - log_success "Symlinked hermes → ~/.local/bin/hermes" + local command_link_dir + local command_link_display_dir + command_link_dir="$(get_command_link_dir)" + command_link_display_dir="$(get_command_link_display_dir)" + + # Create a user-facing shim for the hermes command. + mkdir -p "$command_link_dir" + ln -sf "$HERMES_BIN" "$command_link_dir/hermes" + log_success "Symlinked hermes → $command_link_display_dir/hermes" + + if [ "$DISTRO" = "termux" ]; then + export PATH="$command_link_dir:$PATH" + log_info "$command_link_display_dir is the native Termux command path" + log_success "hermes command ready" + return 0 + fi # Check if ~/.local/bin is on PATH; if not, add it to shell config. # Detect the user's actual login shell (not the shell running this script, # which is always bash when piped from curl). - if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then + if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then SHELL_CONFIGS=() LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" case "$LOGIN_SHELL" in @@ -951,7 +989,7 @@ setup_path() { fi # Export for current session so hermes works immediately - export PATH="$HOME/.local/bin:$PATH" + export PATH="$command_link_dir:$PATH" log_success "hermes command ready" } @@ -1149,8 +1187,7 @@ maybe_start_gateway() { read -p "Pair WhatsApp now? [Y/n] " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - HERMES_CMD="$HOME/.local/bin/hermes" - [ ! -x "$HERMES_CMD" ] && HERMES_CMD="hermes" + HERMES_CMD="$(get_hermes_command_path)" $HERMES_CMD whatsapp || true fi else @@ -1164,16 +1201,17 @@ maybe_start_gateway() { fi echo "" - read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r < /dev/tty + if [ "$DISTRO" = "termux" ]; then + read -p "Would you like to start the gateway in the background? [Y/n] " -n 1 -r < /dev/tty + else + read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r < /dev/tty + fi echo if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - HERMES_CMD="$HOME/.local/bin/hermes" - if [ ! -x "$HERMES_CMD" ]; then - HERMES_CMD="hermes" - fi + HERMES_CMD="$(get_hermes_command_path)" - if command -v systemctl &> /dev/null; then + if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then log_info "Installing systemd service..." if $HERMES_CMD gateway install 2>/dev/null; then log_success "Gateway service installed" @@ -1186,12 +1224,19 @@ maybe_start_gateway() { log_warn "Systemd install failed. You can start manually: hermes gateway" fi else - log_info "systemd not available — starting gateway in background..." + if [ "$DISTRO" = "termux" ]; then + log_info "Termux detected — starting gateway in best-effort background mode..." + else + log_info "systemd not available — starting gateway in background..." + fi nohup $HERMES_CMD gateway > "$HERMES_HOME/logs/gateway.log" 2>&1 & GATEWAY_PID=$! log_success "Gateway started (PID $GATEWAY_PID). Logs: ~/.hermes/logs/gateway.log" log_info "To stop: kill $GATEWAY_PID" log_info "To restart later: hermes gateway" + if [ "$DISTRO" = "termux" ]; then + log_warn "Android may stop background processes when Termux is suspended or the system reclaims resources." + fi fi else log_info "Skipped. Start the gateway later with: hermes gateway" @@ -1230,17 +1275,22 @@ print_success() { echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}" echo "" - echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" - echo "" - LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" - if [ "$LOGIN_SHELL" = "zsh" ]; then - echo " source ~/.zshrc" - elif [ "$LOGIN_SHELL" = "bash" ]; then - echo " source ~/.bashrc" + if [ "$DISTRO" = "termux" ]; then + echo -e "${YELLOW}⚡ 'hermes' was linked into $(get_command_link_display_dir), which is already on PATH in Termux.${NC}" + echo "" else - echo " source ~/.bashrc # or ~/.zshrc" + echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" + echo "" + LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" + if [ "$LOGIN_SHELL" = "zsh" ]; then + echo " source ~/.zshrc" + elif [ "$LOGIN_SHELL" = "bash" ]; then + echo " source ~/.bashrc" + else + echo " source ~/.bashrc # or ~/.zshrc" + fi + echo "" fi - echo "" # Show Node.js warning if auto-install failed if [ "$HAS_NODE" = false ]; then diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 1378ad32..eb767690 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -223,3 +223,25 @@ class TestDoctorMemoryProviderSection: out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0") assert "Memory Provider" in out assert "Built-in memory active" not in out + + +def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path): + helper = TestDoctorMemoryProviderSection() + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + + real_which = doctor_mod.shutil.which + + def fake_which(cmd): + if cmd in {"docker", "node", "npm"}: + return None + return real_which(cmd) + + monkeypatch.setattr(doctor_mod.shutil, "which", fake_which) + + out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="") + + assert "Docker backend is not available inside Termux" in out + assert "Node.js not found (browser tools are optional in the tested Termux path)" in out + assert "Install Node.js on Termux with: pkg install nodejs" in out + assert "docker not found (optional)" not in out diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 374e57b2..c24b72dd 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -12,3 +12,33 @@ def test_show_status_includes_tavily_key(monkeypatch, capsys, tmp_path): output = capsys.readouterr().out assert "Tavily" in output assert "tvly...cdef" in output + + +def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, tmp_path): + from hermes_cli import status as status_mod + import hermes_cli.auth as auth_mod + import hermes_cli.gateway as gateway_mod + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False) + monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False) + monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) + + def _unexpected_systemctl(*args, **kwargs): + raise AssertionError("systemctl should not be called in the Termux status view") + + monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_systemctl) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Manager: Termux / manual process" in output + assert "Start with: hermes gateway" in output + assert "systemd (user)" not in output diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index e980b507..1ad71e53 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -51,6 +51,7 @@ On Termux, the installer automatically: - uses `pkg` for system packages - creates the venv with `python -m venv` - installs `.[termux]` with `pip` +- links `hermes` into `$PREFIX/bin` so it stays on your Termux PATH - skips the untested browser / WhatsApp bootstrap If you want the explicit commands or need to debug a failed install, use the manual path below. @@ -110,14 +111,22 @@ If you only want the minimal core agent, this also works: python -m pip install -e '.' -c constraints-termux.txt ``` -### 5. Verify the install +### 5. Put `hermes` on your Termux PATH + +```bash +ln -sf "$PWD/venv/bin/hermes" "$PREFIX/bin/hermes" +``` + +`$PREFIX/bin` is already on PATH in Termux, so this makes the `hermes` command persist across new shells without re-activating the venv every time. + +### 6. Verify the install ```bash hermes version hermes doctor ``` -### 6. Start Hermes +### 7. Start Hermes ```bash hermes From 387849597227b99d4f0446ae063c702c6e1d3e4f Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:29:32 +0200 Subject: [PATCH 40/51] fix(termux): disable gateway service flows on android --- hermes_cli/gateway.py | 95 +++++++++++++------ hermes_cli/main.py | 4 +- hermes_cli/uninstall.py | 4 + tests/hermes_cli/test_gateway.py | 7 ++ tests/hermes_cli/test_gateway_linger.py | 5 + tests/hermes_cli/test_gateway_service.py | 63 ++++++++++-- .../hermes_cli/test_update_gateway_restart.py | 23 +++-- 7 files changed, 153 insertions(+), 48 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 82689f8f..9e215ff2 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -39,7 +39,7 @@ def _get_service_pids() -> set: pids: set = set() # --- systemd (Linux): user and system scopes --- - if is_linux(): + if supports_systemd_services(): for scope_args in [["systemctl", "--user"], ["systemctl"]]: try: result = subprocess.run( @@ -225,6 +225,16 @@ def stop_profile_gateway() -> bool: def is_linux() -> bool: return sys.platform.startswith('linux') + +def is_termux() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + +def supports_systemd_services() -> bool: + return is_linux() and not is_termux() + + def is_macos() -> bool: return sys.platform == 'darwin' @@ -477,13 +487,15 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b def get_systemd_linger_status() -> tuple[bool | None, str]: - """Return whether systemd user lingering is enabled for the current user. + """Return systemd linger status for the current user. Returns: (True, "") when linger is enabled. (False, "") when linger is disabled. (None, detail) when the status could not be determined. """ + if is_termux(): + return None, "not supported in Termux" if not is_linux(): return None, "not supported on this platform" @@ -766,7 +778,7 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No def _ensure_linger_enabled() -> None: """Enable linger when possible so the user gateway survives logout.""" - if not is_linux(): + if is_termux() or not is_linux(): return import getpass @@ -1801,7 +1813,7 @@ def _setup_whatsapp(): def _is_service_installed() -> bool: """Check if the gateway is installed as a system service.""" - if is_linux(): + if supports_systemd_services(): return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() elif is_macos(): return get_launchd_plist_path().exists() @@ -1810,7 +1822,7 @@ def _is_service_installed() -> bool: def _is_service_running() -> bool: """Check if the gateway service is currently running.""" - if is_linux(): + if supports_systemd_services(): user_unit_exists = get_systemd_unit_path(system=False).exists() system_unit_exists = get_systemd_unit_path(system=True).exists() @@ -1983,7 +1995,7 @@ def gateway_setup(): service_installed = _is_service_installed() service_running = _is_service_running() - if is_linux() and has_conflicting_systemd_units(): + if supports_systemd_services() and has_conflicting_systemd_units(): print_systemd_scope_conflict_warning() print() @@ -1993,7 +2005,7 @@ def gateway_setup(): print_warning("Gateway service is installed but not running.") if prompt_yes_no(" Start it now?", True): try: - if is_linux(): + if supports_systemd_services(): systemd_start() elif is_macos(): launchd_start() @@ -2044,7 +2056,7 @@ def gateway_setup(): if service_running: if prompt_yes_no(" Restart the gateway to pick up changes?", True): try: - if is_linux(): + if supports_systemd_services(): systemd_restart() elif is_macos(): launchd_restart() @@ -2056,7 +2068,7 @@ def gateway_setup(): elif service_installed: if prompt_yes_no(" Start the gateway service?", True): try: - if is_linux(): + if supports_systemd_services(): systemd_start() elif is_macos(): launchd_start() @@ -2064,13 +2076,13 @@ def gateway_setup(): print_error(f" Start failed: {e}") else: print() - if is_linux() or is_macos(): - platform_name = "systemd" if is_linux() else "launchd" + if supports_systemd_services() or is_macos(): + platform_name = "systemd" if supports_systemd_services() else "launchd" if prompt_yes_no(f" Install the gateway as a {platform_name} service? (runs in background, starts on boot)", True): try: installed_scope = None did_install = False - if is_linux(): + if supports_systemd_services(): installed_scope, did_install = install_linux_gateway_from_setup(force=False) else: launchd_install(force=False) @@ -2078,7 +2090,7 @@ def gateway_setup(): print() if did_install and prompt_yes_no(" Start the service now?", True): try: - if is_linux(): + if supports_systemd_services(): systemd_start(system=installed_scope == "system") else: launchd_start() @@ -2089,12 +2101,18 @@ def gateway_setup(): print_info(" You can try manually: hermes gateway install") else: print_info(" You can install later: hermes gateway install") - if is_linux(): + if supports_systemd_services(): print_info(" Or as a boot-time service: sudo hermes gateway install --system") print_info(" Or run in foreground: hermes gateway") else: - print_info(" Service install not supported on this platform.") - print_info(" Run in foreground: hermes gateway") + if is_termux(): + from hermes_constants import display_hermes_home as _dhh + print_info(" Termux does not use systemd/launchd services.") + print_info(" Run in foreground: hermes gateway") + print_info(f" Or start it manually in the background (best effort): nohup hermes gateway >{_dhh()}/logs/gateway.log 2>&1 &") + else: + print_info(" Service install not supported on this platform.") + print_info(" Run in foreground: hermes gateway") else: print() print_info("No platforms configured. Run 'hermes gateway setup' when ready.") @@ -2130,7 +2148,11 @@ def gateway_command(args): force = getattr(args, 'force', False) system = getattr(args, 'system', False) run_as_user = getattr(args, 'run_as_user', None) - if is_linux(): + if is_termux(): + print("Gateway service installation is not supported on Termux.") + print("Run manually: hermes gateway") + sys.exit(1) + if supports_systemd_services(): systemd_install(force=force, system=system, run_as_user=run_as_user) elif is_macos(): launchd_install(force) @@ -2144,7 +2166,11 @@ def gateway_command(args): managed_error("uninstall gateway service (managed by NixOS)") return system = getattr(args, 'system', False) - if is_linux(): + if is_termux(): + print("Gateway service uninstall is not supported on Termux because there is no managed service to remove.") + print("Stop manual runs with: hermes gateway stop") + sys.exit(1) + if supports_systemd_services(): systemd_uninstall(system=system) elif is_macos(): launchd_uninstall() @@ -2154,7 +2180,11 @@ def gateway_command(args): elif subcmd == "start": system = getattr(args, 'system', False) - if is_linux(): + if is_termux(): + print("Gateway service start is not supported on Termux because there is no system service manager.") + print("Run manually: hermes gateway") + sys.exit(1) + if supports_systemd_services(): systemd_start(system=system) elif is_macos(): launchd_start() @@ -2169,7 +2199,7 @@ def gateway_command(args): if stop_all: # --all: kill every gateway process on the machine service_available = False - if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): try: systemd_stop(system=system) service_available = True @@ -2190,7 +2220,7 @@ def gateway_command(args): else: # Default: stop only the current profile's gateway service_available = False - if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): try: systemd_stop(system=system) service_available = True @@ -2218,7 +2248,7 @@ def gateway_command(args): system = getattr(args, 'system', False) service_configured = False - if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): service_configured = True try: systemd_restart(system=system) @@ -2235,7 +2265,7 @@ def gateway_command(args): if not service_available: # systemd/launchd restart failed — check if linger is the issue - if is_linux(): + if supports_systemd_services(): linger_ok, _detail = get_systemd_linger_status() if linger_ok is not True: import getpass @@ -2272,7 +2302,7 @@ def gateway_command(args): system = getattr(args, 'system', False) # Check for service first - if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): systemd_status(deep, system=system) elif is_macos() and get_launchd_plist_path().exists(): launchd_status(deep) @@ -2289,9 +2319,13 @@ def gateway_command(args): for line in runtime_lines: print(f" {line}") print() - print("To install as a service:") - print(" hermes gateway install") - print(" sudo hermes gateway install --system") + if is_termux(): + print("Termux note:") + print(" Android may stop background jobs when Termux is suspended") + else: + print("To install as a service:") + print(" hermes gateway install") + print(" sudo hermes gateway install --system") else: print("✗ Gateway is not running") runtime_lines = _runtime_health_lines() @@ -2303,5 +2337,8 @@ def gateway_command(args): print() print("To start:") print(" hermes gateway # Run in foreground") - print(" hermes gateway install # Install as user service") - print(" sudo hermes gateway install --system # Install as boot-time system service") + if is_termux(): + print(" nohup hermes gateway > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start") + else: + print(" hermes gateway install # Install as user service") + print(" sudo hermes gateway install --system # Install as boot-time system service") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e7be054..5a6e5867 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3763,7 +3763,7 @@ def cmd_update(args): # running gateway needs restarting to pick up the new code. try: from hermes_cli.gateway import ( - is_macos, is_linux, _ensure_user_systemd_env, + is_macos, supports_systemd_services, _ensure_user_systemd_env, find_gateway_pids, _get_service_pids, ) @@ -3774,7 +3774,7 @@ def cmd_update(args): # --- Systemd services (Linux) --- # Discover all hermes-gateway* units (default + profiles) - if is_linux(): + if supports_systemd_services(): try: _ensure_user_systemd_env() except Exception: diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index fa49e3c9..7ab154af 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -122,6 +122,10 @@ def uninstall_gateway_service(): if platform.system() != "Linux": return False + + prefix = os.getenv("PREFIX", "") + if os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix: + return False try: from hermes_cli.gateway import get_service_name diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 11c21363..885597e3 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -10,6 +10,7 @@ import hermes_cli.gateway as gateway class TestSystemdLingerStatus: def test_reports_enabled(self, monkeypatch): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setenv("USER", "alice") monkeypatch.setattr( gateway.subprocess, @@ -22,6 +23,7 @@ class TestSystemdLingerStatus: def test_reports_disabled(self, monkeypatch): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setenv("USER", "alice") monkeypatch.setattr( gateway.subprocess, @@ -32,6 +34,11 @@ class TestSystemdLingerStatus: assert gateway.get_systemd_linger_status() == (False, "") + def test_reports_termux_as_not_supported(self, monkeypatch): + monkeypatch.setattr(gateway, "is_termux", lambda: True) + + assert gateway.get_systemd_linger_status() == (None, "not supported in Termux") + def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys): unit_path = tmp_path / "hermes-gateway.service" diff --git a/tests/hermes_cli/test_gateway_linger.py b/tests/hermes_cli/test_gateway_linger.py index 3dacea66..90f8ea3d 100644 --- a/tests/hermes_cli/test_gateway_linger.py +++ b/tests/hermes_cli/test_gateway_linger.py @@ -8,6 +8,7 @@ import hermes_cli.gateway as gateway class TestEnsureLingerEnabled: def test_linger_already_enabled_via_file(self, monkeypatch, capsys): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setattr("getpass.getuser", lambda: "testuser") monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: True)) @@ -22,6 +23,7 @@ class TestEnsureLingerEnabled: def test_status_enabled_skips_enable(self, monkeypatch, capsys): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setattr("getpass.getuser", lambda: "testuser") monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False)) monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (True, "")) @@ -37,6 +39,7 @@ class TestEnsureLingerEnabled: def test_loginctl_success_enables_linger(self, monkeypatch, capsys): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setattr("getpass.getuser", lambda: "testuser") monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False)) monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, "")) @@ -59,6 +62,7 @@ class TestEnsureLingerEnabled: def test_missing_loginctl_shows_manual_guidance(self, monkeypatch, capsys): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setattr("getpass.getuser", lambda: "testuser") monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False)) monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (None, "loginctl not found")) @@ -76,6 +80,7 @@ class TestEnsureLingerEnabled: def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys): monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setattr(gateway, "is_termux", lambda: False) monkeypatch.setattr("getpass.getuser", lambda: "testuser") monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False)) monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, "")) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 739d4500..aa21793a 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -109,7 +109,8 @@ class TestGatewayStopCleanup: unit_path = tmp_path / "hermes-gateway.service" unit_path.write_text("unit\n", encoding="utf-8") - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) @@ -134,7 +135,8 @@ class TestGatewayStopCleanup: unit_path = tmp_path / "hermes-gateway.service" unit_path.write_text("unit\n", encoding="utf-8") - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) @@ -256,7 +258,8 @@ class TestGatewayServiceDetection: user_unit = SimpleNamespace(exists=lambda: True) system_unit = SimpleNamespace(exists=lambda: True) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr( gateway_cli, @@ -278,7 +281,8 @@ class TestGatewayServiceDetection: class TestGatewaySystemServiceRouting: def test_gateway_install_passes_system_flags(self, monkeypatch): - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) calls = [] @@ -294,11 +298,30 @@ class TestGatewaySystemServiceRouting: assert calls == [(True, True, "alice")] + def test_gateway_install_reports_termux_manual_mode(self, monkeypatch, capsys): + monkeypatch.setattr(gateway_cli, "is_termux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + + try: + gateway_cli.gateway_command( + SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None) + ) + except SystemExit as exc: + assert exc.code == 1 + else: + raise AssertionError("Expected gateway_command to exit on unsupported Termux service install") + + out = capsys.readouterr().out + assert "not supported on Termux" in out + assert "Run manually: hermes gateway" in out + def test_gateway_status_prefers_system_service_when_only_system_unit_exists(self, monkeypatch): user_unit = SimpleNamespace(exists=lambda: False) system_unit = SimpleNamespace(exists=lambda: True) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr( gateway_cli, @@ -313,6 +336,20 @@ class TestGatewaySystemServiceRouting: assert calls == [(False, False)] + def test_gateway_status_on_termux_shows_manual_guidance(self, monkeypatch, capsys): + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: True) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "find_gateway_pids", lambda exclude_pids=None: []) + monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: []) + + gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False)) + + out = capsys.readouterr().out + assert "Gateway is not running" in out + assert "nohup hermes gateway" in out + assert "install as user service" not in out + def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text("plist\n", encoding="utf-8") @@ -513,12 +550,22 @@ class TestGeneratedUnitUsesDetectedVenv: class TestGeneratedUnitIncludesLocalBin: """~/.local/bin must be in PATH so uvx/pipx tools are discoverable.""" - def test_user_unit_includes_local_bin_in_path(self): + def test_user_unit_includes_local_bin_in_path(self, monkeypatch): + home = Path.home() + monkeypatch.setattr( + gateway_cli, + "_build_user_local_paths", + lambda home_path, existing: [str(home / ".local" / "bin")], + ) unit = gateway_cli.generate_systemd_unit(system=False) - home = str(Path.home()) assert f"{home}/.local/bin" in unit - def test_system_unit_includes_local_bin_in_path(self): + def test_system_unit_includes_local_bin_in_path(self, monkeypatch): + monkeypatch.setattr( + gateway_cli, + "_build_user_local_paths", + lambda home_path, existing: [str(home_path / ".local" / "bin")], + ) unit = gateway_cli.generate_systemd_unit(system=True) # System unit uses the resolved home dir from _system_service_identity assert "/.local/bin" in unit diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index e4c8e922..ceb05f65 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -368,9 +368,8 @@ class TestCmdUpdateLaunchdRestart: monkeypatch.setattr( gateway_cli, "is_macos", lambda: False, ) - monkeypatch.setattr( - gateway_cli, "is_linux", lambda: True, - ) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) mock_run.side_effect = _make_run_side_effect( commit_count="3", @@ -429,7 +428,8 @@ class TestCmdUpdateSystemService: ): """When user systemd is inactive but a system service exists, restart via system scope.""" monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) mock_run.side_effect = _make_run_side_effect( commit_count="3", @@ -458,7 +458,8 @@ class TestCmdUpdateSystemService: ): """When system service restart fails, show the failure message.""" monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) mock_run.side_effect = _make_run_side_effect( commit_count="3", @@ -480,7 +481,8 @@ class TestCmdUpdateSystemService: ): """When both user and system services are active, both are restarted.""" monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) mock_run.side_effect = _make_run_side_effect( commit_count="3", @@ -563,7 +565,8 @@ class TestServicePidExclusion: ): """After systemd restart, the sweep must exclude the service PID.""" monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) SERVICE_PID = 55000 @@ -642,7 +645,8 @@ class TestGetServicePids: """Unit tests for _get_service_pids().""" def test_returns_systemd_main_pid(self, monkeypatch): - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) def fake_run(cmd, **kwargs): @@ -691,7 +695,8 @@ class TestGetServicePids: def test_excludes_zero_pid(self, monkeypatch): """systemd returns MainPID=0 for stopped services; skip those.""" - monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) def fake_run(cmd, **kwargs): From 21944259184ffa1f2a299ba22a0dbaa4b1ed3bb1 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:41:58 +0200 Subject: [PATCH 41/51] fix(termux): make setup-hermes use android path --- setup-hermes.sh | 332 ++++++++++++------- tests/hermes_cli/test_setup_hermes_script.py | 21 ++ 2 files changed, 233 insertions(+), 120 deletions(-) create mode 100644 tests/hermes_cli/test_setup_hermes_script.py diff --git a/setup-hermes.sh b/setup-hermes.sh index d2a1b12e..5d0f2928 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -3,17 +3,17 @@ # Hermes Agent Setup Script # ============================================================================ # Quick setup for developers who cloned the repo manually. -# Uses uv for fast Python provisioning and package management. +# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux. # # Usage: # ./setup-hermes.sh # # This script: -# 1. Installs uv if not present -# 2. Creates a virtual environment with Python 3.11 via uv -# 3. Installs all dependencies (main package + submodules) +# 1. Detects desktop/server vs Android/Termux setup path +# 2. Creates a Python 3.11 virtual environment +# 3. Installs the appropriate dependency set for the platform # 4. Creates .env from template (if not exists) -# 5. Symlinks the 'hermes' CLI command into ~/.local/bin +# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir # 6. Runs the setup wizard (optional) # ============================================================================ @@ -31,6 +31,26 @@ cd "$SCRIPT_DIR" PYTHON_VERSION="3.11" +is_termux() { + [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] +} + +get_command_link_dir() { + if is_termux && [ -n "${PREFIX:-}" ]; then + echo "$PREFIX/bin" + else + echo "$HOME/.local/bin" + fi +} + +get_command_link_display_dir() { + if is_termux && [ -n "${PREFIX:-}" ]; then + echo '$PREFIX/bin' + else + echo '~/.local/bin' + fi +} + echo "" echo -e "${CYAN}⚕ Hermes Agent Setup${NC}" echo "" @@ -42,36 +62,40 @@ echo "" echo -e "${CYAN}→${NC} Checking for uv..." UV_CMD="" -if command -v uv &> /dev/null; then - UV_CMD="uv" -elif [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" -elif [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" -fi - -if [ -n "$UV_CMD" ]; then - UV_VERSION=$($UV_CMD --version 2>/dev/null) - echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)" +if is_termux; then + echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv" else - echo -e "${CYAN}→${NC} Installing uv..." - if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then - if [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" - elif [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - fi - - if [ -n "$UV_CMD" ]; then - UV_VERSION=$($UV_CMD --version 2>/dev/null) - echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)" + if command -v uv &> /dev/null; then + UV_CMD="uv" + elif [ -x "$HOME/.local/bin/uv" ]; then + UV_CMD="$HOME/.local/bin/uv" + elif [ -x "$HOME/.cargo/bin/uv" ]; then + UV_CMD="$HOME/.cargo/bin/uv" + fi + + if [ -n "$UV_CMD" ]; then + UV_VERSION=$($UV_CMD --version 2>/dev/null) + echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)" + else + echo -e "${CYAN}→${NC} Installing uv..." + if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then + if [ -x "$HOME/.local/bin/uv" ]; then + UV_CMD="$HOME/.local/bin/uv" + elif [ -x "$HOME/.cargo/bin/uv" ]; then + UV_CMD="$HOME/.cargo/bin/uv" + fi + + if [ -n "$UV_CMD" ]; then + UV_VERSION=$($UV_CMD --version 2>/dev/null) + echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)" + else + echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry." + exit 1 + fi else - echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry." + echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/" exit 1 fi - else - echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/" - exit 1 fi fi @@ -81,16 +105,34 @@ fi echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..." -if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then - PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") - PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) - echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found" +if is_termux; then + if command -v python >/dev/null 2>&1; then + PYTHON_PATH="$(command -v python)" + if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then + PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) + echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found" + else + echo -e "${RED}✗${NC} Termux Python must be 3.11+" + echo " Run: pkg install python" + exit 1 + fi + else + echo -e "${RED}✗${NC} Python not found in Termux" + echo " Run: pkg install python" + exit 1 + fi else - echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..." - $UV_CMD python install "$PYTHON_VERSION" - PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") - PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) - echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed" + if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then + PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") + PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) + echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found" + else + echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..." + $UV_CMD python install "$PYTHON_VERSION" + PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") + PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) + echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed" + fi fi # ============================================================================ @@ -104,11 +146,16 @@ if [ -d "venv" ]; then rm -rf venv fi -$UV_CMD venv venv --python "$PYTHON_VERSION" -echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)" +if is_termux; then + "$PYTHON_PATH" -m venv venv + echo -e "${GREEN}✓${NC} venv created with stdlib venv" +else + $UV_CMD venv venv --python "$PYTHON_VERSION" + echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)" +fi -# Tell uv to install into this venv (no activation needed for uv) export VIRTUAL_ENV="$SCRIPT_DIR/venv" +SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python" # ============================================================================ # Dependencies @@ -116,19 +163,34 @@ export VIRTUAL_ENV="$SCRIPT_DIR/venv" echo -e "${CYAN}→${NC} Installing dependencies..." -# Prefer uv sync with lockfile (hash-verified installs) when available, -# fall back to pip install for compatibility or when lockfile is stale. -if [ -f "uv.lock" ]; then - echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \ - echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || { - echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..." +if is_termux; then + export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || printf '%s' "${ANDROID_API_LEVEL:-}")" + echo -e "${CYAN}→${NC} Termux detected — installing the tested Android bundle" + "$SETUP_PYTHON" -m pip install --upgrade pip setuptools wheel + if [ -f "constraints-termux.txt" ]; then + "$SETUP_PYTHON" -m pip install -e ".[termux]" -c constraints-termux.txt || { + echo -e "${YELLOW}⚠${NC} Termux bundle install failed, falling back to base install..." + "$SETUP_PYTHON" -m pip install -e "." -c constraints-termux.txt + } + else + "$SETUP_PYTHON" -m pip install -e ".[termux]" || "$SETUP_PYTHON" -m pip install -e "." + fi + echo -e "${GREEN}✓${NC} Dependencies installed" +else + # Prefer uv sync with lockfile (hash-verified installs) when available, + # fall back to pip install for compatibility or when lockfile is stale. + if [ -f "uv.lock" ]; then + echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." + UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \ + echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || { + echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..." + $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." + echo -e "${GREEN}✓${NC} Dependencies installed" + } + else $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." echo -e "${GREEN}✓${NC} Dependencies installed" - } -else - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" + fi fi # ============================================================================ @@ -138,7 +200,9 @@ fi echo -e "${CYAN}→${NC} Installing optional submodules..." # tinker-atropos (RL training backend) -if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then +if is_termux; then + echo -e "${CYAN}→${NC} Skipping tinker-atropos on Termux (not part of the tested Android path)" +elif [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then $UV_CMD pip install -e "./tinker-atropos" && \ echo -e "${GREEN}✓${NC} tinker-atropos installed" || \ echo -e "${YELLOW}⚠${NC} tinker-atropos install failed (RL tools may not work)" @@ -160,34 +224,42 @@ else echo if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then INSTALLED=false - - # Check if sudo is available - if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - if command -v apt &> /dev/null; then - sudo apt install -y ripgrep && INSTALLED=true - elif command -v dnf &> /dev/null; then - sudo dnf install -y ripgrep && INSTALLED=true + + if is_termux; then + pkg install -y ripgrep && INSTALLED=true + else + # Check if sudo is available + if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then + if command -v apt &> /dev/null; then + sudo apt install -y ripgrep && INSTALLED=true + elif command -v dnf &> /dev/null; then + sudo dnf install -y ripgrep && INSTALLED=true + fi + fi + + # Try brew (no sudo needed) + if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then + brew install ripgrep && INSTALLED=true + fi + + # Try cargo (no sudo needed) + if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then + echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..." + cargo install ripgrep && INSTALLED=true fi fi - - # Try brew (no sudo needed) - if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then - brew install ripgrep && INSTALLED=true - fi - - # Try cargo (no sudo needed) - if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then - echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..." - cargo install ripgrep && INSTALLED=true - fi - + if [ "$INSTALLED" = true ]; then echo -e "${GREEN}✓${NC} ripgrep installed" else echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:" - echo " sudo apt install ripgrep # Debian/Ubuntu" - echo " brew install ripgrep # macOS" - echo " cargo install ripgrep # With Rust (no sudo)" + if is_termux; then + echo " pkg install ripgrep # Termux / Android" + else + echo " sudo apt install ripgrep # Debian/Ubuntu" + echo " brew install ripgrep # macOS" + echo " cargo install ripgrep # With Rust (no sudo)" + fi echo " https://github.com/BurntSushi/ripgrep#installation" fi fi @@ -207,49 +279,56 @@ else fi # ============================================================================ -# PATH setup — symlink hermes into ~/.local/bin +# PATH setup — symlink hermes into a user-facing bin dir # ============================================================================ echo -e "${CYAN}→${NC} Setting up hermes command..." HERMES_BIN="$SCRIPT_DIR/venv/bin/hermes" -mkdir -p "$HOME/.local/bin" -ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes" -echo -e "${GREEN}✓${NC} Symlinked hermes → ~/.local/bin/hermes" +COMMAND_LINK_DIR="$(get_command_link_dir)" +COMMAND_LINK_DISPLAY_DIR="$(get_command_link_display_dir)" +mkdir -p "$COMMAND_LINK_DIR" +ln -sf "$HERMES_BIN" "$COMMAND_LINK_DIR/hermes" +echo -e "${GREEN}✓${NC} Symlinked hermes → $COMMAND_LINK_DISPLAY_DIR/hermes" -# Determine the appropriate shell config file -SHELL_CONFIG="" -if [[ "$SHELL" == *"zsh"* ]]; then - SHELL_CONFIG="$HOME/.zshrc" -elif [[ "$SHELL" == *"bash"* ]]; then - SHELL_CONFIG="$HOME/.bashrc" - [ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile" +if is_termux; then + export PATH="$COMMAND_LINK_DIR:$PATH" + echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux" else - # Fallback to checking existing files - if [ -f "$HOME/.zshrc" ]; then + # Determine the appropriate shell config file + SHELL_CONFIG="" + if [[ "$SHELL" == *"zsh"* ]]; then SHELL_CONFIG="$HOME/.zshrc" - elif [ -f "$HOME/.bashrc" ]; then + elif [[ "$SHELL" == *"bash"* ]]; then SHELL_CONFIG="$HOME/.bashrc" - elif [ -f "$HOME/.bash_profile" ]; then - SHELL_CONFIG="$HOME/.bash_profile" - fi -fi - -if [ -n "$SHELL_CONFIG" ]; then - # Touch the file just in case it doesn't exist yet but was selected - touch "$SHELL_CONFIG" 2>/dev/null || true - - if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then - if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then - echo "" >> "$SHELL_CONFIG" - echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG" - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" - echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG" - else - echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG" - fi + [ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile" else - echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH" + # Fallback to checking existing files + if [ -f "$HOME/.zshrc" ]; then + SHELL_CONFIG="$HOME/.zshrc" + elif [ -f "$HOME/.bashrc" ]; then + SHELL_CONFIG="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + SHELL_CONFIG="$HOME/.bash_profile" + fi + fi + + if [ -n "$SHELL_CONFIG" ]; then + # Touch the file just in case it doesn't exist yet but was selected + touch "$SHELL_CONFIG" 2>/dev/null || true + + if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then + if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then + echo "" >> "$SHELL_CONFIG" + echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG" + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" + echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG" + else + echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG" + fi + else + echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH" + fi fi fi @@ -281,18 +360,31 @@ echo -e "${GREEN}✓ Setup complete!${NC}" echo "" echo "Next steps:" echo "" -echo " 1. Reload your shell:" -echo " source $SHELL_CONFIG" -echo "" -echo " 2. Run the setup wizard to configure API keys:" -echo " hermes setup" -echo "" -echo " 3. Start chatting:" -echo " hermes" -echo "" +if is_termux; then + echo " 1. Run the setup wizard to configure API keys:" + echo " hermes setup" + echo "" + echo " 2. Start chatting:" + echo " hermes" + echo "" +else + echo " 1. Reload your shell:" + echo " source $SHELL_CONFIG" + echo "" + echo " 2. Run the setup wizard to configure API keys:" + echo " hermes setup" + echo "" + echo " 3. Start chatting:" + echo " hermes" + echo "" +fi echo "Other commands:" echo " hermes status # Check configuration" -echo " hermes gateway install # Install gateway service (messaging + cron)" +if is_termux; then + echo " hermes gateway # Run gateway in foreground" +else + echo " hermes gateway install # Install gateway service (messaging + cron)" +fi echo " hermes cron list # View scheduled jobs" echo " hermes doctor # Diagnose issues" echo "" diff --git a/tests/hermes_cli/test_setup_hermes_script.py b/tests/hermes_cli/test_setup_hermes_script.py new file mode 100644 index 00000000..7978e660 --- /dev/null +++ b/tests/hermes_cli/test_setup_hermes_script.py @@ -0,0 +1,21 @@ +from pathlib import Path +import subprocess + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SETUP_SCRIPT = REPO_ROOT / "setup-hermes.sh" + + +def test_setup_hermes_script_is_valid_shell(): + result = subprocess.run(["bash", "-n", str(SETUP_SCRIPT)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_setup_hermes_script_has_termux_path(): + content = SETUP_SCRIPT.read_text(encoding="utf-8") + + assert "is_termux()" in content + assert ".[termux]" in content + assert "constraints-termux.txt" in content + assert "$PREFIX/bin" in content + assert "Skipping tinker-atropos on Termux" in content From 4970705ed383118bae290fa56745468272735138 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:08:46 +0200 Subject: [PATCH 42/51] fix(termux): silence quiet chat tool previews --- run_agent.py | 35 ++++++++++++++++++++----------- tests/run_agent/test_run_agent.py | 29 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/run_agent.py b/run_agent.py index db3f4b31..fcaa67f6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1486,6 +1486,17 @@ class AIAgent: except (AttributeError, ValueError, OSError): return False + def _should_emit_quiet_tool_messages(self) -> bool: + """Return True when quiet-mode tool summaries should print directly. + + When the caller provides ``tool_progress_callback`` (for example the CLI + TUI or a gateway progress renderer), that callback owns progress display. + Emitting quiet-mode summary lines here duplicates progress and leaks tool + previews into flows that are expected to stay silent, such as + ``hermes chat -q``. + """ + return self.quiet_mode and not self.tool_progress_callback + def _emit_status(self, message: str) -> None: """Emit a lifecycle status message to both CLI and gateway channels. @@ -6347,7 +6358,7 @@ class AIAgent: # Start spinner for CLI mode (skip when TUI handles tool progress) spinner = None - if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner(): + if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.KAWAII_WAITING) spinner = KawaiiSpinner(f"{face} ⚡ running {num_tools} tools concurrently", spinner_type='dots', print_fn=self._print_fn) spinner.start() @@ -6397,7 +6408,7 @@ class AIAgent: logging.debug(f"Tool result ({len(function_result)} chars): {function_result}") # Print cute message per tool - if self.quiet_mode: + if self._should_emit_quiet_tool_messages(): cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) self._safe_print(f" {cute_msg}") elif not self.quiet_mode: @@ -6554,7 +6565,7 @@ class AIAgent: store=self._todo_store, ) tool_duration = time.time() - tool_start_time - if self.quiet_mode: + if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}") elif function_name == "session_search": if not self._session_db: @@ -6569,7 +6580,7 @@ class AIAgent: current_session_id=self.session_id, ) tool_duration = time.time() - tool_start_time - if self.quiet_mode: + if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}") elif function_name == "memory": target = function_args.get("target", "memory") @@ -6582,7 +6593,7 @@ class AIAgent: store=self._memory_store, ) tool_duration = time.time() - tool_start_time - if self.quiet_mode: + if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}") elif function_name == "clarify": from tools.clarify_tool import clarify_tool as _clarify_tool @@ -6592,7 +6603,7 @@ class AIAgent: callback=self.clarify_callback, ) tool_duration = time.time() - tool_start_time - if self.quiet_mode: + if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}") elif function_name == "delegate_task": from tools.delegate_tool import delegate_task as _delegate_task @@ -6603,7 +6614,7 @@ class AIAgent: goal_preview = (function_args.get("goal") or "")[:30] spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating" spinner = None - if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner(): + if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.KAWAII_WAITING) spinner = KawaiiSpinner(f"{face} {spinner_label}", spinner_type='dots', print_fn=self._print_fn) spinner.start() @@ -6625,13 +6636,13 @@ class AIAgent: cute_msg = _get_cute_tool_message_impl('delegate_task', function_args, tool_duration, result=_delegate_result) if spinner: spinner.stop(cute_msg) - elif self.quiet_mode: + elif self._should_emit_quiet_tool_messages(): self._vprint(f" {cute_msg}") elif self._memory_manager and self._memory_manager.has_tool(function_name): # Memory provider tools (hindsight_retain, honcho_search, etc.) # These are not in the tool registry — route through MemoryManager. spinner = None - if self.quiet_mode and not self.tool_progress_callback: + if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.KAWAII_WAITING) emoji = _get_tool_emoji(function_name) preview = _build_tool_preview(function_name, function_args) or function_name @@ -6649,11 +6660,11 @@ class AIAgent: cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_mem_result) if spinner: spinner.stop(cute_msg) - elif self.quiet_mode: + elif self._should_emit_quiet_tool_messages(): self._vprint(f" {cute_msg}") elif self.quiet_mode: spinner = None - if not self.tool_progress_callback: + if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.KAWAII_WAITING) emoji = _get_tool_emoji(function_name) preview = _build_tool_preview(function_name, function_args) or function_name @@ -6676,7 +6687,7 @@ class AIAgent: cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_spinner_result) if spinner: spinner.stop(cute_msg) - else: + elif self._should_emit_quiet_tool_messages(): self._vprint(f" {cute_msg}") else: try: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 98d799ae..e58170c8 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1061,6 +1061,35 @@ class TestExecuteToolCalls: assert len(messages[0]["content"]) < 150_000 assert ("Truncated" in messages[0]["content"] or "" in messages[0]["content"]) + def test_quiet_tool_output_suppressed_when_progress_callback_present(self, agent): + tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) + messages = [] + agent.tool_progress_callback = lambda *args, **kwargs: None + + with patch("run_agent.handle_function_call", return_value="search result"), \ + patch.object(agent, "_safe_print") as mock_print: + agent._execute_tool_calls(mock_msg, messages, "task-1") + + mock_print.assert_not_called() + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + + def test_quiet_tool_output_prints_without_progress_callback(self, agent): + tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) + messages = [] + agent.tool_progress_callback = None + + with patch("run_agent.handle_function_call", return_value="search result"), \ + patch.object(agent, "_safe_print") as mock_print: + agent._execute_tool_calls(mock_msg, messages, "task-1") + + mock_print.assert_called_once() + assert "search" in str(mock_print.call_args.args[0]).lower() + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + class TestConcurrentToolExecution: """Tests for _execute_tool_calls_concurrent and dispatch logic.""" From a3aed1bd26040479aae970f22d683f373572bc92 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:31:07 +0200 Subject: [PATCH 43/51] fix(termux): keep quiet chat output parseable --- cli.py | 1 + run_agent.py | 8 +++++++ tests/run_agent/test_run_agent.py | 40 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/cli.py b/cli.py index 1ca28606..4a4ce15b 100644 --- a/cli.py +++ b/cli.py @@ -8724,6 +8724,7 @@ def main( route_label=turn_route["label"], ): cli.agent.quiet_mode = True + cli.agent.suppress_status_output = True result = cli.agent.run_conversation( user_message=query, conversation_history=cli.conversation_history, diff --git a/run_agent.py b/run_agent.py index fcaa67f6..bbd5a854 100644 --- a/run_agent.py +++ b/run_agent.py @@ -622,6 +622,7 @@ class AIAgent: self.tool_progress_callback = tool_progress_callback self.tool_start_callback = tool_start_callback self.tool_complete_callback = tool_complete_callback + self.suppress_status_output = False self.thinking_callback = thinking_callback self.reasoning_callback = reasoning_callback self._reasoning_deltas_fired = False # Set by _fire_reasoning_delta, reset per API call @@ -1460,7 +1461,14 @@ class AIAgent: After the main response has been delivered and the remaining tool calls are post-response housekeeping (``_mute_post_response``), all non-forced output is suppressed. + + ``suppress_status_output`` is a stricter CLI automation mode used by + parseable single-query flows such as ``hermes chat -q``. In that mode, + all status/diagnostic prints routed through ``_vprint`` are suppressed + so stdout stays machine-readable. """ + if getattr(self, "suppress_status_output", False): + return if not force and getattr(self, "_mute_post_response", False): return if not force and self._has_stream_consumers() and not self._executing_tools: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e58170c8..438612a3 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1090,6 +1090,46 @@ class TestExecuteToolCalls: assert len(messages) == 1 assert messages[0]["role"] == "tool" + def test_vprint_suppressed_in_parseable_quiet_mode(self, agent): + agent.suppress_status_output = True + + with patch.object(agent, "_safe_print") as mock_print: + agent._vprint("status line", force=True) + agent._vprint("normal line") + + mock_print.assert_not_called() + + def test_run_conversation_suppresses_retry_noise_in_parseable_quiet_mode(self, agent): + class _RateLimitError(Exception): + status_code = 429 + + def __str__(self): + return "Error code: 429 - Rate limit exceeded." + + responses = [_RateLimitError(), _mock_response(content="Recovered")] + + def _fake_api_call(api_kwargs): + result = responses.pop(0) + if isinstance(result, Exception): + raise result + return result + + agent.suppress_status_output = True + agent._interruptible_api_call = _fake_api_call + agent._persist_session = lambda *args, **kwargs: None + agent._save_trajectory = lambda *args, **kwargs: None + agent._save_session_log = lambda *args, **kwargs: None + + with patch("run_agent.time.sleep", return_value=None), \ + patch.object(agent, "_vprint") as mock_vprint: + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert result["final_response"] == "Recovered" + rendered = [" ".join(str(arg) for arg in call.args) for call in mock_vprint.call_args_list] + assert not any("API call failed" in line for line in rendered) + assert not any("Rate limit reached" in line for line in rendered) + class TestConcurrentToolExecution: """Tests for _execute_tool_calls_concurrent and dispatch logic.""" From 096b3f9f12abf6d7e31d5d622b55a46092d8bed7 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:09:11 +0200 Subject: [PATCH 44/51] fix(termux): add local image chat route --- cli.py | 277 ++++++++++++++++++---- hermes_cli/commands.py | 2 + hermes_cli/main.py | 5 + tests/cli/test_cli_file_drop.py | 14 ++ tests/cli/test_cli_image_command.py | 98 ++++++++ tests/hermes_cli/test_chat_skills_flag.py | 24 ++ 6 files changed, 380 insertions(+), 40 deletions(-) create mode 100644 tests/cli/test_cli_image_command.py diff --git a/cli.py b/cli.py index 4a4ce15b..37aa8a7c 100644 --- a/cli.py +++ b/cli.py @@ -1008,7 +1008,7 @@ def _cprint(text: str): # --------------------------------------------------------------------------- -# File-drop detection — extracted as a pure function for testability. +# File-drop / local attachment detection — extracted as pure helpers for tests. # --------------------------------------------------------------------------- _IMAGE_EXTENSIONS = frozenset({ @@ -1017,12 +1017,91 @@ _IMAGE_EXTENSIONS = frozenset({ }) -def _detect_file_drop(user_input: str) -> "dict | None": - """Detect if *user_input* is a dragged/pasted file path, not a slash command. +def _is_termux_environment() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) - When a user drags a file into the terminal, macOS pastes the absolute path - (e.g. ``/Users/roland/Desktop/file.png``) which starts with ``/`` and would - otherwise be mistaken for a slash command. + +def _split_path_input(raw: str) -> tuple[str, str]: + """Split a leading file path token from trailing free-form text. + + Supports quoted paths and backslash-escaped spaces so callers can accept + inputs like: + /tmp/pic.png describe this + ~/storage/shared/My\ Photos/cat.png what is this? + "/storage/emulated/0/DCIM/Camera/cat 1.png" summarize + """ + raw = str(raw or "").strip() + if not raw: + return "", "" + + if raw[0] in {'"', "'"}: + quote = raw[0] + pos = 1 + while pos < len(raw): + ch = raw[pos] + if ch == '\\' and pos + 1 < len(raw): + pos += 2 + continue + if ch == quote: + token = raw[1:pos] + remainder = raw[pos + 1 :].strip() + return token, remainder + pos += 1 + return raw[1:], "" + + pos = 0 + while pos < len(raw): + ch = raw[pos] + if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ': + pos += 2 + elif ch == ' ': + break + else: + pos += 1 + + token = raw[:pos].replace('\\ ', ' ') + remainder = raw[pos:].strip() + return token, remainder + + +def _resolve_attachment_path(raw_path: str) -> Path | None: + """Resolve a user-supplied local attachment path. + + Accepts quoted or unquoted paths, expands ``~`` and env vars, and resolves + relative paths from ``TERMINAL_CWD`` when set (matching terminal tool cwd). + Returns ``None`` when the path does not resolve to an existing file. + """ + token = str(raw_path or "").strip() + if not token: + return None + + if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")): + token = token[1:-1].strip() + if not token: + return None + + expanded = os.path.expandvars(os.path.expanduser(token)) + path = Path(expanded) + if not path.is_absolute(): + base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd())) + path = base_dir / path + + try: + resolved = path.resolve() + except Exception: + resolved = path + + if not resolved.exists() or not resolved.is_file(): + return None + return resolved + + +def _detect_file_drop(user_input: str) -> "dict | None": + """Detect if *user_input* starts with a real local file path. + + This catches dragged/pasted paths before they are mistaken for slash + commands, and also supports Termux-friendly paths like ``~/storage/...``. Returns a dict on match:: @@ -1034,29 +1113,31 @@ def _detect_file_drop(user_input: str) -> "dict | None": Returns ``None`` when the input is not a real file path. """ - if not isinstance(user_input, str) or not user_input.startswith("/"): + if not isinstance(user_input, str): return None - # Walk the string absorbing backslash-escaped spaces ("\ "). - raw = user_input - pos = 0 - while pos < len(raw): - ch = raw[pos] - if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ': - pos += 2 # skip escaped space - elif ch == ' ': - break - else: - pos += 1 - - first_token_raw = raw[:pos] - first_token = first_token_raw.replace('\\ ', ' ') - drop_path = Path(first_token) - - if not drop_path.exists() or not drop_path.is_file(): + stripped = user_input.strip() + if not stripped: + return None + + starts_like_path = ( + stripped.startswith("/") + or stripped.startswith("~") + or stripped.startswith("./") + or stripped.startswith("../") + or stripped.startswith('"/') + or stripped.startswith('"~') + or stripped.startswith("'/") + or stripped.startswith("'~") + ) + if not starts_like_path: + return None + + first_token, remainder = _split_path_input(stripped) + drop_path = _resolve_attachment_path(first_token) + if drop_path is None: return None - remainder = raw[pos:].strip() return { "path": drop_path, "is_image": drop_path.suffix.lower() in _IMAGE_EXTENSIONS, @@ -1064,6 +1145,69 @@ def _detect_file_drop(user_input: str) -> "dict | None": } +def _format_image_attachment_badges(attached_images: list[Path], image_counter: int, width: int | None = None) -> str: + """Format the attached-image badge row for the interactive CLI. + + Narrow terminals such as Termux should get a compact summary that fits on a + single row, while wider terminals can show the classic per-image badges. + """ + if not attached_images: + return "" + + width = width or shutil.get_terminal_size((80, 24)).columns + + def _trunc(name: str, limit: int) -> str: + return name if len(name) <= limit else name[: max(1, limit - 3)] + "..." + + if width < 52: + if len(attached_images) == 1: + return f"[📎 {_trunc(attached_images[0].name, 20)}]" + return f"[📎 {len(attached_images)} images attached]" + + if width < 80: + if len(attached_images) == 1: + return f"[📎 {_trunc(attached_images[0].name, 32)}]" + first = _trunc(attached_images[0].name, 20) + extra = len(attached_images) - 1 + return f"[📎 {first}] [+{extra}]" + + base = image_counter - len(attached_images) + 1 + return " ".join( + f"[📎 Image #{base + i}]" + for i in range(len(attached_images)) + ) + + +def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]: + """Collect local image attachments for single-query CLI flows.""" + message = query or "" + images: list[Path] = [] + + if isinstance(message, str): + dropped = _detect_file_drop(message) + if dropped and dropped.get("is_image"): + images.append(dropped["path"]) + message = dropped["remainder"] or f"[User attached image: {dropped['path'].name}]" + + if image_arg: + explicit_path = _resolve_attachment_path(image_arg) + if explicit_path is None: + raise ValueError(f"Image file not found: {image_arg}") + if explicit_path.suffix.lower() not in _IMAGE_EXTENSIONS: + raise ValueError(f"Not a supported image file: {explicit_path}") + images.append(explicit_path) + + deduped: list[Path] = [] + seen: set[str] = set() + for img in images: + key = str(img) + if key in seen: + continue + seen.add(key) + deduped.append(img) + return message, deduped + + class ChatConsole: """Rich Console adapter for prompt_toolkit's patch_stdout context. @@ -2946,6 +3090,14 @@ class HermesCLI: doesn't fire for image-only clipboard content (e.g., VSCode terminal, Windows Terminal with WSL2). """ + if _is_termux_environment(): + _cprint( + f" {_DIM}Clipboard image paste is not available on Termux — " + f"use /image or paste a local image path like " + f"~/storage/shared/Pictures/cat.png{_RST}" + ) + return + from hermes_cli.clipboard import has_clipboard_image if has_clipboard_image(): if self._try_attach_clipboard_image(): @@ -2956,7 +3108,31 @@ class HermesCLI: else: _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") - def _preprocess_images_with_vision(self, text: str, images: list) -> str: + def _handle_image_command(self, cmd_original: str): + """Handle /image — attach a local image file for the next prompt.""" + raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") + if not raw_args: + hint = "~/storage/shared/Pictures/cat.png" if _is_termux_environment() else "/path/to/image.png" + _cprint(f" {_DIM}Usage: /image e.g. /image {hint}{_RST}") + return + + path_token, _remainder = _split_path_input(raw_args) + image_path = _resolve_attachment_path(path_token) + if image_path is None: + _cprint(f" {_DIM}(>_<) File not found: {path_token}{_RST}") + return + if image_path.suffix.lower() not in _IMAGE_EXTENSIONS: + _cprint(f" {_DIM}(._.) Not a supported image file: {image_path.name}{_RST}") + return + + self._attached_images.append(image_path) + _cprint(f" 📎 Attached image: {image_path.name}") + if _remainder: + _cprint(f" {_DIM}Now type your prompt (or use --image in single-query mode): {_remainder}{_RST}") + elif _is_termux_environment(): + _cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {image_path} \"What do you see?\"{_RST}") + + def _preprocess_images_with_vision(self, text: str, images: list, *, announce: bool = True) -> str: """Analyze attached images via the vision tool and return enriched text. Instead of embedding raw base64 ``image_url`` content parts in the @@ -2983,7 +3159,8 @@ class HermesCLI: if not img_path.exists(): continue size_kb = img_path.stat().st_size // 1024 - _cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}") + if announce: + _cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}") try: result_json = _asyncio.run( vision_analyze_tool(image_url=str(img_path), user_prompt=analysis_prompt) @@ -2996,21 +3173,24 @@ class HermesCLI: f"[If you need a closer look, use vision_analyze with " f"image_url: {img_path}]" ) - _cprint(f" {_DIM}✓ image analyzed{_RST}") + if announce: + _cprint(f" {_DIM}✓ image analyzed{_RST}") else: enriched_parts.append( f"[The user attached an image but it couldn't be analyzed. " f"You can try examining it with vision_analyze using " f"image_url: {img_path}]" ) - _cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}") + if announce: + _cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}") except Exception as e: enriched_parts.append( f"[The user attached an image but analysis failed ({e}). " f"You can try examining it with vision_analyze using " f"image_url: {img_path}]" ) - _cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}") + if announce: + _cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}") # Combine: vision descriptions first, then the user's original text user_text = text if isinstance(text, str) and text else "" @@ -3104,7 +3284,10 @@ class HermesCLI: _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") - _cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n") + if _is_termux_environment(): + _cprint(f" {_DIM}Attach image: /image ~/storage/shared/Pictures/cat.png or start your prompt with a local image path{_RST}\n") + else: + _cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n") def show_tools(self): """Display available tools with kawaii ASCII art.""" @@ -4550,6 +4733,8 @@ class HermesCLI: self._show_insights(cmd_original) elif canonical == "paste": self._handle_paste_command() + elif canonical == "image": + self._handle_image_command(cmd_original) elif canonical == "reload-mcp": with self._busy_command(self._slow_command_status(cmd_original)): self._reload_mcp() @@ -8066,10 +8251,9 @@ class HermesCLI: def _get_image_bar(): if not cli_ref._attached_images: return [] - base = cli_ref._image_counter - len(cli_ref._attached_images) + 1 - badges = " ".join( - f"[📎 Image #{base + i}]" - for i in range(len(cli_ref._attached_images)) + badges = _format_image_attachment_badges( + cli_ref._attached_images, + cli_ref._image_counter, ) return [("class:image-badge", f" {badges} ")] @@ -8542,6 +8726,7 @@ class HermesCLI: def main( query: str = None, q: str = None, + image: str = None, toolsets: str = None, skills: str | list[str] | tuple[str, ...] = None, model: str = None, @@ -8567,6 +8752,7 @@ def main( Args: query: Single query to execute (then exit). Alias: -q q: Shorthand for --query + image: Optional local image path to attach to a single query toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") skills: Comma-separated or repeated list of skills to preload for the session model: Model to use (default: anthropic/claude-opus-4-20250514) @@ -8587,6 +8773,7 @@ def main( python cli.py --toolsets web,terminal # Use specific toolsets python cli.py --skills hermes-agent-dev,github-auth python cli.py -q "What is Python?" # Single query mode + python cli.py -q "Describe this" --image ~/storage/shared/Pictures/cat.png python cli.py --list-tools # List tools and exit python cli.py --resume 20260225_143052_a1b2c3 # Resume session python cli.py -w # Start in isolated git worktree @@ -8709,13 +8896,21 @@ def main( atexit.register(_run_cleanup) # Handle single query mode - if query: + if query or image: + query, single_query_images = _collect_query_images(query, image) if quiet: # Quiet mode: suppress banner, spinner, tool previews. # Only print the final response and parseable session info. cli.tool_progress_mode = "off" if cli._ensure_runtime_credentials(): - turn_route = cli._resolve_turn_agent_config(query) + effective_query = query + if single_query_images: + effective_query = cli._preprocess_images_with_vision( + query, + single_query_images, + announce=False, + ) + turn_route = cli._resolve_turn_agent_config(effective_query) if turn_route["signature"] != cli._active_agent_route_signature: cli.agent = None if cli._init_agent( @@ -8726,7 +8921,7 @@ def main( cli.agent.quiet_mode = True cli.agent.suppress_status_output = True result = cli.agent.run_conversation( - user_message=query, + user_message=effective_query, conversation_history=cli.conversation_history, ) response = result.get("final_response", "") if isinstance(result, dict) else str(result) @@ -8741,8 +8936,10 @@ def main( sys.exit(1) else: cli.show_banner() - cli.console.print(f"[bold blue]Query:[/] {query}") - cli.chat(query) + _query_label = query or ("[image attached]" if single_query_images else "") + if _query_label: + cli.console.print(f"[bold blue]Query:[/] {_query_label}") + cli.chat(query, images=single_query_images or None) cli._print_exit_summary() return diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 5231dccb..9f26b4bb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -135,6 +135,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, aliases=("gateway",)), CommandDef("paste", "Check clipboard for an image and attach it", "Info", cli_only=True), + CommandDef("image", "Attach a local image file for your next prompt", "Info", + cli_only=True, args_hint=""), CommandDef("update", "Update Hermes Agent to the latest version", "Info", gateway_only=True), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5a6e5867..7d4a4a92 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -646,6 +646,7 @@ def cmd_chat(args): "verbose": args.verbose, "quiet": getattr(args, "quiet", False), "query": args.query, + "image": getattr(args, "image", None), "resume": getattr(args, "resume", None), "worktree": getattr(args, "worktree", False), "checkpoints": getattr(args, "checkpoints", False), @@ -4291,6 +4292,10 @@ For more help on a command: "-q", "--query", help="Single query (non-interactive mode)" ) + chat_parser.add_argument( + "--image", + help="Optional local image path to attach to a single query" + ) chat_parser.add_argument( "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)" diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py index 386aba5d..78503de8 100644 --- a/tests/cli/test_cli_file_drop.py +++ b/tests/cli/test_cli_file_drop.py @@ -147,6 +147,20 @@ class TestEscapedSpaces: assert result["path"] == tmp_image_with_spaces assert result["remainder"] == "what is this?" + def test_tilde_prefixed_path(self, tmp_path, monkeypatch): + home = tmp_path / "home" + img = home / "storage" / "shared" / "Pictures" / "cat.png" + img.parent.mkdir(parents=True, exist_ok=True) + img.write_bytes(b"\x89PNG\r\n\x1a\n") + monkeypatch.setenv("HOME", str(home)) + + result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?") + + assert result is not None + assert result["path"] == img + assert result["is_image"] is True + assert result["remainder"] == "what is this?" + # --------------------------------------------------------------------------- # Tests: edge cases diff --git a/tests/cli/test_cli_image_command.py b/tests/cli/test_cli_image_command.py new file mode 100644 index 00000000..7c9cef8f --- /dev/null +++ b/tests/cli/test_cli_image_command.py @@ -0,0 +1,98 @@ +from pathlib import Path +from unittest.mock import patch + +from cli import ( + HermesCLI, + _collect_query_images, + _format_image_attachment_badges, +) + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._attached_images = [] + return cli_obj + + +def _make_image(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\x89PNG\r\n\x1a\n") + return path + + +class TestImageCommand: + def test_handle_image_command_attaches_local_image(self, tmp_path): + img = _make_image(tmp_path / "photo.png") + cli_obj = _make_cli() + + with patch("cli._cprint"): + cli_obj._handle_image_command(f"/image {img}") + + assert cli_obj._attached_images == [img] + + def test_handle_image_command_supports_quoted_path_with_spaces(self, tmp_path): + img = _make_image(tmp_path / "my photo.png") + cli_obj = _make_cli() + + with patch("cli._cprint"): + cli_obj._handle_image_command(f'/image "{img}"') + + assert cli_obj._attached_images == [img] + + def test_handle_image_command_rejects_non_image_file(self, tmp_path): + file_path = tmp_path / "notes.txt" + file_path.write_text("hello\n", encoding="utf-8") + cli_obj = _make_cli() + + with patch("cli._cprint") as mock_print: + cli_obj._handle_image_command(f"/image {file_path}") + + assert cli_obj._attached_images == [] + rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args) + assert "Not a supported image file" in rendered + + +class TestCollectQueryImages: + def test_collect_query_images_accepts_explicit_image_arg(self, tmp_path): + img = _make_image(tmp_path / "diagram.png") + + message, images = _collect_query_images("describe this", str(img)) + + assert message == "describe this" + assert images == [img] + + def test_collect_query_images_extracts_leading_path(self, tmp_path): + img = _make_image(tmp_path / "camera.png") + + message, images = _collect_query_images(f"{img} what do you see?") + + assert message == "what do you see?" + assert images == [img] + + def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch): + home = tmp_path / "home" + img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png") + monkeypatch.setenv("HOME", str(home)) + + message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png") + + assert message == "describe this" + assert images == [img] + + +class TestImageBadgeFormatting: + def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path): + img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png") + + badges = _format_image_attachment_badges([img], image_counter=1, width=40) + + assert badges.startswith("[📎 ") + assert "Image #1" not in badges + + def test_compact_badges_summarize_multiple_images(self, tmp_path): + img1 = _make_image(tmp_path / "one.png") + img2 = _make_image(tmp_path / "two.png") + + badges = _format_image_attachment_badges([img1, img2], image_counter=2, width=45) + + assert badges == "[📎 2 images attached]" diff --git a/tests/hermes_cli/test_chat_skills_flag.py b/tests/hermes_cli/test_chat_skills_flag.py index 8551b410..0ec25a54 100644 --- a/tests/hermes_cli/test_chat_skills_flag.py +++ b/tests/hermes_cli/test_chat_skills_flag.py @@ -49,6 +49,30 @@ def test_chat_subcommand_accepts_skills_flag(monkeypatch): } +def test_chat_subcommand_accepts_image_flag(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_cmd_chat(args): + captured["query"] = args.query + captured["image"] = args.image + + monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"], + ) + + main_mod.main() + + assert captured == { + "query": "hello", + "image": "~/storage/shared/Pictures/cat.png", + } + + def test_continue_worktree_and_skills_flags_work_together(monkeypatch): import hermes_cli.main as main_mod From 6dcb3c477426100309e23b785757352757d2654d Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:02:23 +0200 Subject: [PATCH 45/51] fix(termux): compact narrow-screen tui chrome --- cli.py | 71 +++++++++++++++++++++++--------- tests/cli/test_cli_status_bar.py | 31 ++++++++++++++ uv.lock | 17 +++++++- 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/cli.py b/cli.py index 37aa8a7c..3d747f41 100644 --- a/cli.py +++ b/cli.py @@ -1845,15 +1845,51 @@ class HermesCLI: width += ch_width return "".join(out).rstrip() + ellipsis + @staticmethod + def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int: + """Return the live prompt_toolkit width, falling back to ``shutil``. + + The TUI layout can be narrower than ``shutil.get_terminal_size()`` reports, + especially on Termux/mobile shells, so prefer prompt_toolkit's width whenever + an app is active. + """ + try: + from prompt_toolkit.application import get_app + return get_app().output.get_size().columns + except Exception: + return shutil.get_terminal_size(default).columns + + def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool: + """Hide low-value chrome on narrow/mobile terminals to preserve rows.""" + if width is None: + width = self._get_tui_terminal_width() + return width < 64 + + def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: + """Return the visible height for the top/bottom input separator rules.""" + if position not in {"top", "bottom"}: + raise ValueError(f"Unknown input rule position: {position}") + if position == "top": + return 1 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + + def _agent_spacer_height(self, width: Optional[int] = None) -> int: + """Return the spacer height shown above the status bar while the agent runs.""" + if not getattr(self, "_agent_running", False): + return 0 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + + def _spinner_widget_height(self, width: Optional[int] = None) -> int: + """Return the visible height for the spinner/status text line above the status bar.""" + if not getattr(self, "_spinner_text", ""): + return 0 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + def _build_status_bar_text(self, width: Optional[int] = None) -> str: try: snapshot = self._get_status_bar_snapshot() if width is None: - try: - from prompt_toolkit.application import get_app - width = get_app().output.get_size().columns - except Exception: - width = shutil.get_terminal_size((80, 24)).columns + width = self._get_tui_terminal_width() percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] @@ -1889,11 +1925,7 @@ class HermesCLI: # values (especially on SSH) that differ from what prompt_toolkit # actually renders, causing the fragments to overflow to a second # line and produce duplicated status bar rows over long sessions. - try: - from prompt_toolkit.application import get_app - width = get_app().output.get_size().columns - except Exception: - width = shutil.get_terminal_size((80, 24)).columns + width = self._get_tui_terminal_width() duration_label = snapshot["duration"] if width < 52: @@ -8028,9 +8060,9 @@ class HermesCLI: def get_hint_height(): if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running: return 1 - # Keep a 1-line spacer while agent runs so output doesn't push - # right up against the top rule of the input area - return 1 if cli_ref._agent_running else 0 + # Keep a spacer while the agent runs on roomy terminals, but reclaim + # the row on narrow/mobile screens where every line matters. + return cli_ref._agent_spacer_height() def get_spinner_text(): txt = cli_ref._spinner_text @@ -8039,7 +8071,7 @@ class HermesCLI: return [('class:hint', f' {txt}')] def get_spinner_height(): - return 1 if cli_ref._spinner_text else 0 + return cli_ref._spinner_widget_height() spinner_widget = Window( content=FormattedTextControl(get_spinner_text), @@ -8230,18 +8262,17 @@ class HermesCLI: filter=Condition(lambda: cli_ref._approval_state is not None), ) - # Horizontal rules above and below the input (bronze, 1 line each). - # The bottom rule moves down as the TextArea grows with newlines. - # Using char='─' instead of hardcoded repetition so the rule - # always spans the full terminal width on any screen size. + # Horizontal rules above and below the input. + # On narrow/mobile terminals we keep the top separator for structure but + # hide the bottom one to recover a full row for conversation content. input_rule_top = Window( char='─', - height=1, + height=lambda: cli_ref._tui_input_rule_height("top"), style='class:input-rule', ) input_rule_bot = Window( char='─', - height=1, + height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule', ) diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index a884c421..cb794465 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -206,6 +206,37 @@ class TestCLIStatusBar: assert "⚕" in text assert "claude-sonnet-4-20250514" in text + def test_minimal_tui_chrome_threshold(self): + cli_obj = _make_cli() + + assert cli_obj._use_minimal_tui_chrome(width=63) is True + assert cli_obj._use_minimal_tui_chrome(width=64) is False + + def test_bottom_input_rule_hides_on_narrow_terminals(self): + cli_obj = _make_cli() + + assert cli_obj._tui_input_rule_height("top", width=50) == 1 + assert cli_obj._tui_input_rule_height("bottom", width=50) == 0 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + + def test_agent_spacer_reclaimed_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._agent_running = True + + assert cli_obj._agent_spacer_height(width=50) == 0 + assert cli_obj._agent_spacer_height(width=90) == 1 + cli_obj._agent_running = False + assert cli_obj._agent_spacer_height(width=90) == 0 + + def test_spinner_line_hidden_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._spinner_text = "thinking" + + assert cli_obj._spinner_widget_height(width=50) == 0 + assert cli_obj._spinner_widget_height(width=90) == 1 + cli_obj._spinner_text = "" + assert cli_obj._spinner_widget_height(width=90) == 0 + class TestCLIUsageReport: def test_show_usage_includes_estimated_cost(self, capsys): diff --git a/uv.lock b/uv.lock index 8bad8b38..7691ea98 100644 --- a/uv.lock +++ b/uv.lock @@ -1772,6 +1772,15 @@ slack = [ sms = [ { name = "aiohttp" }, ] +termux = [ + { name = "agent-client-protocol" }, + { name = "croniter" }, + { name = "honcho-ai" }, + { name = "mcp" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "simple-term-menu" }, +] tts-premium = [ { name = "elevenlabs" }, ] @@ -1806,19 +1815,25 @@ requires-dist = [ { name = "fire", specifier = ">=0.7.1,<1" }, { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["daytona"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" }, @@ -1861,7 +1876,7 @@ requires-dist = [ { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" }, ] -provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "dingtalk", "feishu", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" From 54d5138a54a2d9a40e34bb9111d39f21b3ec8a95 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:15:28 +0200 Subject: [PATCH 46/51] fix(termux): harden env-backed background jobs --- tests/tools/test_process_registry.py | 61 ++++++++++++++++++++++++++++ tools/process_registry.py | 44 +++++++++++++++----- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 6b2d3819..a61da9dd 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -340,6 +340,67 @@ class TestSpawnEnvSanitization: assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env assert env["PYTHONUNBUFFERED"] == "1" + def test_spawn_via_env_uses_backend_temp_dir_for_artifacts(self, registry): + class FakeEnv: + def __init__(self): + self.commands = [] + + def get_temp_dir(self): + return "/data/data/com.termux/files/usr/tmp" + + def execute(self, command, timeout=None): + self.commands.append((command, timeout)) + return {"output": "4321\n"} + + env = FakeEnv() + fake_thread = MagicMock() + + with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \ + patch.object(registry, "_write_checkpoint"): + session = registry.spawn_via_env(env, "echo hello") + + bg_command = env.commands[0][0] + assert session.pid == 4321 + assert "/data/data/com.termux/files/usr/tmp/hermes_bg_" in bg_command + assert ".exit" in bg_command + assert "rc=$?;" in bg_command + assert " > /tmp/hermes_bg_" not in bg_command + assert "cat /tmp/hermes_bg_" not in bg_command + fake_thread.start.assert_called_once() + + def test_env_poller_quotes_temp_paths_with_spaces(self, registry): + session = _make_session(sid="proc_space") + session.exited = False + + class FakeEnv: + def __init__(self): + self.commands = [] + self._responses = iter([ + {"output": "hello\n"}, + {"output": "1\n"}, + {"output": "0\n"}, + ]) + + def execute(self, command, timeout=None): + self.commands.append((command, timeout)) + return next(self._responses) + + env = FakeEnv() + + with patch("tools.process_registry.time.sleep", return_value=None), \ + patch.object(registry, "_move_to_finished"): + registry._env_poller_loop( + session, + env, + "/path with spaces/hermes_bg.log", + "/path with spaces/hermes_bg.pid", + "/path with spaces/hermes_bg.exit", + ) + + assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null" + assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?" + assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null" + # ========================================================================= # Checkpoint diff --git a/tools/process_registry.py b/tools/process_registry.py index c954378b..7f55ae6d 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -172,6 +172,19 @@ class ProcessRegistry: # ----- Spawn ----- + @staticmethod + def _env_temp_dir(env: Any) -> str: + """Return the writable sandbox temp dir for env-backed background tasks.""" + get_temp_dir = getattr(env, "get_temp_dir", None) + if callable(get_temp_dir): + try: + temp_dir = get_temp_dir() + if isinstance(temp_dir, str) and temp_dir.startswith("/"): + return temp_dir.rstrip("/") or "/" + except Exception as exc: + logger.debug("Could not resolve environment temp dir: %s", exc) + return "/tmp" + def spawn_local( self, command: str, @@ -316,12 +329,20 @@ class ProcessRegistry: ) # Run the command in the sandbox with output capture - log_path = f"/tmp/hermes_bg_{session.id}.log" - pid_path = f"/tmp/hermes_bg_{session.id}.pid" + temp_dir = self._env_temp_dir(env) + log_path = f"{temp_dir}/hermes_bg_{session.id}.log" + pid_path = f"{temp_dir}/hermes_bg_{session.id}.pid" + exit_path = f"{temp_dir}/hermes_bg_{session.id}.exit" quoted_command = shlex.quote(command) + quoted_temp_dir = shlex.quote(temp_dir) + quoted_log_path = shlex.quote(log_path) + quoted_pid_path = shlex.quote(pid_path) + quoted_exit_path = shlex.quote(exit_path) bg_command = ( - f"nohup bash -c {quoted_command} > {log_path} 2>&1 & " - f"echo $! > {pid_path} && cat {pid_path}" + f"mkdir -p {quoted_temp_dir} && " + f"( nohup bash -lc {quoted_command} > {quoted_log_path} 2>&1; " + f"rc=$?; printf '%s\\n' \"$rc\" > {quoted_exit_path} ) & " + f"echo $! > {quoted_pid_path} && cat {quoted_pid_path}" ) try: @@ -342,7 +363,7 @@ class ProcessRegistry: # Start a poller thread that periodically reads the log file reader = threading.Thread( target=self._env_poller_loop, - args=(session, env, log_path, pid_path), + args=(session, env, log_path, pid_path, exit_path), daemon=True, name=f"proc-poller-{session.id}", ) @@ -386,14 +407,17 @@ class ProcessRegistry: self._move_to_finished(session) def _env_poller_loop( - self, session: ProcessSession, env: Any, log_path: str, pid_path: str + self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str ): """Background thread: poll a sandbox log file for non-local backends.""" + quoted_log_path = shlex.quote(log_path) + quoted_pid_path = shlex.quote(pid_path) + quoted_exit_path = shlex.quote(exit_path) while not session.exited: time.sleep(2) # Poll every 2 seconds try: # Read new output from the log file - result = env.execute(f"cat {log_path} 2>/dev/null", timeout=10) + result = env.execute(f"cat {quoted_log_path} 2>/dev/null", timeout=10) new_output = result.get("output", "") if new_output: with session._lock: @@ -403,14 +427,14 @@ class ProcessRegistry: # Check if process is still running check = env.execute( - f"kill -0 $(cat {pid_path} 2>/dev/null) 2>/dev/null; echo $?", + f"kill -0 \"$(cat {quoted_pid_path} 2>/dev/null)\" 2>/dev/null; echo $?", timeout=5, ) check_output = check.get("output", "").strip() if check_output and check_output.splitlines()[-1].strip() != "0": - # Process has exited -- get exit code + # Process has exited -- get exit code captured by the wrapper shell. exit_result = env.execute( - f"wait $(cat {pid_path} 2>/dev/null) 2>/dev/null; echo $?", + f"cat {quoted_exit_path} 2>/dev/null", timeout=5, ) exit_str = exit_result.get("output", "").strip() From 3237733ca598db2442aaaf985762aeec6ebbfda2 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:46:08 +0200 Subject: [PATCH 47/51] fix(termux): harden execute_code and mobile browser/audio UX --- cli.py | 29 +++++++++--- hermes_cli/doctor.py | 2 +- tests/cli/test_cli_image_command.py | 11 +++++ tests/hermes_cli/test_doctor.py | 43 ++++++++++++++++++ tests/tools/test_browser_homebrew_paths.py | 12 +++++ tests/tools/test_code_execution.py | 43 ++++++++++++++++++ tests/tools/test_voice_mode.py | 15 +++++++ tools/browser_tool.py | 23 +++++++++- tools/code_execution_tool.py | 52 ++++++++++++++++------ tools/voice_mode.py | 34 ++++++++++---- 10 files changed, 233 insertions(+), 31 deletions(-) diff --git a/cli.py b/cli.py index 3d747f41..27f691dc 100644 --- a/cli.py +++ b/cli.py @@ -1022,6 +1022,20 @@ def _is_termux_environment() -> bool: return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +def _termux_example_image_path(filename: str = "cat.png") -> str: + """Return a realistic example media path for the current Termux setup.""" + candidates = [ + os.path.expanduser("~/storage/shared"), + "/sdcard", + "/storage/emulated/0", + "/storage/self/primary", + ] + for root in candidates: + if os.path.isdir(root): + return os.path.join(root, "Pictures", filename) + return os.path.join("~/storage/shared", "Pictures", filename) + + def _split_path_input(raw: str) -> tuple[str, str]: """Split a leading file path token from trailing free-form text. @@ -3126,7 +3140,7 @@ class HermesCLI: _cprint( f" {_DIM}Clipboard image paste is not available on Termux — " f"use /image or paste a local image path like " - f"~/storage/shared/Pictures/cat.png{_RST}" + f"{_termux_example_image_path()}{_RST}" ) return @@ -3144,7 +3158,7 @@ class HermesCLI: """Handle /image — attach a local image file for the next prompt.""" raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") if not raw_args: - hint = "~/storage/shared/Pictures/cat.png" if _is_termux_environment() else "/path/to/image.png" + hint = _termux_example_image_path() if _is_termux_environment() else "/path/to/image.png" _cprint(f" {_DIM}Usage: /image e.g. /image {hint}{_RST}") return @@ -3162,7 +3176,7 @@ class HermesCLI: if _remainder: _cprint(f" {_DIM}Now type your prompt (or use --image in single-query mode): {_remainder}{_RST}") elif _is_termux_environment(): - _cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {image_path} \"What do you see?\"{_RST}") + _cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {_termux_example_image_path(image_path.name)} \"What do you see?\"{_RST}") def _preprocess_images_with_vision(self, text: str, images: list, *, announce: bool = True) -> str: """Analyze attached images via the vision tool and return enriched text. @@ -3317,7 +3331,7 @@ class HermesCLI: _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") if _is_termux_environment(): - _cprint(f" {_DIM}Attach image: /image ~/storage/shared/Pictures/cat.png or start your prompt with a local image path{_RST}\n") + _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n") else: _cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n") @@ -6229,8 +6243,11 @@ class HermesCLI: for line in reqs["details"].split("\n"): _cprint(f" {_DIM}{line}{_RST}") if reqs["missing_packages"]: - _cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}") - _cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}") + if _is_termux_environment(): + _cprint(f"\n {_BOLD}Install: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}") + else: + _cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}") + _cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}") return with self._voice_lock: diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c2bba845..5ef7acb3 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -596,7 +596,7 @@ def run_doctor(args): else: if _is_termux(): check_info("agent-browser is not installed (expected in the tested Termux path)") - check_info("Install it manually later with: npm install") + check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") else: check_warn("agent-browser not installed", "(run: npm install)") else: diff --git a/tests/cli/test_cli_image_command.py b/tests/cli/test_cli_image_command.py index 7c9cef8f..45bdfa7e 100644 --- a/tests/cli/test_cli_image_command.py +++ b/tests/cli/test_cli_image_command.py @@ -5,6 +5,7 @@ from cli import ( HermesCLI, _collect_query_images, _format_image_attachment_badges, + _termux_example_image_path, ) @@ -80,6 +81,16 @@ class TestCollectQueryImages: assert images == [img] +class TestTermuxImageHints: + def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch): + existing = {"/sdcard", "/storage/emulated/0"} + monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing) + + hint = _termux_example_image_path() + + assert hint == "/sdcard/Pictures/cat.png" + + class TestImageBadgeFormatting: def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path): img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png") diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index eb767690..1c1246e4 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -245,3 +245,46 @@ def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkey assert "Node.js not found (browser tools are optional in the tested Termux path)" in out assert "Install Node.js on Termux with: pkg install nodejs" in out assert "docker not found (optional)" not in out + + +def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]), + TOOLSET_REQUIREMENTS={ + "terminal": {"name": "terminal"}, + "browser": {"name": "browser"}, + }, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "✓ browser" not in out + assert "browser" in out + assert "system dependency not met" in out + assert "agent-browser is not installed (expected in the tested Termux path)" in out + assert "npm install -g agent-browser && agent-browser install" in out diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index 33b72560..4c07efde 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -13,6 +13,7 @@ from tools.browser_tool import ( _find_agent_browser, _run_browser_command, _SANE_PATH, + check_browser_requirements, ) @@ -149,6 +150,17 @@ class TestFindAgentBrowser: _find_agent_browser() +class TestBrowserRequirements: + def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False) + monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + + assert check_browser_requirements() is False + + class TestRunBrowserCommandPathConstruction: """Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH.""" diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 5ac3fd87..33653c36 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -44,6 +44,7 @@ from tools.code_execution_tool import ( build_execute_code_schema, EXECUTE_CODE_SCHEMA, _TOOL_DOC_LINES, + _execute_remote, ) @@ -115,6 +116,48 @@ class TestHermesToolsGeneration(unittest.TestCase): self.assertIn("def retry(", src) self.assertIn("import json, os, socket, shlex, time", src) + def test_file_transport_uses_tempfile_fallback_for_rpc_dir(self): + src = generate_hermes_tools_module(["terminal"], transport="file") + self.assertIn("import json, os, shlex, tempfile, time", src) + self.assertIn("os.path.join(tempfile.gettempdir(), \"hermes_rpc\")", src) + self.assertNotIn('os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc")', src) + + +class TestExecuteCodeRemoteTempDir(unittest.TestCase): + def test_execute_remote_uses_backend_temp_dir_for_sandbox(self): + class FakeEnv: + def __init__(self): + self.commands = [] + + def get_temp_dir(self): + return "/data/data/com.termux/files/usr/tmp" + + def execute(self, command, cwd=None, timeout=None): + self.commands.append((command, cwd, timeout)) + if "command -v python3" in command: + return {"output": "OK\n"} + if "python3 script.py" in command: + return {"output": "hello\n", "returncode": 0} + return {"output": ""} + + env = FakeEnv() + fake_thread = MagicMock() + + with patch("tools.code_execution_tool._load_config", return_value={"timeout": 30, "max_tool_calls": 5}), \ + patch("tools.code_execution_tool._get_or_create_env", return_value=(env, "ssh")), \ + patch("tools.code_execution_tool._ship_file_to_remote"), \ + patch("tools.code_execution_tool.threading.Thread", return_value=fake_thread): + result = json.loads(_execute_remote("print('hello')", "task-1", ["terminal"])) + + self.assertEqual(result["status"], "success") + mkdir_cmd = env.commands[1][0] + run_cmd = next(cmd for cmd, _, _ in env.commands if "python3 script.py" in cmd) + cleanup_cmd = env.commands[-1][0] + self.assertIn("mkdir -p /data/data/com.termux/files/usr/tmp/hermes_exec_", mkdir_cmd) + self.assertIn("HERMES_RPC_DIR=/data/data/com.termux/files/usr/tmp/hermes_exec_", run_cmd) + self.assertIn("rm -rf /data/data/com.termux/files/usr/tmp/hermes_exec_", cleanup_cmd) + self.assertNotIn("mkdir -p /tmp/hermes_exec_", mkdir_cmd) + @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") class TestExecuteCode(unittest.TestCase): diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 933393f8..3ad72891 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -183,6 +183,21 @@ class TestDetectAudioEnvironment: assert result["available"] is False assert any("PortAudio" in w for w in result["warnings"]) + def test_termux_import_error_shows_termux_install_guidance(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any("pkg install python-numpy portaudio" in w for w in result["warnings"]) + assert any("python -m pip install sounddevice" in w for w in result["warnings"]) + # ============================================================================ # check_voice_requirements diff --git a/tools/browser_tool.py b/tools/browser_tool.py index e62a586c..6e393e57 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -285,6 +285,17 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: return _cached_cloud_provider +def _is_termux_environment() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + +def _browser_install_hint() -> str: + if _is_termux_environment(): + return "npm install -g agent-browser && agent-browser install" + return "npm install -g agent-browser && agent-browser install --with-deps" + + def _is_local_mode() -> bool: """Return True when the browser tool will use a local browser backend.""" if _get_cdp_override(): @@ -796,7 +807,8 @@ def _find_agent_browser() -> str: return "npx agent-browser" raise FileNotFoundError( - "agent-browser CLI not found. Install it with: npm install -g agent-browser\n" + "agent-browser CLI not found. Install it with: " + f"{_browser_install_hint()}\n" "Or run 'npm install' in the repo root to install locally.\n" "Or ensure npx is available in your PATH." ) @@ -2040,10 +2052,17 @@ def check_browser_requirements() -> bool: # The agent-browser CLI is always required try: - _find_agent_browser() + browser_cmd = _find_agent_browser() except FileNotFoundError: return False + # On Termux, the bare npx fallback is too fragile to treat as a satisfied + # local browser dependency. Require a real install (global or local) so the + # browser tool is not advertised as available when it will likely fail on + # first use. + if _is_termux_environment() and _is_local_mode() and browser_cmd.strip() == "npx agent-browser": + return False + # In cloud mode, also require provider credentials provider = _get_cloud_provider() if provider is not None and not provider.is_configured(): diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index f0d61210..2b9e329a 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -33,6 +33,7 @@ import json import logging import os import platform +import shlex import signal import socket import subprocess @@ -246,9 +247,9 @@ def _call(tool_name, args): _FILE_TRANSPORT_HEADER = '''\ """Auto-generated Hermes tools RPC stubs (file-based transport).""" -import json, os, shlex, time +import json, os, shlex, tempfile, time -_RPC_DIR = os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc") +_RPC_DIR = os.environ.get("HERMES_RPC_DIR") or os.path.join(tempfile.gettempdir(), "hermes_rpc") _seq = 0 ''' + _COMMON_HELPERS + '''\ @@ -536,13 +537,30 @@ def _ship_file_to_remote(env, remote_path: str, content: str) -> None: quotes are fine. """ encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + quoted_remote_path = shlex.quote(remote_path) env.execute( - f"echo '{encoded}' | base64 -d > {remote_path}", + f"echo '{encoded}' | base64 -d > {quoted_remote_path}", cwd="/", timeout=30, ) +def _env_temp_dir(env: Any) -> str: + """Return a writable temp dir for env-backed execute_code sandboxes.""" + get_temp_dir = getattr(env, "get_temp_dir", None) + if callable(get_temp_dir): + try: + temp_dir = get_temp_dir() + if isinstance(temp_dir, str) and temp_dir.startswith("/"): + return temp_dir.rstrip("/") or "/" + except Exception as exc: + logger.debug("Could not resolve execute_code env temp dir: %s", exc) + candidate = tempfile.gettempdir() + if isinstance(candidate, str) and candidate.startswith("/"): + return candidate.rstrip("/") or "/" + return "/tmp" + + def _rpc_poll_loop( env, rpc_dir: str, @@ -563,11 +581,12 @@ def _rpc_poll_loop( poll_interval = 0.1 # 100 ms + quoted_rpc_dir = shlex.quote(rpc_dir) while not stop_event.is_set(): try: # List pending request files (skip .tmp partials) ls_result = env.execute( - f"ls -1 {rpc_dir}/req_* 2>/dev/null || true", + f"ls -1 {quoted_rpc_dir}/req_* 2>/dev/null || true", cwd="/", timeout=10, ) @@ -589,9 +608,10 @@ def _rpc_poll_loop( call_start = time.monotonic() + quoted_req_file = shlex.quote(req_file) # Read request read_result = env.execute( - f"cat {req_file}", + f"cat {quoted_req_file}", cwd="/", timeout=10, ) @@ -600,7 +620,7 @@ def _rpc_poll_loop( except (json.JSONDecodeError, ValueError): logger.debug("Malformed RPC request in %s", req_file) # Remove bad request to avoid infinite retry - env.execute(f"rm -f {req_file}", cwd="/", timeout=5) + env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5) continue tool_name = request.get("tool", "") @@ -608,6 +628,7 @@ def _rpc_poll_loop( seq = request.get("seq", 0) seq_str = f"{seq:06d}" res_file = f"{rpc_dir}/res_{seq_str}" + quoted_res_file = shlex.quote(res_file) # Enforce allow-list if tool_name not in allowed_tools: @@ -665,14 +686,14 @@ def _rpc_poll_loop( tool_result.encode("utf-8") ).decode("ascii") env.execute( - f"echo '{encoded_result}' | base64 -d > {res_file}.tmp" - f" && mv {res_file}.tmp {res_file}", + f"echo '{encoded_result}' | base64 -d > {quoted_res_file}.tmp" + f" && mv {quoted_res_file}.tmp {quoted_res_file}", cwd="/", timeout=60, ) # Remove the request file - env.execute(f"rm -f {req_file}", cwd="/", timeout=5) + env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5) except Exception as e: if not stop_event.is_set(): @@ -707,7 +728,10 @@ def _execute_remote( env, env_type = _get_or_create_env(effective_task_id) sandbox_id = uuid.uuid4().hex[:12] - sandbox_dir = f"/tmp/hermes_exec_{sandbox_id}" + temp_dir = _env_temp_dir(env) + sandbox_dir = f"{temp_dir}/hermes_exec_{sandbox_id}" + quoted_sandbox_dir = shlex.quote(sandbox_dir) + quoted_rpc_dir = shlex.quote(f"{sandbox_dir}/rpc") tool_call_log: list = [] tool_call_counter = [0] @@ -735,7 +759,7 @@ def _execute_remote( # Create sandbox directory on remote env.execute( - f"mkdir -p {sandbox_dir}/rpc", cwd="/", timeout=10, + f"mkdir -p {quoted_rpc_dir}", cwd="/", timeout=10, ) # Generate and ship files @@ -759,7 +783,7 @@ def _execute_remote( # Build environment variable prefix for the script env_prefix = ( - f"HERMES_RPC_DIR={sandbox_dir}/rpc " + f"HERMES_RPC_DIR={shlex.quote(f'{sandbox_dir}/rpc')} " f"PYTHONDONTWRITEBYTECODE=1" ) tz = os.getenv("HERMES_TIMEZONE", "").strip() @@ -770,7 +794,7 @@ def _execute_remote( logger.info("Executing code on %s backend (task %s)...", env_type, effective_task_id[:8]) script_result = env.execute( - f"cd {sandbox_dir} && {env_prefix} python3 script.py", + f"cd {quoted_sandbox_dir} && {env_prefix} python3 script.py", timeout=timeout, ) @@ -807,7 +831,7 @@ def _execute_remote( # Clean up remote sandbox dir try: env.execute( - f"rm -rf {sandbox_dir}", cwd="/", timeout=15, + f"rm -rf {quoted_sandbox_dir}", cwd="/", timeout=15, ) except Exception: logger.debug("Failed to clean up remote sandbox %s", sandbox_dir) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 1b09a178..c3c0b575 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -48,6 +48,17 @@ def _audio_available() -> bool: return False +def _is_termux_environment() -> bool: + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + +def _voice_capture_install_hint() -> str: + if _is_termux_environment(): + return "pkg install python-numpy portaudio && python -m pip install sounddevice" + return "pip install sounddevice numpy" + + def detect_audio_environment() -> dict: """Detect if the current environment supports audio I/O. @@ -98,14 +109,21 @@ def detect_audio_environment() -> dict: else: warnings.append("Audio subsystem error (PortAudio cannot query devices)") except ImportError: - warnings.append("Audio libraries not installed (pip install sounddevice numpy)") + warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") except OSError: - warnings.append( - "PortAudio system library not found -- install it first:\n" - " Linux: sudo apt-get install libportaudio2\n" - " macOS: brew install portaudio\n" - "Then retry /voice on." - ) + if _is_termux_environment(): + warnings.append( + "PortAudio system library not found -- install it first:\n" + " Termux: pkg install portaudio\n" + "Then retry /voice on." + ) + else: + warnings.append( + "PortAudio system library not found -- install it first:\n" + " Linux: sudo apt-get install libportaudio2\n" + " macOS: brew install portaudio\n" + "Then retry /voice on." + ) return { "available": not warnings, @@ -748,7 +766,7 @@ def check_voice_requirements() -> Dict[str, Any]: if has_audio: details_parts.append("Audio capture: OK") else: - details_parts.append("Audio capture: MISSING (pip install sounddevice numpy)") + details_parts.append(f"Audio capture: MISSING ({_voice_capture_install_hint()})") if not stt_enabled: details_parts.append("STT provider: DISABLED in config (stt.enabled: false)") From 769ec1ee1a42b8e99fc39e30acc4caebef4ab5a1 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:16:58 +0200 Subject: [PATCH 48/51] fix(termux): deepen browser, voice, and tui support --- cli.py | 54 +++++-- tests/cli/test_cli_skin_integration.py | 19 +++ tests/tools/test_browser_homebrew_paths.py | 14 ++ tests/tools/test_voice_mode.py | 98 +++++++++++- tools/browser_tool.py | 25 ++- tools/voice_mode.py | 172 ++++++++++++++++++++- 6 files changed, 358 insertions(+), 24 deletions(-) diff --git a/cli.py b/cli.py index 27f691dc..3848a24d 100644 --- a/cli.py +++ b/cli.py @@ -5974,10 +5974,16 @@ class HermesCLI: """Start capturing audio from the microphone.""" if getattr(self, '_should_exit', False): return - from tools.voice_mode import AudioRecorder, check_voice_requirements + from tools.voice_mode import create_audio_recorder, check_voice_requirements reqs = check_voice_requirements() if not reqs["audio_available"]: + if _is_termux_environment(): + raise RuntimeError( + "Voice mode requires either Termux:API microphone access or Python audio libraries.\n" + "Option 1: pkg install termux-api and install the Termux:API Android app\n" + "Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice" + ) raise RuntimeError( "Voice mode requires sounddevice and numpy.\n" "Install with: pip install sounddevice numpy\n" @@ -6006,7 +6012,7 @@ class HermesCLI: pass if self._voice_recorder is None: - self._voice_recorder = AudioRecorder() + self._voice_recorder = create_audio_recorder() # Apply config-driven silence params self._voice_recorder._silence_threshold = voice_cfg.get("silence_threshold", 200) @@ -6035,7 +6041,13 @@ class HermesCLI: with self._voice_lock: self._voice_recording = False raise - _cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}(auto-stops on silence | Ctrl+B to stop & exit continuous){_RST}") + if getattr(self._voice_recorder, "supports_silence_autostop", True): + _recording_hint = "auto-stops on silence | Ctrl+B to stop & exit continuous" + elif _is_termux_environment(): + _recording_hint = "Termux:API capture | Ctrl+B to stop" + else: + _recording_hint = "Ctrl+B to stop" + _cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}") # Periodically refresh prompt to update audio level indicator def _refresh_level(): @@ -6244,7 +6256,9 @@ class HermesCLI: _cprint(f" {_DIM}{line}{_RST}") if reqs["missing_packages"]: if _is_termux_environment(): - _cprint(f"\n {_BOLD}Install: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}") + _cprint(f"\n {_BOLD}Option 1: pkg install termux-api{_RST}") + _cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}") + _cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}") else: _cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}") _cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}") @@ -7201,27 +7215,39 @@ class HermesCLI: def _get_tui_prompt_fragments(self): """Return the prompt_toolkit fragments for the current interactive state.""" symbol, state_suffix = self._get_tui_prompt_symbols() + compact = self._use_minimal_tui_chrome(width=self._get_tui_terminal_width()) + + def _state_fragment(style: str, icon: str, extra: str = ""): + if compact: + text = icon + if extra: + text = f"{text} {extra.strip()}".rstrip() + return [(style, text + " ")] + if extra: + return [(style, f"{icon} {extra} {state_suffix}")] + return [(style, f"{icon} {state_suffix}")] + if self._voice_recording: bar = self._audio_level_bar() - return [("class:voice-recording", f"● {bar} {state_suffix}")] + return _state_fragment("class:voice-recording", "●", bar) if self._voice_processing: - return [("class:voice-processing", f"◉ {state_suffix}")] + return _state_fragment("class:voice-processing", "◉") if self._sudo_state: - return [("class:sudo-prompt", f"🔐 {state_suffix}")] + return _state_fragment("class:sudo-prompt", "🔐") if self._secret_state: - return [("class:sudo-prompt", f"🔑 {state_suffix}")] + return _state_fragment("class:sudo-prompt", "🔑") if self._approval_state: - return [("class:prompt-working", f"⚠ {state_suffix}")] + return _state_fragment("class:prompt-working", "⚠") if self._clarify_freetext: - return [("class:clarify-selected", f"✎ {state_suffix}")] + return _state_fragment("class:clarify-selected", "✎") if self._clarify_state: - return [("class:prompt-working", f"? {state_suffix}")] + return _state_fragment("class:prompt-working", "?") if self._command_running: - return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")] + return _state_fragment("class:prompt-working", self._command_spinner_frame()) if self._agent_running: - return [("class:prompt-working", f"⚕ {state_suffix}")] + return _state_fragment("class:prompt-working", "⚕") if self._voice_mode: - return [("class:voice-prompt", f"🎤 {state_suffix}")] + return _state_fragment("class:voice-prompt", "🎤") return [("class:prompt", symbol)] def _get_tui_prompt_text(self) -> str: diff --git a/tests/cli/test_cli_skin_integration.py b/tests/cli/test_cli_skin_integration.py index 61a177ca..08a86782 100644 --- a/tests/cli/test_cli_skin_integration.py +++ b/tests/cli/test_cli_skin_integration.py @@ -49,6 +49,25 @@ class TestCliSkinPromptIntegration: set_active_skin("ares") assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")] + def test_narrow_terminals_compact_voice_prompt_fragments(self): + cli = _make_cli_stub() + cli._voice_mode = True + + with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50): + assert cli._get_tui_prompt_fragments() == [("class:voice-prompt", "🎤 ")] + + def test_narrow_terminals_compact_voice_recording_prompt_fragments(self): + cli = _make_cli_stub() + cli._voice_recording = True + cli._voice_recorder = SimpleNamespace(current_rms=3000) + + with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50): + frags = cli._get_tui_prompt_fragments() + + assert frags[0][0] == "class:voice-recording" + assert frags[0][1].startswith("●") + assert "❯" not in frags[0][1] + def test_icon_only_skin_symbol_still_visible_in_special_states(self): cli = _make_cli_stub() cli._secret_state = {"response_queue": object()} diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index 4c07efde..6f92e88f 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -161,6 +161,20 @@ class TestBrowserRequirements: assert check_browser_requirements() is False +class TestRunBrowserCommandTermuxFallback: + def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser") + monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None) + + result = _run_browser_command("task-1", "navigate", ["https://example.com"]) + + assert result["success"] is False + assert "bare npx fallback" in result["error"] + assert "agent-browser install" in result["error"] + + class TestRunBrowserCommandPathConstruction: """Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH.""" diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 3ad72891..6ff64702 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -199,11 +199,42 @@ class TestDetectAudioEnvironment: assert any("python -m pip install sounddevice" in w for w in result["warnings"]) + def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None) + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True + assert any("Termux:API microphone recording available" in n for n in result.get("notices", [])) + assert result["warnings"] == [] + + # ============================================================================ # check_voice_requirements # ============================================================================ class TestCheckVoiceRequirements: + def test_termux_api_capture_counts_as_audio_available(self, monkeypatch): + monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False) + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]}) + monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai") + + from tools.voice_mode import check_voice_requirements + result = check_voice_requirements() + + assert result["available"] is True + assert result["audio_available"] is True + assert result["missing_packages"] == [] + assert "Termux:API microphone" in result["details"] + def test_all_requirements_met(self, monkeypatch): monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", @@ -250,8 +281,71 @@ class TestCheckVoiceRequirements: # AudioRecorder # ============================================================================ -class TestAudioRecorderStart: - def test_start_raises_without_audio(self, monkeypatch): +class TestCreateAudioRecorder: + def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + + from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder + recorder = create_audio_recorder() + + assert isinstance(recorder, TermuxAudioRecorder) + assert recorder.supports_silence_autostop is False + + +class TestTermuxAudioRecorder: + def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir): + command_calls = [] + output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac" + + def fake_run(cmd, **kwargs): + command_calls.append(cmd) + if cmd[1] == "-f": + Path(cmd[2]).write_bytes(b"aac-bytes") + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") + monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) + + from tools.voice_mode import TermuxAudioRecorder + recorder = TermuxAudioRecorder() + recorder.start() + recorder._start_time = time.monotonic() - 1.0 + result = recorder.stop() + + assert result == str(output_path) + assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"] + assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"] + + def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir): + output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac" + + def fake_run(cmd, **kwargs): + if cmd[1] == "-f": + Path(cmd[2]).write_bytes(b"aac-bytes") + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") + monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) + + from tools.voice_mode import TermuxAudioRecorder + recorder = TermuxAudioRecorder() + recorder.start() + recorder.cancel() + + assert output_path.exists() is False + assert recorder.is_recording is False + + +class TestAudioRecorder: + def test_start_raises_without_audio_libs(self, monkeypatch): def _fail_import(): raise ImportError("no sounddevice") monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 6e393e57..5fc028b6 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -296,6 +296,17 @@ def _browser_install_hint() -> str: return "npm install -g agent-browser && agent-browser install --with-deps" +def _requires_real_termux_browser_install(browser_cmd: str) -> bool: + return _is_termux_environment() and _is_local_mode() and browser_cmd.strip() == "npx agent-browser" + + +def _termux_browser_install_error() -> str: + return ( + "Local browser automation on Termux cannot rely on the bare npx fallback. " + f"Install agent-browser explicitly first: {_browser_install_hint()}" + ) + + def _is_local_mode() -> bool: """Return True when the browser tool will use a local browser backend.""" if _get_cdp_override(): @@ -864,6 +875,11 @@ def _run_browser_command( except FileNotFoundError as e: logger.warning("agent-browser CLI not found: %s", e) return {"success": False, "error": str(e)} + + if _requires_real_termux_browser_install(browser_cmd): + error = _termux_browser_install_error() + logger.warning("browser command blocked on Termux: %s", error) + return {"success": False, "error": error} from tools.interrupt import is_interrupted if is_interrupted(): @@ -2060,7 +2076,7 @@ def check_browser_requirements() -> bool: # local browser dependency. Require a real install (global or local) so the # browser tool is not advertised as available when it will likely fail on # first use. - if _is_termux_environment() and _is_local_mode() and browser_cmd.strip() == "npx agent-browser": + if _requires_real_termux_browser_install(browser_cmd): return False # In cloud mode, also require provider credentials @@ -2092,10 +2108,13 @@ if __name__ == "__main__": else: print("❌ Missing requirements:") try: - _find_agent_browser() + browser_cmd = _find_agent_browser() + if _requires_real_termux_browser_install(browser_cmd): + print(" - bare npx fallback found (insufficient on Termux local mode)") + print(f" Install: {_browser_install_hint()}") except FileNotFoundError: print(" - agent-browser CLI not found") - print(" Install: npm install -g agent-browser && agent-browser install --with-deps") + print(f" Install: {_browser_install_hint()}") if _cp is not None and not _cp.is_configured(): print(f" - {_cp.provider_name()} credentials not configured") print(" Tip: set browser.cloud_provider to 'local' to use free local mode instead") diff --git a/tools/voice_mode.py b/tools/voice_mode.py index c3c0b575..a3128eb4 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -59,6 +59,22 @@ def _voice_capture_install_hint() -> str: return "pip install sounddevice numpy" +def _termux_microphone_command() -> Optional[str]: + if not _is_termux_environment(): + return None + return shutil.which("termux-microphone-record") + + +def _termux_media_player_command() -> Optional[str]: + if not _is_termux_environment(): + return None + return shutil.which("termux-media-player") + + +def _termux_voice_capture_available() -> bool: + return _termux_microphone_command() is not None + + def detect_audio_environment() -> dict: """Detect if the current environment supports audio I/O. @@ -68,6 +84,7 @@ def detect_audio_environment() -> dict: """ warnings = [] # hard-fail: these block voice mode notices = [] # informational: logged but don't block + termux_capture = _termux_voice_capture_available() # SSH detection if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): @@ -100,18 +117,28 @@ def detect_audio_environment() -> dict: try: devices = sd.query_devices() if not devices: - warnings.append("No audio input/output devices detected") + if termux_capture: + notices.append("No PortAudio devices detected, but Termux:API microphone capture is available") + else: + warnings.append("No audio input/output devices detected") except Exception: # In WSL with PulseAudio, device queries can fail even though # recording/playback works fine. Don't block if PULSE_SERVER is set. if os.environ.get('PULSE_SERVER'): notices.append("Audio device query failed but PULSE_SERVER is set -- continuing") + elif termux_capture: + notices.append("PortAudio device query failed, but Termux:API microphone capture is available") else: warnings.append("Audio subsystem error (PortAudio cannot query devices)") except ImportError: - warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") + if termux_capture: + notices.append("Termux:API microphone recording available (sounddevice not required)") + else: + warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") except OSError: - if _is_termux_environment(): + if termux_capture: + notices.append("Termux:API microphone recording available (PortAudio not required)") + elif _is_termux_environment(): warnings.append( "PortAudio system library not found -- install it first:\n" " Termux: pkg install portaudio\n" @@ -192,6 +219,129 @@ def play_beep(frequency: int = 880, duration: float = 0.12, count: int = 1) -> N logger.debug("Beep playback failed: %s", e) +# ============================================================================ +# Termux Audio Recorder +# ============================================================================ +class TermuxAudioRecorder: + """Recorder backend that uses Termux:API microphone capture commands.""" + + supports_silence_autostop = False + + def __init__(self) -> None: + self._lock = threading.Lock() + self._recording = False + self._start_time = 0.0 + self._recording_path: Optional[str] = None + self._current_rms = 0 + + @property + def is_recording(self) -> bool: + return self._recording + + @property + def elapsed_seconds(self) -> float: + if not self._recording: + return 0.0 + return time.monotonic() - self._start_time + + @property + def current_rms(self) -> int: + return self._current_rms + + def start(self, on_silence_stop=None) -> None: + del on_silence_stop # Termux:API does not expose live silence callbacks. + mic_cmd = _termux_microphone_command() + if not mic_cmd: + raise RuntimeError( + "Termux voice capture requires the termux-api package and app.\n" + "Install with: pkg install termux-api\n" + "Then install/update the Termux:API Android app." + ) + + with self._lock: + if self._recording: + return + os.makedirs(_TEMP_DIR, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + self._recording_path = os.path.join(_TEMP_DIR, f"recording_{timestamp}.aac") + + command = [ + mic_cmd, + "-f", self._recording_path, + "-l", "0", + "-e", "aac", + "-r", str(SAMPLE_RATE), + "-c", str(CHANNELS), + ] + try: + subprocess.run(command, capture_output=True, text=True, timeout=15, check=True) + except subprocess.CalledProcessError as e: + details = (e.stderr or e.stdout or str(e)).strip() + raise RuntimeError(f"Termux microphone start failed: {details}") from e + except Exception as e: + raise RuntimeError(f"Termux microphone start failed: {e}") from e + + with self._lock: + self._start_time = time.monotonic() + self._recording = True + self._current_rms = 0 + logger.info("Termux voice recording started") + + def _stop_termux_recording(self) -> None: + mic_cmd = _termux_microphone_command() + if not mic_cmd: + return + subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False) + + def stop(self) -> Optional[str]: + with self._lock: + if not self._recording: + return None + self._recording = False + path = self._recording_path + self._recording_path = None + started_at = self._start_time + self._current_rms = 0 + + self._stop_termux_recording() + if not path or not os.path.isfile(path): + return None + if time.monotonic() - started_at < 0.3: + try: + os.unlink(path) + except OSError: + pass + return None + if os.path.getsize(path) <= 0: + try: + os.unlink(path) + except OSError: + pass + return None + logger.info("Termux voice recording stopped: %s", path) + return path + + def cancel(self) -> None: + with self._lock: + path = self._recording_path + self._recording = False + self._recording_path = None + self._current_rms = 0 + try: + self._stop_termux_recording() + except Exception: + pass + if path and os.path.isfile(path): + try: + os.unlink(path) + except OSError: + pass + logger.info("Termux voice recording cancelled") + + def shutdown(self) -> None: + self.cancel() + + # ============================================================================ # AudioRecorder # ============================================================================ @@ -211,6 +361,8 @@ class AudioRecorder: the user is silent for ``silence_duration`` seconds and calls the callback. """ + supports_silence_autostop = True + def __init__(self) -> None: self._lock = threading.Lock() self._stream: Any = None @@ -544,6 +696,13 @@ class AudioRecorder: return wav_path +def create_audio_recorder() -> AudioRecorder | TermuxAudioRecorder: + """Return the best recorder backend for the current environment.""" + if _termux_voice_capture_available(): + return TermuxAudioRecorder() + return AudioRecorder() + + # ============================================================================ # Whisper hallucination filter # ============================================================================ @@ -752,7 +911,8 @@ def check_voice_requirements() -> Dict[str, Any]: stt_available = stt_enabled and stt_provider != "none" missing: List[str] = [] - has_audio = _audio_available() + termux_capture = _termux_voice_capture_available() + has_audio = _audio_available() or termux_capture if not has_audio: missing.extend(["sounddevice", "numpy"]) @@ -763,7 +923,9 @@ def check_voice_requirements() -> Dict[str, Any]: available = has_audio and stt_available and env_check["available"] details_parts = [] - if has_audio: + if termux_capture: + details_parts.append("Audio capture: OK (Termux:API microphone)") + elif has_audio: details_parts.append("Audio capture: OK") else: details_parts.append(f"Audio capture: MISSING ({_voice_capture_install_hint()})") From c3141429b799cf79b847b34df59c596307ef7847 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:41:30 +0200 Subject: [PATCH 49/51] fix(termux): tighten voice setup and mobile chat UX --- cli.py | 34 ++++++++++++++++++++++++------- hermes_cli/doctor.py | 17 ++++++++++++++++ tests/cli/test_cli_status_bar.py | 22 ++++++++++++++++++++ tests/hermes_cli/test_doctor.py | 4 ++++ tests/tools/test_voice_mode.py | 33 ++++++++++++++++++++++++++++++ tools/voice_mode.py | 35 ++++++++++++++++++++++++++++++-- 6 files changed, 136 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index 3848a24d..cc9ca214 100644 --- a/cli.py +++ b/cli.py @@ -1899,7 +1899,26 @@ class HermesCLI: return 0 return 0 if self._use_minimal_tui_chrome(width=width) else 1 + def _get_voice_status_fragments(self, width: Optional[int] = None): + """Return the voice status bar fragments for the interactive TUI.""" + width = width or self._get_tui_terminal_width() + compact = self._use_minimal_tui_chrome(width=width) + if self._voice_recording: + if compact: + return [("class:voice-status-recording", " ● REC ")] + return [("class:voice-status-recording", " ● REC Ctrl+B to stop ")] + if self._voice_processing: + if compact: + return [("class:voice-status", " ◉ STT ")] + return [("class:voice-status", " ◉ Transcribing... ")] + if compact: + return [("class:voice-status", " 🎤 Ctrl+B ")] + tts = " | TTS on" if self._voice_tts else "" + cont = " | Continuous" if self._voice_continuous else "" + return [("class:voice-status", f" 🎤 Voice mode{tts}{cont} — Ctrl+B to record ")] + def _build_status_bar_text(self, width: Optional[int] = None) -> str: + """Return a compact one-line session status string for the TUI footer.""" try: snapshot = self._get_status_bar_snapshot() if width is None: @@ -5979,6 +5998,13 @@ class HermesCLI: reqs = check_voice_requirements() if not reqs["audio_available"]: if _is_termux_environment(): + details = reqs.get("details", "") + if "Termux:API Android app is not installed" in details: + raise RuntimeError( + "Termux:API command package detected, but the Android app is missing.\n" + "Install/update the Termux:API Android app, then retry /voice on.\n" + "Fallback: pkg install python-numpy portaudio && python -m pip install sounddevice" + ) raise RuntimeError( "Voice mode requires either Termux:API microphone access or Python audio libraries.\n" "Option 1: pkg install termux-api and install the Termux:API Android app\n" @@ -8338,13 +8364,7 @@ class HermesCLI: # Persistent voice mode status bar (visible only when voice mode is on) def _get_voice_status(): - if cli_ref._voice_recording: - return [('class:voice-status-recording', ' ● REC Ctrl+B to stop ')] - if cli_ref._voice_processing: - return [('class:voice-status', ' ◉ Transcribing... ')] - tts = " | TTS on" if cli_ref._voice_tts else "" - cont = " | Continuous" if cli_ref._voice_continuous else "" - return [('class:voice-status', f' 🎤 Voice mode{tts}{cont} — Ctrl+B to record ')] + return cli_ref._get_voice_status_fragments() voice_status_bar = ConditionalContainer( Window( diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 5ef7acb3..e90631a9 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -71,6 +71,17 @@ def _system_package_install_cmd(pkg: str) -> str: return f"sudo apt install {pkg}" +def _termux_browser_setup_steps(node_installed: bool) -> list[str]: + steps: list[str] = [] + step = 1 + if not node_installed: + steps.append(f"{step}) pkg install nodejs") + step += 1 + steps.append(f"{step}) npm install -g agent-browser") + steps.append(f"{step + 1}) agent-browser install") + return steps + + def _has_provider_env_config(content: str) -> bool: """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" return any(key in content for key in _PROVIDER_ENV_HINTS) @@ -597,12 +608,18 @@ def run_doctor(args): if _is_termux(): check_info("agent-browser is not installed (expected in the tested Termux path)") check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=True): + check_info(step) else: check_warn("agent-browser not installed", "(run: npm install)") else: if _is_termux(): check_info("Node.js not found (browser tools are optional in the tested Termux path)") check_info("Install Node.js on Termux with: pkg install nodejs") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=False): + check_info(step) else: check_warn("Node.js not found", "(optional, needed for browser tools)") diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index cb794465..eabcd0f9 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -237,6 +237,28 @@ class TestCLIStatusBar: cli_obj._spinner_text = "" assert cli_obj._spinner_widget_height(width=90) == 0 + def test_voice_status_bar_compacts_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._voice_mode = True + cli_obj._voice_recording = False + cli_obj._voice_processing = False + cli_obj._voice_tts = True + cli_obj._voice_continuous = True + + fragments = cli_obj._get_voice_status_fragments(width=50) + + assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")] + + def test_voice_recording_status_bar_compacts_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._voice_mode = True + cli_obj._voice_recording = True + cli_obj._voice_processing = False + + fragments = cli_obj._get_voice_status_fragments(width=50) + + assert fragments == [("class:voice-status-recording", " ● REC ")] + class TestCLIUsageReport: def test_show_usage_includes_estimated_cost(self, capsys): diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 1c1246e4..faaa7a8a 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -244,6 +244,10 @@ def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkey assert "Docker backend is not available inside Termux" in out assert "Node.js not found (browser tools are optional in the tested Termux path)" in out assert "Install Node.js on Termux with: pkg install nodejs" in out + assert "Termux browser setup:" in out + assert "1) pkg install nodejs" in out + assert "2) npm install -g agent-browser" in out + assert "3) agent-browser install" in out assert "docker not found (optional)" not in out diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 6ff64702..1d35c486 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -190,6 +190,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_CONNECTION", raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: None) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -198,6 +199,22 @@ class TestDetectAudioEnvironment: assert any("pkg install python-numpy portaudio" in w for w in result["warnings"]) assert any("python -m pip install sounddevice" in w for w in result["warnings"]) + def test_termux_api_package_without_android_app_blocks_voice(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False) + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any("Termux:API Android app is not installed" in w for w in result["warnings"]) + def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch): monkeypatch.setenv("TERMUX_VERSION", "0.118.3") @@ -206,6 +223,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_CONNECTION", raising=False) monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None) + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) from tools.voice_mode import detect_audio_environment @@ -224,6 +242,7 @@ class TestCheckVoiceRequirements: def test_termux_api_capture_counts_as_audio_available(self, monkeypatch): monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False) monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]}) monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai") @@ -286,6 +305,7 @@ class TestCreateAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder recorder = create_audio_recorder() @@ -293,6 +313,17 @@ class TestCreateAudioRecorder: assert isinstance(recorder, TermuxAudioRecorder) assert recorder.supports_silence_autostop is False + def test_termux_without_android_app_falls_back_to_audio_recorder(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False) + + from tools.voice_mode import create_audio_recorder, AudioRecorder + recorder = create_audio_recorder() + + assert isinstance(recorder, AudioRecorder) + class TestTermuxAudioRecorder: def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir): @@ -308,6 +339,7 @@ class TestTermuxAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) @@ -332,6 +364,7 @@ class TestTermuxAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index a3128eb4..d8ddfd23 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -71,8 +71,24 @@ def _termux_media_player_command() -> Optional[str]: return shutil.which("termux-media-player") +def _termux_api_app_installed() -> bool: + if not _is_termux_environment(): + return False + try: + result = subprocess.run( + ["pm", "list", "packages", "com.termux.api"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + return "package:com.termux.api" in (result.stdout or "") + except Exception: + return False + + def _termux_voice_capture_available() -> bool: - return _termux_microphone_command() is not None + return _termux_microphone_command() is not None and _termux_api_app_installed() def detect_audio_environment() -> dict: @@ -84,7 +100,9 @@ def detect_audio_environment() -> dict: """ warnings = [] # hard-fail: these block voice mode notices = [] # informational: logged but don't block - termux_capture = _termux_voice_capture_available() + termux_mic_cmd = _termux_microphone_command() + termux_app_installed = _termux_api_app_installed() + termux_capture = bool(termux_mic_cmd and termux_app_installed) # SSH detection if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): @@ -133,11 +151,19 @@ def detect_audio_environment() -> dict: except ImportError: if termux_capture: notices.append("Termux:API microphone recording available (sounddevice not required)") + elif termux_mic_cmd and not termux_app_installed: + warnings.append( + "Termux:API Android app is not installed. Install/update the Termux:API app to use termux-microphone-record." + ) else: warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") except OSError: if termux_capture: notices.append("Termux:API microphone recording available (PortAudio not required)") + elif termux_mic_cmd and not termux_app_installed: + warnings.append( + "Termux:API Android app is not installed. Install/update the Termux:API app to use termux-microphone-record." + ) elif _is_termux_environment(): warnings.append( "PortAudio system library not found -- install it first:\n" @@ -257,6 +283,11 @@ class TermuxAudioRecorder: "Install with: pkg install termux-api\n" "Then install/update the Termux:API Android app." ) + if not _termux_api_app_installed(): + raise RuntimeError( + "Termux voice capture requires the Termux:API Android app.\n" + "Install/update the Termux:API app, then retry /voice on." + ) with self._lock: if self._recording: From 69a0092c383e36857edc77aa8d4e411d3fcb7827 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 14:53:02 -0700 Subject: [PATCH 50/51] fix: deduplicate _is_termux() into hermes_constants.is_termux() Replace 6 identical copies of the Termux detection function across cli.py, browser_tool.py, voice_mode.py, status.py, doctor.py, and gateway.py with a single shared implementation in hermes_constants.py. Each call site imports with its original local name to preserve all existing callers (internal references and test monkeypatches). --- cli.py | 4 +--- hermes_cli/doctor.py | 4 +--- hermes_cli/gateway.py | 4 +--- hermes_cli/status.py | 4 +--- hermes_constants.py | 10 ++++++++++ tools/browser_tool.py | 4 +--- tools/voice_mode.py | 4 +--- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cli.py b/cli.py index cc9ca214..b93fde77 100644 --- a/cli.py +++ b/cli.py @@ -1017,9 +1017,7 @@ _IMAGE_EXTENSIONS = frozenset({ }) -def _is_termux_environment() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux as _is_termux_environment def _termux_example_image_path(filename: str = "cat.png") -> str: diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index e90631a9..fb629e0f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -54,9 +54,7 @@ _PROVIDER_ENV_HINTS = ( ) -def _is_termux() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux as _is_termux def _python_install_cmd() -> str: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 9e215ff2..b19ceaac 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -226,9 +226,7 @@ def is_linux() -> bool: return sys.platform.startswith('linux') -def is_termux() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux def supports_systemd_services() -> bool: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index a04d5701..11f4371b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -79,9 +79,7 @@ def _effective_provider_label() -> str: return provider_label(effective) -def _is_termux() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux as _is_termux def show_status(args): diff --git a/hermes_constants.py b/hermes_constants.py index 638d36a3..09005227 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -93,6 +93,16 @@ def parse_reasoning_effort(effort: str) -> dict | None: return None +def is_termux() -> bool: + """Return True when running inside a Termux (Android) environment. + + Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific + ``PREFIX`` path. Import-safe — no heavy deps. + """ + prefix = os.getenv("PREFIX", "") + return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) + + OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 5fc028b6..9ad8ba48 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -285,9 +285,7 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: return _cached_cloud_provider -def _is_termux_environment() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux as _is_termux_environment def _browser_install_hint() -> str: diff --git a/tools/voice_mode.py b/tools/voice_mode.py index d8ddfd23..b6f0df29 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -48,9 +48,7 @@ def _audio_available() -> bool: return False -def _is_termux_environment() -> bool: - prefix = os.getenv("PREFIX", "") - return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) +from hermes_constants import is_termux as _is_termux_environment def _voice_capture_install_hint() -> str: From 3b554bf839106f3f437e8a61aec46fac210560e2 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 9 Apr 2026 15:18:30 -0700 Subject: [PATCH 51/51] fix: test for suppress_status_output should capture stdout, not mock _vprint The test was mocking _vprint entirely, bypassing the suppress guard. Switch to capturing _print_fn output so the real _vprint runs and the guard suppresses retry noise as intended. --- tests/run_agent/test_run_agent.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 438612a3..11024820 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -5,6 +5,7 @@ pieces. The OpenAI client and tool loading are mocked so no network calls are made. """ +import io import json import logging import re @@ -1120,15 +1121,17 @@ class TestExecuteToolCalls: agent._save_trajectory = lambda *args, **kwargs: None agent._save_session_log = lambda *args, **kwargs: None - with patch("run_agent.time.sleep", return_value=None), \ - patch.object(agent, "_vprint") as mock_vprint: + captured = io.StringIO() + agent._print_fn = lambda *args, **kw: print(*args, file=captured, **kw) + + with patch("run_agent.time.sleep", return_value=None): result = agent.run_conversation("hello") assert result["completed"] is True assert result["final_response"] == "Recovered" - rendered = [" ".join(str(arg) for arg in call.args) for call in mock_vprint.call_args_list] - assert not any("API call failed" in line for line in rendered) - assert not any("Rate limit reached" in line for line in rendered) + output = captured.getvalue() + assert "API call failed" not in output + assert "Rate limit reached" not in output class TestConcurrentToolExecution: