diff --git a/Dockerfile b/Dockerfile index 7f4ebc2d152e..fb07200f8145 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,11 +57,12 @@ RUN chmod -R a+rX /opt/hermes # ---------- Python virtualenv ---------- RUN uv venv && \ - uv pip install --no-cache-dir -e ".[all]" + uv pip install --no-cache-dir -e ".[all]" && \ + chown -R hermes:hermes /opt/hermes/.venv # ---------- Runtime ---------- ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data -ENV PATH="/opt/data/.local/bin:${PATH}" +ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}" VOLUME [ "/opt/data" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ] diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 72457300261c..65e1353409d0 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -570,6 +570,7 @@ def _make_agent( "quiet_mode": True, "session_id": session_id, "model": model or default_model, + "fallback_model": config.get("fallback_providers") or config.get("fallback_model") or None, } try: @@ -587,6 +588,17 @@ def _make_agent( except Exception: logger.debug("ACP session falling back to default provider resolution", exc_info=True) + # Keep ACP behavior aligned with CLI/gateway: honor configured fallback + # chains so editor integrations can fail over on transient provider errors. + try: + fb = config.get("fallback_providers") or config.get("fallback_model") or [] + if isinstance(fb, dict): + fb = [fb] if fb.get("provider") and fb.get("model") else [] + if fb: + kwargs["fallback_model"] = fb + except Exception: + logger.debug("Could not load fallback_providers for ACP agent", exc_info=True) + _register_task_cwd(session_id, cwd) agent = AIAgent(**kwargs) # ACP stdio transport requires stdout to remain protocol-only JSON-RPC. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 13fb1c892418..c56c1ad8f1c5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -41,10 +41,57 @@ import time from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse -from openai import OpenAI +# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the +# openai SDK pulls a large type tree (~240 ms cold, including responses/*, +# graders/*). We expose `OpenAI` here as a thin proxy that imports the SDK on +# first call and forwards, so: +# (a) the 15+ in-module `OpenAI(...)` construction sites work unchanged +# (Python's function-scope name lookup resolves `OpenAI` to the proxy +# object bound in module globals here, without triggering any import); +# (b) external code can still do `auxiliary_client.OpenAI` or +# `patch("agent.auxiliary_client.OpenAI", ...)` — tests see the proxy, +# and patch replaces the module attribute as usual; +# (c) `OpenAI` as a type annotation resolves at runtime to the proxy class +# (which is harmless — annotations aren't type-checked at runtime). +# See tests/agent/test_auxiliary_client.py for patch patterns this supports. +if TYPE_CHECKING: + from openai import OpenAI # noqa: F401 — type hints only + +_OPENAI_CLS_CACHE: Optional[type] = None + + +def _load_openai_cls() -> type: + """Import and cache ``openai.OpenAI``.""" + global _OPENAI_CLS_CACHE + if _OPENAI_CLS_CACHE is None: + from openai import OpenAI as _cls + _OPENAI_CLS_CACHE = _cls + return _OPENAI_CLS_CACHE + + +class _OpenAIProxy: + """Module-level proxy that looks like the ``openai.OpenAI`` class. + + Forwards ``OpenAI(...)`` calls and ``isinstance(x, OpenAI)`` checks to the + real SDK class, importing the SDK lazily on first use. + """ + + __slots__ = () + + def __call__(self, *args, **kwargs): + return _load_openai_cls()(*args, **kwargs) + + def __instancecheck__(self, obj): + return isinstance(obj, _load_openai_cls()) + + def __repr__(self): + return "" + + +OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home @@ -94,18 +141,29 @@ def _extract_url_query_params(url: str): "github-models": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", + "tencent": "tencent-tokenhub", + "tokenhub": "tencent-tokenhub", + "tencent-cloud": "tencent-tokenhub", + "tencentmaas": "tencent-tokenhub", } def _normalize_aux_provider(provider: Optional[str]) -> str: normalized = (provider or "auto").strip().lower() - if normalized.startswith("custom:"): + # Preserve custom: prefix to avoid shadowing user-defined providers + # that may have names like "codex" that match built-in provider aliases + was_custom = normalized.startswith("custom:") + if was_custom: suffix = normalized.split(":", 1)[1].strip() if not suffix: return "custom" normalized = suffix - if normalized == "codex": - return "openai-codex" + # Only apply built-in aliases if NOT a custom provider + # This prevents user-defined custom providers like "codex" from + # being rewritten to "openai-codex", which breaks auxiliary paths + if not was_custom: + if normalized == "codex": + return "openai-codex" if normalized == "main": # Resolve to the user's actual main provider so named custom providers # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly. @@ -166,6 +224,7 @@ def _fixed_temperature_for_model( "opencode-go": "glm-5", "kilocode": "google/gemini-3-flash-preview", "ollama-cloud": "nemotron-3-nano:30b", + "tencent-tokenhub": "hy3-preview", } # Vision-specific model overrides for direct providers. @@ -405,6 +464,33 @@ def create(self, **kwargs) -> Any: # Note: the Codex endpoint (chatgpt.com/backend-api/codex) does NOT # support max_output_tokens or temperature — omit to avoid 400 errors. + # Translate extra_body.reasoning (chat.completions shape) into the + # Responses API's top-level reasoning + include fields. Mirrors + # agent/transports/codex.py::build_kwargs() so auxiliary callers + # that configure reasoning via auxiliary..extra_body get the + # same behavior as the main agent's Codex transport. + extra_body = kwargs.get("extra_body") or {} + if isinstance(extra_body, dict): + reasoning_cfg = extra_body.get("reasoning") + if isinstance(reasoning_cfg, dict): + if reasoning_cfg.get("enabled") is False: + # Reasoning explicitly disabled — do not set reasoning + # or include. The Codex backend still thinks by + # default, but we honor the caller's intent where the + # API allows it. + pass + else: + effort = reasoning_cfg.get("effort", "medium") + # Codex backend rejects "minimal"; clamp to "low" to + # match the main-agent Codex transport behavior. + if effort == "minimal": + effort = "low" + resp_kwargs["reasoning"] = { + "effort": effort, + "summary": "auto", + } + resp_kwargs["include"] = ["reasoning.encrypted_content"] + # Tools support for auxiliary callers (e.g. skills_hub) that pass function schemas tools = kwargs.get("tools") if tools: @@ -634,9 +720,7 @@ def create(self, **kwargs) -> Any: response = self._client.messages.create(**anthropic_kwargs) _transport = get_transport("anthropic_messages") - _nr = _transport.normalize_response( - response, strip_tool_prefix=self._is_oauth - ) + _nr = _transport.normalize_response(response) # ToolCall already duck-types as OpenAI shape (.type, .function.name, # .function.arguments) via properties, so no wrapping needed. @@ -714,6 +798,116 @@ def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"): self.base_url = sync_wrapper.base_url +def _endpoint_speaks_anthropic_messages(base_url: str) -> bool: + """True if the endpoint at ``base_url`` speaks the Anthropic Messages + protocol instead of OpenAI chat.completions. + + Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the + auxiliary client and the main agent stay in sync on transport selection. + Covers: + + - Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies, + Anthropic-compatible gateways). + - ``api.kimi.com/coding`` (Kimi Coding Plan — the /coding route only + speaks Claude-Code's native Anthropic shape; ``chat.completions`` + returns 404 on Anthropic-only model aliases like ``kimi-for-coding``). + - ``api.anthropic.com`` (native Anthropic). + """ + normalized = (base_url or "").strip().lower().rstrip("/") + if not normalized: + return False + if normalized.endswith("/anthropic"): + return True + hostname = base_url_hostname(normalized) + if hostname == "api.anthropic.com": + return True + if hostname == "api.kimi.com" and "/coding" in normalized: + return True + return False + + +def _maybe_wrap_anthropic( + client_obj: Any, + model: str, + api_key: str, + base_url: str, + api_mode: Optional[str] = None, +) -> Any: + """Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when + the endpoint actually speaks Anthropic Messages. + + This is the single chokepoint for aux-client transport correction. + Runs at the end of every ``resolve_provider_client`` branch so that + api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and + future /anthropic gateways all land on the right wire format + regardless of which branch built the client. + + Returns ``client_obj`` unchanged when: + + - It's already an Anthropic/Codex/Gemini/CopilotACP wrapper. + - The endpoint is an OpenAI-wire endpoint. + - ``api_mode`` is explicitly set to a non-Anthropic transport. + - The ``anthropic`` SDK is not installed (falls back to OpenAI wire). + """ + # Already wrapped — don't double-wrap. + if isinstance(client_obj, AnthropicAuxiliaryClient): + return client_obj + # Other specialized adapters we should never re-dispatch. + if isinstance(client_obj, CodexAuxiliaryClient): + return client_obj + try: + from agent.gemini_native_adapter import GeminiNativeClient + if isinstance(client_obj, GeminiNativeClient): + return client_obj + except ImportError: + pass + try: + from agent.copilot_acp_client import CopilotACPClient + if isinstance(client_obj, CopilotACPClient): + return client_obj + except ImportError: + pass + + # Explicit non-anthropic api_mode wins over URL heuristics. + if api_mode and api_mode != "anthropic_messages": + return client_obj + + should_wrap = ( + api_mode == "anthropic_messages" + or _endpoint_speaks_anthropic_messages(base_url) + ) + if not should_wrap: + return client_obj + + try: + from agent.anthropic_adapter import build_anthropic_client + except ImportError: + logger.warning( + "Endpoint %s speaks Anthropic Messages but the anthropic SDK is " + "not installed — falling back to OpenAI-wire (will likely 404).", + base_url, + ) + return client_obj + + try: + real_client = build_anthropic_client(api_key, base_url) + except Exception as exc: + logger.warning( + "Failed to build Anthropic client for %s (%s) — falling back to " + "OpenAI-wire client.", base_url, exc, + ) + return client_obj + + logger.debug( + "Auxiliary transport: wrapping client in AnthropicAuxiliaryClient " + "(model=%s, base_url=%s, api_mode=%s)", + model, base_url[:60] if base_url else "", api_mode or "auto-detected", + ) + return AnthropicAuxiliaryClient( + real_client, model, api_key, base_url, is_oauth=False, + ) + + def _read_nous_auth() -> Optional[dict]: """Read and validate ~/.hermes/auth.json for an active Nous provider. @@ -884,7 +1078,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() - return OpenAI(api_key=api_key, base_url=base_url, **extra), model + _client = OpenAI(api_key=api_key, base_url=base_url, **extra) + _client = _maybe_wrap_anthropic(_client, model, api_key, base_url) + return _client, model creds = resolve_api_key_provider_credentials(provider_id) api_key = str(creds.get("api_key", "")).strip() @@ -910,7 +1106,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() - return OpenAI(api_key=api_key, base_url=base_url, **extra), model + _client = OpenAI(api_key=api_key, base_url=base_url, **extra) + _client = _maybe_wrap_anthropic(_client, model, api_key, base_url) + return _client, model return None, None @@ -919,7 +1117,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: -def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]: +def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Optional[str]]: pool_present, entry = _select_pool_entry("openrouter") if pool_present: or_key = _pool_runtime_api_key(entry) @@ -930,7 +1128,7 @@ def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]: return OpenAI(api_key=or_key, base_url=base_url, default_headers=_OR_HEADERS), _OPENROUTER_MODEL - or_key = os.getenv("OPENROUTER_API_KEY") + or_key = (explicit_api_key or "").strip() or os.getenv("OPENROUTER_API_KEY") if not or_key: return None, None logger.debug("Auxiliary client: OpenRouter") @@ -1194,7 +1392,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False), model, ) - return OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model + # URL-based anthropic detection for custom endpoints that didn't set + # api_mode explicitly (e.g. kimi.com/coding reached via custom config). + _fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra) + _fallback_client = _maybe_wrap_anthropic( + _fallback_client, model, custom_key, custom_base, custom_mode, + ) + return _fallback_client, model def _try_codex() -> Tuple[Optional[Any], Optional[str]]: @@ -1745,8 +1949,20 @@ def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool: return True return False - def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): - """Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed.""" + def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", + api_key_str: str = ""): + """Wrap a plain OpenAI client in the correct transport adapter. + + Handles two cases: + - ``CodexAuxiliaryClient`` when the endpoint needs the Responses API + (explicit ``api_mode=codex_responses`` or api.openai.com + codex + model name). + - ``AnthropicAuxiliaryClient`` when the endpoint speaks Anthropic + Messages (explicit ``api_mode=anthropic_messages``, any ``/anthropic`` + suffix, ``api.kimi.com/coding``, or ``api.anthropic.com``). + + Clients that are already specialized wrappers pass through unchanged. + """ if _needs_codex_wrap(client_obj, base_url_str, final_model_str): logger.debug( "resolve_provider_client: wrapping client in CodexAuxiliaryClient " @@ -1754,7 +1970,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): api_mode or "auto-detected", final_model_str, base_url_str[:60] if base_url_str else "") return CodexAuxiliaryClient(client_obj, final_model_str) - return client_obj + # Anthropic-wire endpoints: rewrap plain OpenAI clients so + # chat.completions.create() is translated to /v1/messages. + return _maybe_wrap_anthropic( + client_obj, final_model_str, api_key_str, base_url_str, api_mode, + ) # ── Auto: try all providers in priority order ──────────────────── if provider == "auto": @@ -1776,7 +1996,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── OpenRouter ─────────────────────────────────────────────────── if provider == "openrouter": - client, default = _try_openrouter() + client, default = _try_openrouter(explicit_api_key=explicit_api_key) if client is None: logger.warning( "resolve_provider_client: openrouter requested but %s", @@ -1834,7 +2054,13 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ─────────── if provider == "custom": if explicit_base_url: - custom_base = explicit_base_url.strip() + # Only rewrite /anthropic → /v1 when the caller is NOT using + # api_mode=anthropic_messages, which sends Anthropic-native request + # bodies and expects an /anthropic-compatible endpoint. + if api_mode != "anthropic_messages": + custom_base = _to_openai_base_url(explicit_base_url).strip() + else: + custom_base = explicit_base_url.strip() custom_key = ( (explicit_api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip() @@ -1847,7 +2073,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): ) return None, None final_model = _normalize_resolved_model( - model or _read_main_model() or "gpt-4o-mini", + model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini", provider, ) extra = {} @@ -1862,7 +2088,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): is_agent_turn=True, is_vision=is_vision ) client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) - client = _wrap_if_needed(client, final_model, custom_base) + client = _wrap_if_needed(client, final_model, custom_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) # Try custom first, then codex, then API-key providers @@ -1872,7 +2098,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): if client is not None: final_model = _normalize_resolved_model(model or default, provider) _cbase = str(getattr(client, "base_url", "") or "") - client = _wrap_if_needed(client, final_model, _cbase) + _ckey = str(getattr(client, "api_key", "") or "") + client = _wrap_if_needed(client, final_model, _cbase, _ckey) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) logger.warning("resolve_provider_client: custom/main requested " @@ -1895,10 +2122,22 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): entry_api_mode = (api_mode or custom_entry.get("api_mode") or "").strip() if custom_base: final_model = _normalize_resolved_model( - model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini", + model + or custom_entry.get("model") + or (main_runtime.get("model") if main_runtime else None) + or _read_main_model() + or "gpt-4o-mini", provider, ) - _clean_base2, _dq2 = _extract_url_query_params(custom_base) + # anthropic_messages talks to the /anthropic surface directly; + # OpenAI-wire paths (chat_completions / codex_responses) need the + # /v1 equivalent. Rewrite only on the OpenAI-wire path so the + # Anthropic fallback SDK still sees the original URL. + if entry_api_mode == "anthropic_messages": + openai_base = custom_base + else: + openai_base = _to_openai_base_url(custom_base) + _clean_base2, _dq2 = _extract_url_query_params(openai_base) _extra2 = {"default_query": _dq2} if _dq2 else {} logger.debug( "resolve_provider_client: named custom provider %r (%s, api_mode=%s)", @@ -1917,7 +2156,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): "installed — falling back to OpenAI-wire.", provider, ) - client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2) + # Fallback went OpenAI-wire after all — redo the query + # extraction against the rewritten /v1 URL. + _fallback_base = _to_openai_base_url(custom_base) + _fb_clean, _fb_dq = _extract_url_query_params(_fallback_base) + _fb_extra = {"default_query": _fb_dq} if _fb_dq else {} + client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) sync_anthropic = AnthropicAuxiliaryClient( @@ -1936,7 +2180,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): ): client = CodexAuxiliaryClient(client, final_model) else: - client = _wrap_if_needed(client, final_model, custom_base) + client = _wrap_if_needed(client, final_model, openai_base, custom_key) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) logger.warning( @@ -2029,8 +2273,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # Honor api_mode for any API-key provider (e.g. direct OpenAI with # codex-family models). The copilot-specific wrapping above handles - # copilot; this covers the general case (#6800). - client = _wrap_if_needed(client, final_model, base_url) + # copilot; this covers the general case (#6800). Also rewraps + # Anthropic-wire endpoints (Kimi Coding Plan api.kimi.com/coding, + # /anthropic-suffixed gateways) so named providers like kimi-coding + # land on the right transport without needing per-provider branches. + client = _wrap_if_needed(client, final_model, base_url, api_key) logger.debug("resolve_provider_client: %s (%s)", provider, final_model) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode @@ -2038,7 +2285,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): if pconfig.auth_type == "external_process": creds = resolve_external_process_provider_credentials(provider) - final_model = _normalize_resolved_model(model or _read_main_model(), provider) + final_model = _normalize_resolved_model( + model + or (main_runtime.get("model") if main_runtime else None) + or _read_main_model(), + provider, + ) if provider == "copilot-acp": api_key = str(creds.get("api_key", "")).strip() base_url = str(creds.get("base_url", "")).strip() @@ -2132,10 +2384,43 @@ def get_text_auxiliary_client( Args: task: Optional task name ("compression", "web_extract") to check for a task-specific provider override. + main_runtime: Optional live runtime dict with provider/model/base_url/ + api_key/api_mode. When provided, this takes priority over config + file values for consistent runtime behavior with main loop. Callers may override the returned model via config.yaml (e.g. auxiliary.compression.model, auxiliary.web_extract.model). """ + # When main_runtime is provided, use it directly to ensure aux tasks + # respect runtime mutations (model switch, provider override, etc.) + # consistent with the main conversation loop. + if main_runtime and any(main_runtime.get(k) for k in ("provider", "model", "base_url", "api_key")): + runtime = _normalize_main_runtime(main_runtime) + runtime_provider = runtime.get("provider", "") + runtime_model = runtime.get("model", "") + runtime_base_url = runtime.get("base_url", "") + runtime_api_key = runtime.get("api_key", "") + runtime_api_mode = runtime.get("api_mode", "") + + # Handle custom provider case with explicit base_url + if runtime_base_url and (runtime_provider == "custom" or runtime_provider.startswith("custom:")): + return resolve_provider_client( + "custom", + model=runtime_model, + explicit_base_url=runtime_base_url, + explicit_api_key=runtime_api_key, + api_mode=runtime_api_mode or None, + ) + + # Use main provider directly for aux tasks + if runtime_provider and runtime_provider not in ("auto", ""): + return resolve_provider_client( + runtime_provider, + model=runtime_model, + api_mode=runtime_api_mode or None, + ) + + # Fallback to config-based resolution (legacy path for gateway/cron) provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None) return resolve_provider_client( provider, @@ -2153,7 +2438,42 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di For standard providers returns (AsyncOpenAI, model). For Codex returns (AsyncCodexAuxiliaryClient, model) which wraps the Responses API. Returns (None, None) when no provider is available. + + When main_runtime is provided, this takes priority over config file values + for consistent runtime behavior with main loop. """ + # When main_runtime is provided, use it directly to ensure aux tasks + # respect runtime mutations (model switch, provider override, etc.) + # consistent with the main conversation loop. + if main_runtime and any(main_runtime.get(k) for k in ("provider", "model", "base_url", "api_key")): + runtime = _normalize_main_runtime(main_runtime) + runtime_provider = runtime.get("provider", "") + runtime_model = runtime.get("model", "") + runtime_base_url = runtime.get("base_url", "") + runtime_api_key = runtime.get("api_key", "") + runtime_api_mode = runtime.get("api_mode", "") + + # Handle custom provider case with explicit base_url + if runtime_base_url and (runtime_provider == "custom" or runtime_provider.startswith("custom:")): + return resolve_provider_client( + "custom", + model=runtime_model, + async_mode=True, + explicit_base_url=runtime_base_url, + explicit_api_key=runtime_api_key, + api_mode=runtime_api_mode or None, + ) + + # Use main provider directly for aux tasks + if runtime_provider and runtime_provider not in ("auto", ""): + return resolve_provider_client( + runtime_provider, + model=runtime_model, + async_mode=True, + api_mode=runtime_api_mode or None, + ) + + # Fallback to config-based resolution (legacy path for gateway/cron) provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None) return resolve_provider_client( provider, @@ -2229,6 +2549,7 @@ def resolve_vision_provider_client( provider: Optional[str] = None, model: Optional[str] = None, *, + main_runtime: Optional[Dict[str, Any]] = None, base_url: Optional[str] = None, api_key: Optional[str] = None, async_mode: bool = False, @@ -2239,7 +2560,29 @@ def resolve_vision_provider_client( provider overrides still use the generic provider router for non-standard backends, so users can intentionally force experimental providers. Auto mode stays conservative and only tries vision backends known to work today. + + When main_runtime is provided, this takes priority over config file values + for consistent runtime behavior with main loop. """ + # When main_runtime is provided, use it directly to ensure vision tasks + # respect runtime mutations (model switch, provider override, etc.) + # consistent with the main conversation loop. + if main_runtime and any(main_runtime.get(k) for k in ("provider", "model", "base_url", "api_key")): + runtime = _normalize_main_runtime(main_runtime) + runtime_provider = runtime.get("provider", "") + runtime_model = runtime.get("model", "") + + if runtime_provider and runtime_provider not in ("auto", ""): + # Use main provider for vision + client, resolved = resolve_provider_client( + runtime_provider, + model=runtime_model, + is_vision=True, + async_mode=async_mode, + ) + if client is not None: + return runtime_provider, client, resolved or runtime_model + requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model( "vision", provider, model, base_url, api_key ) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 9bed919503f0..4d407d3c0d4e 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -17,6 +17,7 @@ - Richer tool call/result detail in summarizer input """ +import copy import hashlib import json import logging @@ -45,7 +46,10 @@ "summary — resume exactly from there. " "Respond ONLY to the latest user message " "that appears AFTER this summary. The current session state (files, " - "config, etc.) may reflect work described here — avoid repeating it:" + "config, etc.) may reflect work described here — avoid repeating it. " + "IMPORTANT: Your persistent memory block (if present) is authoritative " + "and supersedes this summary — check memory for facts about the user, " + "your identity, and prior context." ) LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" @@ -55,6 +59,22 @@ _SUMMARY_RATIO = 0.20 # Absolute ceiling for summary tokens (even on very large context windows) _SUMMARY_TOKENS_CEILING = 12_000 +_SUMMARY_PROMPT_OVERHEAD_TOKENS = 3_000 +_SUMMARY_REQUEST_SAFETY_RATIO = 0.90 + +_REFETCHABLE_TOOL_NAMES = frozenset({ + "browser_snapshot", + "browser_navigate", + "browser_click", + "browser_type", + "browser_scroll", + "web_search", + "search_files", + "read_file", +}) +_ATTACHED_FILE_REF_RE = re.compile( + r"📄\s+@file:(?P.+?)\s+\((?P\d+)\s+tokens\)" +) # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -432,6 +452,7 @@ def __init__( self.last_prompt_tokens = 0 self.last_completion_tokens = 0 + self.summary_model_override = summary_model_override self.summary_model = summary_model_override or "" # Stores the previous compaction summary for iterative updates @@ -904,6 +925,35 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi self._summary_failure_cooldown_until = 0.0 # no cooldown return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately + # Unknown-error best-effort retry on main model. Losing N turns of + # context is almost always worse than one extra summary attempt. + should_retry_on_main = ( + (self.summary_model and self.summary_model != self.model) + or ( + not self.summary_model + and bool(self.model) + and self.summary_model_override is None + ) + ) + if ( + should_retry_on_main + and not getattr(self, "_summary_model_fallen_back", False) + ): + self._summary_model_fallen_back = True + logging.warning( + "Summary model '%s' failed (%s). " + "Retrying on main model '%s' before giving up.", + self.summary_model, e, self.model, + ) + _err_text = str(e).strip() or e.__class__.__name__ + if len(_err_text) > 220: + _err_text = _err_text[:217].rstrip() + "..." + self._last_aux_model_failure_error = _err_text + self._last_aux_model_failure_model = self.summary_model + self.summary_model = "" # empty = use main model + self._summary_failure_cooldown_until = 0.0 + return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + # Transient errors (timeout, rate limit, network) — shorter cooldown _transient_cooldown = 60 self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown @@ -929,6 +979,332 @@ def _with_summary_prefix(summary: str) -> str: break return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX + @staticmethod + def _strip_existing_summary_text(text: str) -> str: + """Remove an already-injected compaction summary from message text. + + Re-compression passes the previous summary separately as + ``PREVIOUS SUMMARY``. If the old summary message also sits in the + middle region, feeding it again as a normal turn causes recursive, + duplicate summaries. For merged summary+tail messages, keep only the + real tail content after the merge separator. + """ + if not isinstance(text, str): + return text + if not text.lstrip().startswith(SUMMARY_PREFIX): + return text + marker = "--- END OF CONTEXT SUMMARY" + marker_idx = text.find(marker) + if marker_idx < 0: + return "" + after = text.find("\n\n", marker_idx) + if after < 0: + return "" + return text[after + 2:].lstrip() + + def _drop_existing_summary_turns( + self, + turns: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Remove prior compaction summaries from turns being summarized.""" + cleaned: List[Dict[str, Any]] = [] + for msg in turns: + content = msg.get("content") + if isinstance(content, str): + stripped = self._strip_existing_summary_text(content) + if stripped == "": + continue + if stripped != content: + msg = {**msg, "content": stripped} + cleaned.append(msg) + continue + + if isinstance(content, list): + changed = False + new_content = [] + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + stripped = self._strip_existing_summary_text(block["text"]) + if stripped == "": + changed = True + continue + if stripped != block["text"]: + block = {**block, "text": stripped} + changed = True + new_content.append(block) + if not new_content: + continue + if changed: + msg = {**msg, "content": new_content} + cleaned.append(msg) + continue + + cleaned.append(msg) + return cleaned + + def _tombstone_tool_results_for_summary( + self, + turns: List[Dict[str, Any]], + *, + refetchable_tools: Optional[set[str]] = None, + target_tokens: Optional[int] = None, + estimate_fn=None, + large_threshold_chars: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Replace selected large/old tool results with compact tombstones. + + The assistant tool-call message and the role=tool protocol entry are + preserved. Only the tool content is shortened so the summarizer still + sees the call graph and the reason the result was cleared. When + ``target_tokens`` is provided, tombstone the best candidates only until + the selected estimator fits. + """ + refetchable_tools = refetchable_tools or _REFETCHABLE_TOOL_NAMES + estimate_fn = estimate_fn or self._estimate_summary_request_tokens + + tool_meta: dict[str, dict[str, Any]] = {} + for msg in turns: + if msg.get("role") != "assistant": + continue + for tc in msg.get("tool_calls") or []: + if not isinstance(tc, dict): + continue + cid = self._get_tool_call_id(tc) + if not cid: + continue + fn = tc.get("function", {}) or {} + tool_meta[cid] = { + "tool_name": fn.get("name", "?"), + "arguments": fn.get("arguments", ""), + } + + def _is_large(content: Any) -> tuple[bool, int]: + if content is None: + return False, 0 + if isinstance(content, str): + size = len(content) + else: + size = len(_content_text_for_contains(content)) + threshold = large_threshold_chars + if threshold is None: + threshold = max(4000, self.tail_token_budget * _CHARS_PER_TOKEN) + return size >= threshold, size + + def _tool_tombstone(msg: Dict[str, Any], *, size: int) -> str: + cid = msg.get("tool_call_id", "") + meta = tool_meta.get(cid, {}) + tool_name = meta.get("tool_name", msg.get("tool_name") or msg.get("name") or "?") + args = meta.get("arguments", "") + return ( + "[tool result compacted]\n" + f"tool: {tool_name}\n" + f"tool_call_id: {cid or '?'}\n" + f"args: {args}\n" + f"original_size: {size:,} chars\n" + f"refetchable: {'yes' if tool_name in refetchable_tools else 'no'}\n" + "reason: omitted during context compaction because the " + "compaction input exceeded the safe request budget.\n" + + ( + "note: rerun the same tool call if exact content is needed." + if tool_name in refetchable_tools + else "note: output was too large to retain; do not rerun " + "side-effecting tools unless the user asks." + ) + ) + + result = [m.copy() for m in turns] + candidates: list[tuple[int, bool, bool, int]] = [] + over_target = target_tokens is not None and estimate_fn(result) > target_tokens + for i, msg in enumerate(result): + if msg.get("role") != "tool": + continue + cid = msg.get("tool_call_id", "") + meta = tool_meta.get(cid, {}) + tool_name = meta.get("tool_name", msg.get("tool_name") or msg.get("name") or "?") + is_large, size = _is_large(msg.get("content")) + is_refetchable = tool_name in refetchable_tools + if is_large or is_refetchable or (over_target and size > 500): + candidates.append((i, is_large, is_refetchable, size)) + + if target_tokens is not None and estimate_fn(result) <= target_tokens: + return result + + # Prefer the biggest re-fetchable outputs, then other large outputs, + # then smaller re-fetchable outputs. Older equal-priority results go + # first because newer work is usually closer to the protected tail. + candidates.sort(key=lambda item: (not item[2], not item[1], -item[3], item[0])) + for i, _is_large_result, _is_refetchable, size in candidates: + msg = result[i] + tombstone = _tool_tombstone(msg, size=size) + new_msg = msg.copy() + new_msg["content"] = tombstone + result[i] = new_msg + if target_tokens is not None and estimate_fn(result) <= target_tokens: + break + return result + + def _compact_stale_large_user_messages( + self, + messages: List[Dict[str, Any]], + *, + target_tokens: Optional[int] = None, + large_threshold_chars: int = 24_000, + ) -> List[Dict[str, Any]]: + """Replace older huge user context blocks while preserving the latest ask. + + Failed retries can persist the same @file-expanded user message more + than once. The latest user message is the active request and must stay + intact; older large user messages should be represented by the summary + and a compact tombstone so one attached file cannot survive every + compaction pass as protected head context. + """ + if not messages: + return messages + + last_user_idx = -1 + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + last_user_idx = i + break + if last_user_idx < 0: + return messages + + result = [m.copy() for m in messages] + def _attached_file_paths(text: str) -> set[str]: + paths: set[str] = set() + for match in _ATTACHED_FILE_REF_RE.finditer(text): + path = match.group("path").strip().strip("`\"'") + if path: + paths.add(path) + return paths + + seen_hashes: set[str] = set() + seen_file_paths: set[str] = set() + candidates: list[tuple[int, bool, bool, int]] = [] + for i in range(len(result) - 1, -1, -1): + msg = result[i] + if msg.get("role") != "user": + continue + text = _content_text_for_contains(msg.get("content")) + size = len(text) + digest = hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest() if size > 1000 else "" + duplicate = bool(digest and digest in seen_hashes) + if digest: + seen_hashes.add(digest) + file_paths = _attached_file_paths(text) + duplicate_file_path = bool(file_paths and (file_paths & seen_file_paths)) + seen_file_paths.update(file_paths) + if i == last_user_idx: + continue + if duplicate or duplicate_file_path or size >= large_threshold_chars: + candidates.append((i, duplicate, duplicate_file_path, size)) + + if target_tokens is not None and estimate_messages_tokens_rough(result) <= target_tokens: + # Still remove exact older duplicates even when already under the + # target; same-file older injections are also stale context bloat. + candidates = [candidate for candidate in candidates if candidate[1] or candidate[2]] + + # Biggest stale contexts first; exact duplicates and same-file older + # injections before merely-large unrelated user messages. + candidates.sort(key=lambda item: (not (item[1] or item[2]), -item[3], item[0])) + for i, duplicate, duplicate_file_path, size in candidates: + msg = result[i] + text = _content_text_for_contains(msg.get("content")) + file_paths = sorted(_attached_file_paths(text)) + preview = " ".join(text[:500].split()) + if len(text) > 500: + preview += "..." + new_msg = msg.copy() + new_msg["content"] = ( + "[large user context compacted]\n" + f"original_size: {size:,} chars\n" + f"rough_tokens: {(size + 3) // _CHARS_PER_TOKEN:,}\n" + f"duplicate_of_later_user_message: {'yes' if duplicate else 'no'}\n" + f"older_duplicate_file_path: {'yes' if duplicate_file_path else 'no'}\n" + f"attached_files: {', '.join(file_paths) if file_paths else 'unknown'}\n" + "reason: older user-supplied context was omitted during " + "overflow compaction because the retry request exceeded the " + "model context window.\n" + "note: use the compaction summary plus the latest user message " + "for the active task; do not treat this tombstone as a new request.\n" + f"preview: {preview}" + ) + result[i] = new_msg + if target_tokens is not None and estimate_messages_tokens_rough(result) <= target_tokens: + break + return result + + def _truncate_tool_call_arguments_for_overflow( + self, + messages: List[Dict[str, Any]], + *, + head_chars: int = 200, + ) -> List[Dict[str, Any]]: + """Shrink oversized assistant tool-call arguments in retained context.""" + result = [m.copy() for m in messages] + for i, msg in enumerate(result): + if msg.get("role") != "assistant" or not msg.get("tool_calls"): + continue + new_tcs = [] + modified = False + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + fn = tc.get("function", {}) or {} + args = fn.get("arguments", "") + if isinstance(args, str) and len(args) > 500: + new_args = _truncate_tool_call_args_json(args, head_chars=head_chars) + if new_args != args: + tc = {**tc, "function": {**fn, "arguments": new_args}} + modified = True + new_tcs.append(tc) + if modified: + result[i] = {**msg, "tool_calls": new_tcs} + return result + + def compact_redundant_context( + self, + messages: List[Dict[str, Any]], + *, + target_tokens: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Deterministically trim retry bloat without calling an LLM. + + This is intentionally narrower than full summarizing compression. It + removes content that is either reproducible (tool outputs / tool-call + args) or stale duplicate user context (older @file payloads where a + newer user message attached the same file path). It is useful before + providers such as MiniMax, where every failed recovery request has a + fixed request cost. + """ + target = target_tokens or self.threshold_tokens + trimmed = self._truncate_tool_call_arguments_for_overflow(messages) + trimmed = self._tombstone_tool_results_for_summary( + trimmed, + target_tokens=target, + estimate_fn=estimate_messages_tokens_rough, + large_threshold_chars=4000, + ) + trimmed = self._compact_stale_large_user_messages( + trimmed, + target_tokens=target, + ) + return self._sanitize_tool_pairs(trimmed) + + def _summary_input_token_budget(self) -> int: + """Return the safe input budget for the auxiliary summarizer request.""" + effective_summary_tokens = max(_MIN_SUMMARY_TOKENS, self.max_summary_tokens) + output_budget = int(effective_summary_tokens * 1.3) + safety_window = int(self.context_length * _SUMMARY_REQUEST_SAFETY_RATIO) + return max( + _MIN_SUMMARY_TOKENS, + safety_window - output_budget - _SUMMARY_PROMPT_OVERHEAD_TOKENS, + ) + + def _estimate_summary_request_tokens(self, turns: List[Dict[str, Any]]) -> int: + """Estimate the summarizer request after per-message serialization caps.""" + serialized_chars = len(self._serialize_for_summary(turns)) + return serialized_chars // _CHARS_PER_TOKEN + _SUMMARY_PROMPT_OVERHEAD_TOKENS + # ------------------------------------------------------------------ # Tool-call / tool-result pair integrity helpers # ------------------------------------------------------------------ @@ -941,64 +1317,68 @@ def _get_tool_call_id(tc) -> str: return getattr(tc, "id", "") or "" def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Fix orphaned tool_call / tool_result pairs after compression. - - Two failure modes: - 1. A tool *result* references a call_id whose assistant tool_call was - removed (summarized/truncated). The API rejects this with - "No tool call found for function call output with call_id ...". - 2. An assistant message has tool_calls whose results were dropped. - The API rejects this because every tool_call must be followed by - a tool result with the matching call_id. - - This method removes orphaned results and inserts stub results for - orphaned calls so the message list is always well-formed. + """Fix and adjacency-normalize tool_call / tool_result pairs. + + Providers require every assistant tool_call to be followed immediately + by matching role=tool messages. Compression can remove or move either + side of the pair, so rebuild the ordering from surviving assistant + calls and surviving tool results. """ - surviving_call_ids: set = set() + surviving_call_ids: list[str] = [] for msg in messages: if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: cid = self._get_tool_call_id(tc) if cid: - surviving_call_ids.add(cid) + surviving_call_ids.append(cid) - result_call_ids: set = set() + surviving_id_set = set(surviving_call_ids) + tool_result_by_id: dict[str, Dict[str, Any]] = {} for msg in messages: if msg.get("role") == "tool": cid = msg.get("tool_call_id") - if cid: - result_call_ids.add(cid) - - # 1. Remove tool results whose call_id has no matching assistant tool_call - orphaned_results = result_call_ids - surviving_call_ids - if orphaned_results: - messages = [ - m for m in messages - if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results) - ] - if not self.quiet_mode: - logger.info("Compression sanitizer: removed %d orphaned tool result(s)", len(orphaned_results)) - - # 2. Add stub results for assistant tool_calls whose results were dropped - missing_results = surviving_call_ids - result_call_ids - if missing_results: - patched: List[Dict[str, Any]] = [] - for msg in messages: - patched.append(msg) - if msg.get("role") == "assistant": - for tc in msg.get("tool_calls") or []: - cid = self._get_tool_call_id(tc) - if cid in missing_results: - patched.append({ - "role": "tool", - "content": "[Result from earlier conversation — see context summary above]", - "tool_call_id": cid, - }) - messages = patched - if not self.quiet_mode: - logger.info("Compression sanitizer: added %d stub tool result(s)", len(missing_results)) + if cid and cid in surviving_id_set and cid not in tool_result_by_id: + tool_result_by_id[cid] = msg - return messages + patched: List[Dict[str, Any]] = [] + inserted_results = 0 + stubbed_results = 0 + removed_orphans = 0 + for msg in messages: + if msg.get("role") == "tool": + if msg.get("tool_call_id") not in surviving_id_set: + removed_orphans += 1 + continue + + patched.append(msg) + if msg.get("role") != "assistant": + continue + + for tc in msg.get("tool_calls") or []: + cid = self._get_tool_call_id(tc) + if not cid: + continue + existing = tool_result_by_id.get(cid) + if existing: + patched.append(existing) + inserted_results += 1 + else: + patched.append({ + "role": "tool", + "content": "[Result from earlier conversation — see context summary above]", + "tool_call_id": cid, + }) + stubbed_results += 1 + + if not self.quiet_mode and (removed_orphans or stubbed_results or inserted_results): + logger.info( + "Compression sanitizer: normalized tool pairs " + "(inserted=%d stubbed=%d removed_orphans=%d)", + inserted_results, + stubbed_results, + removed_orphans, + ) + return patched def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> int: """Push a compress-start boundary forward past any orphan tool results. @@ -1177,7 +1557,15 @@ def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool: # Main compression entry point # ------------------------------------------------------------------ - def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]: + def compress( + self, + messages: List[Dict[str, Any]], + current_tokens: int = None, + focus_topic: str = None, + *, + overflow_snapshot: Optional[Dict[str, Any]] = None, + overflow_mode: bool = False, + ) -> List[Dict[str, Any]]: """Compress conversation messages by summarizing middle turns. Algorithm: @@ -1209,7 +1597,13 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages) - # Phase 1: Prune old tool results (cheap, no LLM call) + # Phase 1: Prune old tool results (cheap, no LLM call). + # + # Keep the original messages for the LLM summary. The pruned copy is + # used for boundary calculation and final transcript assembly, but if + # the summarizer sees only "[read_file] read foo.py" style placeholders + # it cannot preserve the actual findings from old tool output. + summary_source_messages = messages messages, pruned_count = self._prune_old_tool_results( messages, protect_tail_count=self.protect_last_n, protect_tail_tokens=self.tail_token_budget, @@ -1227,7 +1621,35 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f if compress_start >= compress_end: return messages - turns_to_summarize = messages[compress_start:compress_end] + turns_to_summarize = self._drop_existing_summary_turns( + summary_source_messages[compress_start:compress_end] + ) + summary_input = turns_to_summarize + summary_input_estimate = self._estimate_summary_request_tokens(summary_input) + if overflow_mode and overflow_snapshot and isinstance(overflow_snapshot.get("messages"), list): + snapshot_messages = copy.deepcopy(overflow_snapshot["messages"]) + snapshot_middle = self._drop_existing_summary_turns( + snapshot_messages[compress_start:min(compress_end, len(snapshot_messages))] + ) + if not snapshot_middle and snapshot_messages: + snapshot_middle = self._drop_existing_summary_turns(snapshot_messages) + tail_count = min(self.protect_last_n + 3, len(summary_input)) + snapshot_candidate = snapshot_middle + if tail_count: + snapshot_candidate = snapshot_candidate + copy.deepcopy(summary_input[-tail_count:]) + snapshot_estimate = self._estimate_summary_request_tokens(snapshot_candidate) + if snapshot_estimate < summary_input_estimate: + summary_input = snapshot_candidate + summary_input_estimate = snapshot_estimate + + summary_input_budget = self._summary_input_token_budget() + if summary_input_estimate > summary_input_budget: + summary_input = self._tombstone_tool_results_for_summary( + summary_input, + target_tokens=summary_input_budget, + ) + if not self.quiet_mode: + logger.info("Compression: tombstoned oversized tool results before summarization") if not self.quiet_mode: logger.info( @@ -1252,7 +1674,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f ) # Phase 3: Generate structured summary - summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + summary = self._generate_summary(summary_input, focus_topic=focus_topic) # Phase 4: Assemble compressed message list compressed = [] @@ -1322,6 +1744,28 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f _merge_summary_into_tail = False compressed.append(msg) + if overflow_mode and estimate_messages_tokens_rough(compressed) > self.threshold_tokens: + before_overflow_trim = estimate_messages_tokens_rough(compressed) + compressed = self._truncate_tool_call_arguments_for_overflow(compressed) + compressed = self._tombstone_tool_results_for_summary( + compressed, + target_tokens=self.threshold_tokens, + estimate_fn=estimate_messages_tokens_rough, + large_threshold_chars=4000, + ) + compressed = self._compact_stale_large_user_messages( + compressed, + target_tokens=self.threshold_tokens, + ) + after_overflow_trim = estimate_messages_tokens_rough(compressed) + if after_overflow_trim < before_overflow_trim and not self.quiet_mode: + logger.info( + "Compression: overflow trim reduced retained transcript " + "from ~%d to ~%d tokens", + before_overflow_trim, + after_overflow_trim, + ) + self.compression_count += 1 compressed = self._sanitize_tool_pairs(compressed) diff --git a/agent/title_generator.py b/agent/title_generator.py index d5811580a0dd..e7de32691c75 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -8,7 +8,7 @@ import threading from typing import Callable, Optional -from agent.auxiliary_client import call_llm +from agent.auxiliary_client import call_llm, extract_content_or_reasoning logger = logging.getLogger(__name__) @@ -58,7 +58,7 @@ def generate_title( temperature=0.3, timeout=timeout, ) - title = (response.choices[0].message.content or "").strip() + title = extract_content_or_reasoning(response).strip() # Clean up: remove quotes, trailing punctuation, prefixes like "Title: " title = title.strip('"\'') if title.lower().startswith("title:"): diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 34d5caa88a96..10f1c3aa4caf 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -102,6 +102,7 @@ def build_kwargs( is_nvidia_nim: bool is_kimi: bool is_custom_provider: bool + is_zai: bool ollama_num_ctx: int | None # Provider routing provider_preferences: dict | None @@ -166,6 +167,8 @@ def build_kwargs( api_kwargs.pop("temperature", None) elif fixed_temp is not None: api_kwargs["temperature"] = fixed_temp + elif params.get("is_custom_provider", False): + api_kwargs["temperature"] = params.get("temperature", 0.2) # Qwen metadata (caller precomputes {sessionId, promptId}) qwen_meta = params.get("qwen_session_metadata") @@ -180,6 +183,7 @@ def build_kwargs( if is_moonshot_model(model): tools = sanitize_moonshot_tools(tools) api_kwargs["tools"] = tools + api_kwargs.setdefault("parallel_tool_calls", True) # max_tokens resolution — priority: ephemeral > user > provider default max_tokens_fn = params.get("max_tokens_param_fn") @@ -259,6 +263,16 @@ def build_kwargs( if is_nous: extra_body["tags"] = ["product=hermes-agent"] + # ZAI extra_body.thinking + if is_zai: + if reasoning_config and isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: + pass # omit for ZAI when disabled + else: + extra_body["thinking"] = {"type": "enabled"} + else: + extra_body["thinking"] = {"type": "enabled"} + # Ollama num_ctx ollama_ctx = params.get("ollama_num_ctx") if ollama_ctx: diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 1dfe59ea327c..741b504ea68a 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -555,6 +555,10 @@ def normalize_usage( output_details = getattr(response_usage, "output_tokens_details", None) if output_details: reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0)) + if reasoning_tokens == 0: + completion_details = getattr(response_usage, "completion_tokens_details", None) + if completion_details: + reasoning_tokens = _to_int(getattr(completion_details, "reasoning_tokens", 0)) return CanonicalUsage( input_tokens=input_tokens, diff --git a/cli-config.yaml.example b/cli-config.yaml.example index d6cb0bcb46f3..9ff5b5ec68f5 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -234,6 +234,11 @@ terminal: container_memory: 5120 # Memory in MB (5120 = 5GB) container_disk: 51200 # Disk in MB (51200 = 50GB) container_persistent: true # Persist filesystem across sessions (false = ephemeral) + # Docker tmpfs size overrides (default: 512m /tmp, 256m /var/tmp, 64m /run) + # Uncomment to allow larger tmpfs for tools like spaCy that download large models + # container_tempfs_tmp_size: 1024m # /tmp size (e.g. 512m, 1g, 2g) + # container_tempfs_var_tmp_size: 512m # /var/tmp size + # container_tempfs_run_size: 128m # /run size # ----------------------------------------------------------------------------- # SUDO SUPPORT (works with ALL backends above) diff --git a/cli.py b/cli.py index 0d5bcf9fda2e..195536cf4ca0 100644 --- a/cli.py +++ b/cli.py @@ -34,6 +34,17 @@ from datetime import datetime from typing import List, Dict, Any, Optional + +def _is_loopback_hostname(hostname: str) -> bool: + """Return True if hostname indicates a loopback interface.""" + if not isinstance(hostname, str): + return False + loopbacks = {"localhost", "127.0.0.1", "::1", "0.0.0.0"} + for lb in loopbacks: + if lb in hostname: + return True + return False + logger = logging.getLogger(__name__) # Suppress startup messages for clean CLI experience @@ -3269,6 +3280,16 @@ def _ensure_runtime_credentials(self) -> bool: base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") resolved_api_mode = runtime.get("api_mode", self.api_mode) + # Preserve explicitly-selected non-default transport modes across turns. + # Some providers/models require anthropic_messages or codex_responses; + # runtime resolution may return chat_completions as a generic default. + if ( + resolved_provider == self.provider + and self.api_mode + and self.api_mode != "chat_completions" + and resolved_api_mode == "chat_completions" + ): + resolved_api_mode = self.api_mode resolved_acp_command = runtime.get("command") resolved_acp_args = list(runtime.get("args") or []) resolved_credential_pool = runtime.get("credential_pool") @@ -3278,7 +3299,7 @@ def _ensure_runtime_credentials(self) -> bool: # no API key was found, use a placeholder so the OpenAI SDK # doesn't reject the request and local servers just ignore it. _source = runtime.get("source", "") - _has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url + _has_custom_base = isinstance(base_url, str) and base_url and ("openrouter.ai" not in base_url or _is_loopback_hostname(base_url)) if _has_custom_base: api_key = "no-key-required" logger.debug( @@ -4636,7 +4657,7 @@ def show_config(self): # Get terminal config from environment (which was set from cli-config.yaml) terminal_env = os.getenv("TERMINAL_ENV", "local") terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd()) - terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60") + terminal_timeout = self.config.get("terminal.timeout", os.getenv("TERMINAL_TIMEOUT", "60")) user_config_path = _hermes_home / 'config.yaml' project_config_path = Path(__file__).parent / 'cli-config.yaml' @@ -11165,7 +11186,10 @@ def main( # Signal to terminal_tool that we're in interactive mode # This enables interactive sudo password prompts with timeout - os.environ["HERMES_INTERACTIVE"] = "1" + # Only set when stdin and stdout are real TTYs to avoid breaking + # piped/non-interactive usage (CI, Docker, cron jobs) + if sys.stdin.isatty() and sys.stdout.isatty(): + os.environ["HERMES_INTERACTIVE"] = "1" # Handle gateway mode (messaging + cron) if gateway: diff --git a/cron/jobs.py b/cron/jobs.py index 6c0a2405b2ec..ffc2e1fbc9b0 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -788,19 +788,27 @@ def get_due_jobs() -> List[Dict[str, Any]]: next_run = job.get("next_run_at") if not next_run: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") recovered_next = _recoverable_oneshot_run_at( - job.get("schedule", {}), + schedule, now, last_run_at=job.get("last_run_at"), ) + recovery_kind = "one-shot" if recovered_next else None + if not recovered_next and kind in ("cron", "interval"): + recovered_next = compute_next_run(schedule, now.isoformat()) + if recovered_next: + recovery_kind = kind if not recovered_next: continue job["next_run_at"] = recovered_next next_run = recovered_next logger.info( - "Job '%s' had no next_run_at; recovering one-shot run at %s", + "Job '%s' had no next_run_at; recovering %s run at %s", job.get("name", job["id"]), + recovery_kind, recovered_next, ) for rj in raw_jobs: diff --git a/cron/scheduler.py b/cron/scheduler.py index 21ec8dbdec26..5d7261ca192e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -123,9 +123,13 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: def _resolve_origin(job: dict) -> Optional[dict]: - """Extract origin info from a job, preserving any extra routing metadata.""" + """Extract origin info from a job, preserving any extra routing metadata. + + ``origin`` is expected to be ``None`` or a dict with routing fields. + Tolerate non-dict values (string/list/int) by returning ``None``. + """ origin = job.get("origin") - if not origin: + if not origin or not isinstance(origin, dict): return None platform = origin.get("platform") chat_id = origin.get("chat_id") @@ -198,7 +202,9 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d if resolved: parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved) if resolved_is_explicit: - chat_id, thread_id = parsed_chat_id, parsed_thread_id + chat_id = parsed_chat_id + if parsed_thread_id is not None: + thread_id = parsed_thread_id else: chat_id = resolved except Exception: @@ -236,7 +242,11 @@ def _resolve_delivery_targets(job: dict) -> List[dict]: deliver = job.get("deliver", "local") if deliver == "local": return [] - parts = [p.strip() for p in str(deliver).split(",") if p.strip()] + # Handle deliver as both string ("telegram,discord") and list (["telegram", "discord"]) + if isinstance(deliver, list): + parts = [p.strip() for p in deliver if p.strip()] + else: + parts = [p.strip() for p in str(deliver).split(",") if p.strip()] seen = set() targets = [] for part in parts: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 299aab97a224..df04ca06808a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,6 +10,10 @@ INSTALL_DIR="/opt/hermes" # optionally remap the hermes user/group to match host-side ownership, fix volume # permissions, then re-exec as hermes. if [ "$(id -u)" = "0" ]; then + # If caller sets HERMES_HOME outside /opt/data (e.g. /home/hermes/.hermes), + # ensure the parent path exists before dropping privileges so mkdir -p later + # doesn't fail with EPERM as the hermes user. + mkdir -p "$(dirname "$HERMES_HOME")" 2>/dev/null || true if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "Changing hermes UID to $HERMES_UID" usermod -u "$HERMES_UID" hermes @@ -22,6 +26,10 @@ if [ "$(id -u)" = "0" ]; then groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true fi + # Ensure the home path itself exists before ownership checks. Without this, + # stat/chown paths can fail on first boot with custom HERMES_HOME mounts. + mkdir -p "$HERMES_HOME" 2>/dev/null || true + # Fix ownership of the data volume. When HERMES_UID remaps the hermes user, # files created by previous runs (under the old UID) become inaccessible. # Always chown -R when UID was remapped; otherwise only if top-level is wrong. @@ -64,7 +72,7 @@ source "${INSTALL_DIR}/.venv/bin/activate" # The "home/" subdirectory is a per-profile HOME for subprocesses (git, # ssh, gh, npm …). Without it those tools write to /root which is # ephemeral and shared across profiles. See issue #4426. -mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home} +mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home,.local,.local/bin} # .env if [ ! -f "$HERMES_HOME/.env" ]; then diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 94936ac9dd51..58f64db0cb65 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -15,6 +15,7 @@ from utils import atomic_json_write logger = logging.getLogger(__name__) +_SLACK_CHANNEL_LIST_WARNED_TEAMS: set[str] = set() DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" @@ -183,10 +184,19 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: if not cursor: break except Exception as e: - logger.warning( - "Channel directory: failed to list Slack channels for team %s: %s", - team_id, e, - ) + # Avoid high-frequency warning spam on recurring Slack API failures. + # Keep the first warning visible, then downgrade repeats to debug. + if team_id not in _SLACK_CHANNEL_LIST_WARNED_TEAMS: + logger.warning( + "Channel directory: failed to list Slack channels for team %s: %s", + team_id, e, + ) + _SLACK_CHANNEL_LIST_WARNED_TEAMS.add(team_id) + else: + logger.debug( + "Channel directory: repeated Slack channel-list failure for team %s: %s", + team_id, e, + ) continue # Merge in DM/group entries discovered from session history. diff --git a/gateway/config.py b/gateway/config.py index 128bfa61ca0c..8cec869cca06 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1356,3 +1356,18 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.default_reset_policy.at_hour = int(reset_hour) except ValueError: pass + + # Generic plugin platform home channel support: + # For any platform (built-in or plugin), check if {PLATFORM}_HOME_CHANNEL + # is set and apply it automatically. This handles plugin platforms that + # set HOME_CHANNEL in .env but weren't getting it applied. + for platform in config.platforms: + if config.platforms[platform].enabled: + prefix = platform.name.upper().replace("-", "_") + home_channel_env = os.getenv(f"{prefix}_HOME_CHANNEL") + if home_channel_env and not config.platforms[platform].home_channel: + config.platforms[platform].home_channel = HomeChannel( + platform=platform, + chat_id=home_channel_env, + name=os.getenv(f"{prefix}_HOME_CHANNEL_NAME", "Home"), + ) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e0b2a64c672f..d3f3ee455e63 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -608,6 +608,19 @@ async def connect(self) -> bool: if proxy_url: logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) + # Ensure we don't leave a previous websocket client alive across + # reconnect/restart cycles. A stale client can keep receiving the + # same inbound event and produce duplicate responses. + if self._client is not None: + try: + if not self._client.is_closed(): + await self._client.close() + except Exception as exc: + logger.debug("[%s] Failed to close previous Discord client: %s", self.name, exc) + finally: + self._client = None + self._ready_event.clear() + # Create bot — proxy= for HTTP, connector= for SOCKS. # allowed_mentions is set with safe defaults (no @everyone/roles) # so LLM output or echoed user content can't ping the whole @@ -643,7 +656,7 @@ async def on_message(message: DiscordMessage): # IDs (otherwise on_message's author.id lookup can miss). if not adapter_self._ready_event.is_set(): try: - await asyncio.wait_for(adapter_self._ready_event.wait(), timeout=30.0) + await asyncio.wait_for(adapter_self._ready_event.wait(), timeout=120.0) except asyncio.TimeoutError: pass @@ -753,7 +766,7 @@ async def on_voice_state_update(member, before, after): self._bot_task = asyncio.create_task(self._client.start(self.config.token)) # Wait for ready - await asyncio.wait_for(self._ready_event.wait(), timeout=30) + await asyncio.wait_for(self._ready_event.wait(), timeout=120) self._running = True return True @@ -809,11 +822,11 @@ async def _run_post_connect_initialization(self) -> None: return if sync_policy == "bulk": - synced = await asyncio.wait_for(self._client.tree.sync(), timeout=30) + synced = await asyncio.wait_for(self._client.tree.sync(), timeout=120) logger.info("[%s] Synced %d slash command(s) via bulk tree sync", self.name, len(synced)) return - summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=30) + summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=120) logger.info( "[%s] Safely reconciled %d slash command(s): unchanged=%d updated=%d recreated=%d created=%d deleted=%d", self.name, @@ -1527,8 +1540,10 @@ def _after(error): logger.error("Voice playback error: %s", error) loop.call_soon_threadsafe(done.set) + vc.speaking(False) source = discord.FFmpegPCMAudio(audio_path) source = discord.PCMVolumeTransformer(source, volume=1.0) + vc.speaking(True) vc.play(source, after=_after) try: await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT) @@ -3211,6 +3226,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: channel_ids.add(parent_channel_id) require_mention = self._discord_require_mention() + strict_mention = self._discord_strict_mention() # Voice-linked text channels act as free-response while voice is active. # Only the exact bound channel gets the exemption, not sibling threads. voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} @@ -3226,7 +3242,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: # the bot has previously participated (auto-created or replied in). in_bot_thread = is_thread and thread_id in self._threads - if require_mention and not is_free_channel and not in_bot_thread: + if require_mention and not is_free_channel and not (in_bot_thread and not strict_mention): if self._client.user not in message.mentions and not mention_prefix: return # Auto-thread: when enabled, automatically create a thread for every @@ -3528,6 +3544,16 @@ async def _flush_text_batch(self, key: str) -> None: self._pending_text_batch_tasks.pop(key, None) + + def _discord_strict_mention(self) -> bool: + """Return whether Discord channel messages require a bot mention even in known threads.""" + configured = self.config.extra.get("strict_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("DISCORD_STRICT_MENTION", "false").lower() not in ("false", "0", "no", "off") + # --------------------------------------------------------------------------- # Discord UI Components (outside the adapter class) # --------------------------------------------------------------------------- @@ -3545,7 +3571,7 @@ class ExecApprovalView(discord.ui.View): """ def __init__(self, session_key: str, allowed_user_ids: set): - super().__init__(timeout=300) # 5-minute timeout + super().__init__(timeout=1200) # 5-minute timeout self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.resolved = False @@ -3638,7 +3664,7 @@ class UpdatePromptView(discord.ui.View): """ def __init__(self, session_key: str, allowed_user_ids: set): - super().__init__(timeout=300) + super().__init__(timeout=1200) self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.resolved = False diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 9d12441357ae..d1725b2c8e39 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -94,8 +94,12 @@ def _decode_header_value(raw: str) -> str: for part, charset in parts: if isinstance(part, bytes): decoded.append(part.decode(charset or "utf-8", errors="replace")) - else: + elif isinstance(part, str): decoded.append(part) + else: + # Some IMAP servers can surface non-string header chunks + # (e.g. integers). Normalize defensively instead of crashing. + decoded.append(str(part)) return " ".join(decoded) @@ -362,7 +366,13 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]: if status != "OK": continue - raw_email = msg_data[0][1] + raw_email = None + for part in msg_data or []: + if isinstance(part, tuple) and len(part) >= 2 and isinstance(part[1], (bytes, bytearray)): + raw_email = bytes(part[1]) + break + if not raw_email: + continue msg = email_lib.message_from_bytes(raw_email) sender_raw = msg.get("From", "") diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 718f01e9954d..70512348ade3 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -56,6 +56,7 @@ import mimetypes import os import re +import subprocess import threading import time import uuid @@ -2700,6 +2701,7 @@ async def _process_inbound_message( reply_to_message_id = ( getattr(message, "parent_id", None) or getattr(message, "upper_message_id", None) + or getattr(message, "root_id", None) or None ) reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None @@ -3886,10 +3888,18 @@ async def _send_uploaded_file_message( metadata=metadata, ) else: + if resolved_message_type == "audio": + duration_ms = _get_audio_duration_ms(file_path) + payload_obj = {"file_key": file_key} + if duration_ms > 0: + payload_obj["duration"] = duration_ms + payload = json.dumps(payload_obj, ensure_ascii=False) + else: + payload = json.dumps({"file_key": file_key}, ensure_ascii=False) message_response = await self._feishu_send_with_retry( chat_id=chat_id, msg_type=resolved_message_type, - payload=json.dumps({"file_key": file_key}, ensure_ascii=False), + payload=payload, reply_to=reply_to, metadata=metadata, ) @@ -4454,6 +4464,29 @@ def _poll_registration( _qrcode_mod = None # type: ignore[assignment] +def _get_audio_duration_ms(file_path: str) -> int: + """Extract audio duration in milliseconds using ffprobe.""" + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + file_path, + ], + capture_output=True, + text=True, + timeout=10, + ) + return int(float(result.stdout.strip()) * 1000) + except Exception: + return 0 + + def _render_qr(url: str) -> bool: """Try to render a QR code in the terminal. Returns True if successful.""" if _qrcode_mod is None: diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 932846458412..40dea9ddc88a 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -387,10 +387,16 @@ async def _open_ws(self, gateway_url: str) -> None: """Open a WebSocket connection to the QQ Bot gateway.""" # Only clean up WebSocket resources — keep _http_client alive for REST API calls. if self._ws and not self._ws.closed: - await self._ws.close() + try: + await asyncio.wait_for(self._ws.close(), timeout=3) + except Exception: + logger.debug("[%s] Timed out while closing stale QQ websocket", self._log_tag) self._ws = None if self._session and not self._session.closed: - await self._session.close() + try: + await asyncio.wait_for(self._session.close(), timeout=3) + except Exception: + logger.debug("[%s] Timed out while closing stale QQ websocket session", self._log_tag) self._session = None self._session = aiohttp.ClientSession() diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index b099adc50e05..42fc08ba8c03 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -203,11 +203,16 @@ async def discover_fallback_ips() -> list[str]: if isinstance(r, list): doh_ips.extend(r) - # Deduplicate preserving order, exclude system-DNS IPs + # Deduplicate preserving order. Do NOT exclude system-DNS IPs — when + # DoH and system DNS agree on an IP it is a confirmation of validity, + # not a reason to discard it. TelegramFallbackTransport tries the + # primary path (system DNS) first and falls through to explicit IPs + # only on connect failure, so including the same IP in both lanes lets + # a transient primary failure recover via the explicit IP route. seen: set[str] = set() candidates: list[str] = [] for ip in doh_ips: - if ip not in seen and ip not in system_ips: + if ip not in seen: seen.add(ip) candidates.append(ip) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 958e71da176b..175c1d545e6b 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1982,7 +1982,16 @@ async def send_weixin_direct( live_adapter = _LIVE_ADAPTERS.get(resolved_token) send_session = getattr(live_adapter, '_send_session', None) - if live_adapter is not None and send_session is not None and not send_session.closed: + current_loop = asyncio.get_running_loop() + live_loop = getattr(live_adapter, '_loop', None) + can_reuse_live_adapter = ( + live_adapter is not None + and send_session is not None + and not send_session.closed + and (live_loop is None or live_loop is current_loop) + ) + + if can_reuse_live_adapter: last_result: Optional[SendResult] = None cleaned = live_adapter.format_message(message) if cleaned: diff --git a/gateway/run.py b/gateway/run.py index ac8f763b7fc7..2c3cdbaebe8f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14,6 +14,7 @@ """ import asyncio +import concurrent.futures import dataclasses import json import logging @@ -387,6 +388,7 @@ def _try_resolve_fallback_provider() -> dict | None: "command": runtime.get("command"), "args": list(runtime.get("args") or []), "credential_pool": runtime.get("credential_pool"), + "model": entry.get("model"), } except Exception as fb_exc: logger.debug("Fallback entry %s failed: %s", entry.get("provider"), fb_exc) @@ -537,6 +539,44 @@ def _resolve_gateway_model(config: dict | None = None) -> str: return "" +def _compression_history_to_agent_messages( + history: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Convert stored gateway transcript rows into compression-safe messages. + + Manual and hygiene compression need the same rich history shape as the + normal agent run path. Dropping assistant tool_calls or tool results before + summarization removes the evidence the compaction summary is supposed to + preserve. + """ + messages: list[dict[str, Any]] = [] + for msg in history: + role = msg.get("role") + if not role or role in ("system", "session_meta"): + continue + + has_tool_calls = "tool_calls" in msg + has_tool_call_id = "tool_call_id" in msg + is_tool_message = role == "tool" + if has_tool_calls or has_tool_call_id or is_tool_message: + messages.append({k: v for k, v in msg.items() if k != "timestamp"}) + continue + + if role not in ("user", "assistant"): + continue + content = msg.get("content") + if not content: + continue + entry = {"role": role, "content": content} + if role == "assistant": + for key in ("reasoning", "reasoning_details", "codex_reasoning_items"): + value = msg.get(key) + if value: + entry[key] = value + messages.append(entry) + return messages + + def _resolve_hermes_bin() -> Optional[list[str]]: """Resolve the Hermes update command as argv parts. @@ -1080,6 +1120,10 @@ def _resolve_session_agent_runtime( ) runtime_kwargs = _resolve_runtime_agent_kwargs() + # If fallback provider included an explicit model, apply it + runtime_model = runtime_kwargs.pop("model", None) + if runtime_model: + model = runtime_model if override and resolved_session_key: model, runtime_kwargs = self._apply_session_model_override( resolved_session_key, model, runtime_kwargs @@ -3991,6 +4035,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "verbose": return await self._handle_verbose_command(event) + if canonical == "busy": + return await self._handle_busy_command(event) + + if canonical == "footer": + return await self._handle_footer_command(event) if canonical == "yolo": return await self._handle_yolo_command(event) @@ -4374,8 +4423,11 @@ async def _prepare_inbound_message_text( _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) _msg_runtime = _resolve_runtime_agent_kwargs() + # If fallback provider included an explicit model, use it + _msg_fallback_model = _msg_runtime.pop("model", None) + _msg_effective_model = _msg_fallback_model or self._model _msg_ctx_len = get_model_context_length( - self._model, + _msg_effective_model, base_url=self._base_url or _msg_runtime.get("base_url") or "", api_key=_msg_runtime.get("api_key") or "", ) @@ -4738,12 +4790,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g user_config=_hyg_data if isinstance(_hyg_data, dict) else None, ) if _hyg_runtime.get("api_key"): - _hyg_msgs = [ - {"role": m.get("role"), "content": m.get("content")} - for m in history - if m.get("role") in ("user", "assistant") - and m.get("content") - ] + _hyg_msgs = _compression_history_to_agent_messages(history) if len(_hyg_msgs) >= 4: _hyg_agent = AIAgent( @@ -4759,13 +4806,17 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _hyg_agent._print_fn = lambda *a, **kw: None loop = asyncio.get_running_loop() - _compressed, _ = await loop.run_in_executor( - None, - lambda: _hyg_agent._compress_context( - _hyg_msgs, "", - approx_tokens=_approx_tokens, - ), - ) + with concurrent.futures.ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="hermes-compress", + ) as pool: + _compressed, _ = await loop.run_in_executor( + pool, + lambda: _hyg_agent._compress_context( + _hyg_msgs, "", + approx_tokens=_approx_tokens, + ), + ) # _compress_context ends the old session and creates # a new session_id. Write compressed messages into @@ -4990,7 +5041,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if _is_ctx_fail: response = ( "⚠️ Session too large for the model's context window.\n" - "Use /compact to compress the conversation, or " + "Use /compress to compress the conversation, or " "/reset to start fresh." ) else: @@ -5247,7 +5298,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if _hist_len > 50: return ( "⚠️ Session too large for the model's context window.\n" - "Use /compact to compress the conversation, or " + "Use /compress to compress the conversation, or " "/reset to start fresh." ) elif status_code == 400: @@ -5890,7 +5941,19 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: current_model = model_cfg.get("default", "") current_provider = model_cfg.get("provider", current_provider) current_base_url = model_cfg.get("base_url", "") + current_api_key = str(model_cfg.get("api_key", "") or "") user_provs = cfg.get("providers") + # Gateway /model requires the current provider API key to verify + # model listings on custom endpoints. Resolve from provider config + # (key_env -> api_key fallback) when model.api_key is not set. + if not current_api_key and isinstance(user_provs, dict): + _pentry = user_provs.get(current_provider) + if isinstance(_pentry, dict): + _key_env = str(_pentry.get("key_env", "") or "").strip() + if _key_env: + current_api_key = str(os.getenv(_key_env, "") or "") + if not current_api_key: + current_api_key = str(_pentry.get("api_key", "") or "") try: from hermes_cli.config import get_compatible_custom_providers custom_provs = get_compatible_custom_providers(cfg) @@ -6366,6 +6429,19 @@ def _get_guild_id(event: MessageEvent) -> Optional[int]: return raw.guild.id return None + async def _handle_busy_command(self, event: MessageEvent) -> str: + """Handle /busy [queue|steer|interrupt|status] command for gateway sessions.""" + args = (event.get_command_args() or "").strip().lower() + if args in ("", "status"): + return f"Busy mode: {self._busy_input_mode}" + + if args not in ("queue", "steer", "interrupt"): + return "Usage: /busy [queue|steer|interrupt|status]" + + self._busy_input_mode = args + os.environ["HERMES_GATEWAY_BUSY_INPUT_MODE"] = args + return f"Busy mode set to {args}." + async def _handle_voice_command(self, event: MessageEvent) -> str: """Handle /voice [on|off|tts|channel|leave|status] command.""" args = event.get_command_args().strip().lower() @@ -7292,11 +7368,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: if not runtime_kwargs.get("api_key"): return "No provider configured -- cannot compress." - msgs = [ - {"role": m.get("role"), "content": m.get("content")} - for m in history - if m.get("role") in ("user", "assistant") and m.get("content") - ] + msgs = _compression_history_to_agent_messages(history) original_count = len(msgs) approx_tokens = estimate_messages_tokens_rough(msgs) @@ -7317,10 +7389,19 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: return "Nothing to compress yet (the transcript is still all protected context)." loop = asyncio.get_running_loop() - compressed, _ = await loop.run_in_executor( - None, - lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) - ) + with concurrent.futures.ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="hermes-compress", + ) as pool: + compressed, _ = await loop.run_in_executor( + pool, + lambda: tmp_agent._compress_context( + msgs, + "", + approx_tokens=approx_tokens, + focus_topic=focus_topic, + ), + ) # _compress_context already calls end_session() on the old session # (preserving its full transcript in SQLite) and creates a new @@ -10558,23 +10639,39 @@ def _approval_notify_sync(approval_data: dict) -> None: # append any that aren't already present in the final response, so the # adapter's extract_media() can find and deliver the files exactly once. # + # Only consider tool results from tools that are known to produce media (allowlist). # Uses path-based deduplication against _history_media_paths (collected # before run_conversation) instead of index slicing. This is safe even # when context compression shrinks the message list. (Fixes #160) if "MEDIA:" not in final_response: media_tags = [] has_voice_directive = False + # Build a map from tool_call_id to tool name for assistant's tool calls + tool_call_id_to_name = {} + for msg in result.get("messages", []): + if msg.get("role") == "assistant": + for tool_call in msg.get("tool_calls", []): + tc_id = tool_call.get("id") + if tc_id: + tool_name = tool_call.get("function", {}).get("name") + if tool_name: + tool_call_id_to_name[tc_id] = tool_name + # Allowlist of tools that can produce media attachments + _ALLOWED_MEDIA_TOOLS = {"text_to_speech_tool"} for msg in result.get("messages", []): if msg.get("role") in ("tool", "function"): - content = msg.get("content", "") - if "MEDIA:" in content: - for match in re.finditer(r'MEDIA:(\S+)', content): - path = match.group(1).strip().rstrip('",}') - if path and path not in _history_media_paths: - media_tags.append(f"MEDIA:{path}") + tool_call_id = msg.get("tool_call_id") + # Only process if the tool is in the allowlist + if tool_call_id and tool_call_id_to_name.get(tool_call_id) in _ALLOWED_MEDIA_TOOLS: + content = msg.get("content", "") + if "MEDIA:" in content: + for match in re.finditer(r'MEDIA:(\S+)', content): + path = match.group(1).strip().rstrip('\",}') + if path and path not in _history_media_paths: + media_tags.append(f"MEDIA:{path}") if "[[audio_as_voice]]" in content: has_voice_directive = True - + if media_tags: seen = set() unique_tags = [] @@ -11105,11 +11202,19 @@ async def _notify_long_running(): "Queued follow-up for session %s: final stream delivery not confirmed; sending first response before continuing.", session_key or "?", ) + # Keep text and media delivery behavior aligned with normal + # adapter flow: send cleaned text, then deliver MEDIA/local files. + _, first_response_text = adapter.extract_media(first_response) await adapter.send( source.chat_id, - first_response, + first_response_text, metadata=_status_thread_metadata, ) + await self._deliver_media_from_response( + first_response, + event, + adapter, + ) except Exception as e: logger.warning("Failed to send first response before queued message: %s", e) elif first_response: diff --git a/gateway/status.py b/gateway/status.py index 7f7df182f57e..e7e9595f70c1 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -14,6 +14,7 @@ import hashlib import json import os +import tempfile import signal import subprocess import sys @@ -59,7 +60,13 @@ def _get_lock_dir() -> Path: """Return the machine-local directory for token-scoped gateway locks.""" override = os.getenv("HERMES_GATEWAY_LOCK_DIR") if override: - return Path(override) + state_home = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local" / "state")) + try: + state_home.mkdir(parents=True, exist_ok=True) + except PermissionError: + # Fall back to a temporary directory if we cannot write to the default location + state_home = Path(tempfile.gettempdir()) / "hermes_state" + state_home.mkdir(parents=True, exist_ok=True) state_home = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local" / "state")) return state_home / "hermes" / _LOCKS_DIRNAME @@ -104,7 +111,19 @@ def _get_scope_lock_path(scope: str, identity: str) -> Path: def _get_process_start_time(pid: int) -> Optional[int]: - """Return the kernel start time for a process when available.""" + """Return a stable process start marker when available. + + Prefer psutil for cross-platform support (Linux/macOS/Windows). Fall back + to /proc on Linux-only environments where psutil is unavailable. + """ + try: + import psutil # type: ignore + # Convert to integer milliseconds so comparisons are stable across + # JSON round-trips and platforms. + return int(psutil.Process(pid).create_time() * 1000) + except Exception: + pass + stat_path = Path(f"/proc/{pid}/stat") try: # Field 22 in /proc//stat is process start time (clock ticks). @@ -120,6 +139,14 @@ def get_process_start_time(pid: int) -> Optional[int]: def _read_process_cmdline(pid: int) -> Optional[str]: """Return the process command line as a space-separated string.""" + try: + import psutil # type: ignore + cmd_parts = psutil.Process(pid).cmdline() + if cmd_parts: + return " ".join(str(part) for part in cmd_parts if part) + except Exception: + pass + cmdline_path = Path(f"/proc/{pid}/cmdline") try: raw = cmdline_path.read_bytes() @@ -512,6 +539,14 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, and current_start != existing.get("start_time") ): stale = True + # Cross-platform stale lock guard: if PID is alive but does + # not look like a Hermes gateway process, treat lock as stale. + if ( + not stale + and existing.get("kind") == _GATEWAY_KIND + and not _looks_like_gateway_process(existing_pid) + ): + stale = True # Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped # processes still respond to os.kill(pid, 0) but are not # actually running. Treat them as stale so --replace works. diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index fb6a79d1ff4d..cd61f23c943b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -186,7 +186,7 @@ class ProviderConfig: id="zai", name="Z.AI / GLM", auth_type="api_key", - inference_base_url="https://api.z.ai/api/paas/v4", + inference_base_url="https://api.z.ai/api/anthropic", api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), base_url_env_var="GLM_BASE_URL", ), @@ -513,7 +513,7 @@ def _resolve_api_key_provider_secret( ZAI_ENDPOINTS = [ # (id, base_url, probe_models, label) ("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"), - ("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"), + ("cn", "https://open.bigmodel.cn/api/anthropic/v1", ["glm-5"], "China"), ("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"), ("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"), ] @@ -3376,7 +3376,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: env_url = "" if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip() + env_url = (get_env_value(pconfig.base_url_env_var) or "").strip() if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) @@ -3473,7 +3473,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: env_url = "" if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip() + env_url = (get_env_value(pconfig.base_url_env_var) or "").strip() if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f001cf726a83..9ae0f08bdeed 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -128,7 +128,7 @@ class CommandDef: CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", - cli_only=True, args_hint="[queue|steer|interrupt|status]", + args_hint="[queue|steer|interrupt|status]", subcommands=("queue", "steer", "interrupt", "status")), # Tools & Skills diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bb11a5dff5e3..169df8c58555 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -30,34 +30,67 @@ _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} +# (path, mtime_ns, size) -> cached expanded config dict. +# load_config() returns a deepcopy of the cached value when the file +# hasn't changed since the last load, skipping yaml.safe_load + +# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call). +# save_config() + migrate_config() write via atomic_yaml_write which +# produces a fresh inode, so stat() sees a new mtime_ns and the next +# load repopulates automatically — no explicit invalidation hook. +_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} +# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as +# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want +# the user's on-disk values without defaults merged in. +_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # Env var names written to .env that aren't in OPTIONAL_ENV_VARS # (managed by setup/provider flows directly). _EXTRA_ENV_KEYS = frozenset({ "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", - "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL", + "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME", + "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME", + "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME", "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", + "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", + "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME", "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", + "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", + "FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME", + "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME", "WECOM_BOT_ID", "WECOM_SECRET", "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", + "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME", "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", + "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME", "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat) "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", - "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", + "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", - "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", + "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD", "MATRIX_RECOVERY_KEY", + # Langfuse observability plugin — optional tuning keys + standard SDK vars. + # Activation is via plugins.enabled (opt-in through `hermes plugins enable + # observability/langfuse` or `hermes tools → Langfuse`); credentials gate + # the plugin at runtime. + "HERMES_LANGFUSE_ENV", + "HERMES_LANGFUSE_RELEASE", + "HERMES_LANGFUSE_SAMPLE_RATE", + "HERMES_LANGFUSE_MAX_CHARS", + "HERMES_LANGFUSE_DEBUG", + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_BASE_URL", }) import yaml @@ -206,6 +239,7 @@ def get_container_exec_info() -> Optional[dict]: # Re-export from hermes_constants — canonical definition lives there. from hermes_constants import get_hermes_home # noqa: F811,E402 +from utils import atomic_replace def get_config_path() -> Path: """Get the main config file path.""" @@ -389,6 +423,20 @@ def _ensure_hermes_home_managed(home: Path): # (60+ tool iterations with tiny output) before users assume the # bot is dead and /restart. "gateway_notify_interval": 180, + # Freshness window for the gateway auto-continue note (seconds). + # After a gateway crash/restart/SIGTERM mid-run, the next user + # message gets a "[System note: your previous turn was + # interrupted — process the unfinished tool result(s) first]" + # prepended so the model picks up where it left off. That's the + # right behaviour while the interruption is fresh, but stale + # markers (transcript last touched hours or days ago) can revive + # an unrelated old task when the user's next message starts new + # work. This window is the max age of the last persisted + # transcript row for which we still inject the continue note. + # Default 3600s comfortably covers a long turn (gateway_timeout + # default is 1800s) plus runtime slack. Set to 0 to disable the + # gate and restore pre-fix behaviour (always inject). + "gateway_auto_continue_freshness": 3600, # How user-attached images are presented to the main model on each turn. # "auto" — attach natively when the active model reports # supports_vision=True AND the user hasn't explicitly @@ -546,7 +594,7 @@ def _ensure_hermes_home_managed(home: Path): "threshold": 0.50, # compress when context usage exceeds this ratio "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed - + "hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). @@ -671,6 +719,14 @@ def _ensure_hermes_home_managed(home: Path): "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} + # Gateway runtime-metadata footer appended to the FINAL message of a turn + # (disabled by default to keep replies minimal). When enabled, renders + # e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under + # display.platforms..runtime_footer. + "runtime_footer": { + "enabled": False, + "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide + }, }, # Web dashboard settings @@ -888,6 +944,7 @@ def _ensure_hermes_home_managed(home: Path): # Telegram platform settings (gateway mode) "telegram": { + "reactions": False, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) }, @@ -942,7 +999,7 @@ def _ensure_hermes_home_managed(home: Path): # Pre-exec security scanning via tirith "security": { "allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs) - "redact_secrets": True, + "redact_secrets": False, "tirith_enabled": True, "tirith_path": "tirith", "tirith_timeout": 5, @@ -1166,6 +1223,22 @@ def _ensure_hermes_home_managed(home: Path): "category": "provider", "advanced": True, }, + "LM_API_KEY": { + "description": "LM Studio bearer token for auth-enabled local servers", + "prompt": "LM Studio API key / bearer token", + "url": None, + "password": True, + "category": "provider", + "advanced": True, + }, + "LM_BASE_URL": { + "description": "LM Studio base URL override", + "prompt": "LM Studio base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "GLM_API_KEY": { "description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)", "prompt": "Z.AI / GLM API key", @@ -1692,6 +1765,30 @@ def _ensure_hermes_home_managed(home: Path): "category": "tool", }, + # ── Langfuse observability ── + "HERMES_LANGFUSE_PUBLIC_KEY": { + "description": "Langfuse project public key (pk-lf-...)", + "prompt": "Langfuse public key", + "url": "https://cloud.langfuse.com", + "password": False, + "category": "tool", + }, + "HERMES_LANGFUSE_SECRET_KEY": { + "description": "Langfuse project secret key (sk-lf-...)", + "prompt": "Langfuse secret key", + "url": "https://cloud.langfuse.com", + "password": True, + "category": "tool", + }, + "HERMES_LANGFUSE_BASE_URL": { + "description": "Langfuse server URL (default: https://cloud.langfuse.com)", + "prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)", + "url": None, + "password": False, + "category": "tool", + "advanced": True, + }, + # ── Messaging platforms ── "TELEGRAM_BOT_TOKEN": { "description": "Telegram bot token from @BotFather", @@ -1839,6 +1936,14 @@ def _ensure_hermes_home_managed(home: Path): "category": "messaging", "advanced": True, }, + "MATRIX_DM_AUTO_THREAD": { + "description": "Auto-create threads for DM messages in Matrix (default: false)", + "prompt": "Auto-create threads in DMs (true/false)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "MATRIX_DEVICE_ID": { "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)", "prompt": "Matrix device ID (stable across restarts)", @@ -2180,14 +2285,21 @@ def _normalize_custom_provider_entry( "baseUrl": "base_url", "apiMode": "api_mode", "keyEnv": "key_env", + "apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant "defaultModel": "default_model", "contextLength": "context_length", "rateLimitDelay": "rate_limit_delay", } + # api_key_env is a documented snake_case alias for key_env (see + # website/docs/guides/azure-foundry.md). Normalize it up front so the + # rest of the normalizer treats it as the canonical field. + if "api_key_env" in entry and "key_env" not in entry: + entry["key_env"] = entry["api_key_env"] _KNOWN_KEYS = { - "name", "api", "url", "base_url", "api_key", "key_env", + "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env", "api_mode", "transport", "model", "default_model", "models", "context_length", "rate_limit_delay", + "request_timeout_seconds", "stale_timeout_seconds", } for camel, snake in _CAMEL_ALIASES.items(): if camel in entry and snake not in entry: @@ -2439,6 +2551,9 @@ def check_config_version() -> Tuple[int, int]: _VALID_CUSTOM_PROVIDER_FIELDS = { "name", "base_url", "api_key", "api_mode", "model", "models", "context_length", "rate_limit_delay", + # key_env is read at runtime by runtime_provider.py and auxiliary_client.py + # — include it here so the set accurately describes the supported schema. + "key_env", } # Fields that look like they should be inside custom_providers, not at root @@ -2515,10 +2630,32 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List[" "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1", )) - # ── fallback_model must be a top-level dict with provider + model ──── + # ── fallback_model: single dict OR list of dicts (chain) ───────────── fb = config.get("fallback_model") if fb is not None: - if not isinstance(fb, dict): + if isinstance(fb, list): + # Chain fallback — validate each entry + for i, entry in enumerate(fb): + if not isinstance(entry, dict): + issues.append(ConfigIssue( + "error", + f"fallback_model[{i}] should be a dict, got {type(entry).__name__}", + "Each entry needs provider + model", + )) + else: + if not entry.get("provider"): + issues.append(ConfigIssue( + "warning", + f"fallback_model[{i}] is missing 'provider' field", + "Add: provider: openrouter (or another provider)", + )) + if not entry.get("model"): + issues.append(ConfigIssue( + "warning", + f"fallback_model[{i}] is missing 'model' field", + "Add: model: ", + )) + elif not isinstance(fb, dict): issues.append(ConfigIssue( "error", f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}", @@ -2986,6 +3123,43 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A "Use `hermes plugins enable ` to activate." ) + # ── Version 21 → 22: preserve terminal.cwd and auxiliary models during migrations + # Some migration blocks call save_config() which internally runs + # _normalize_root_model_keys that can inadvertently reset terminal.cwd to + # default (".") and drop auxiliary.model when the normalized form differs + # from what was migrated. This step re-reads the raw file and restores + # both fields if they were lost. See issues #17182. + if current_ver < 22: + raw = read_raw_config() + migrated_cfg = load_config() + + # Restore terminal.cwd if it was set in raw but missing in migrated + raw_terminal = raw.get("terminal", {}) + if isinstance(raw_terminal, dict) and "cwd" in raw_terminal: + migrated_terminal = migrated_cfg.get("terminal", {}) + if not isinstance(migrated_terminal, dict): + migrated_terminal = {} + if not migrated_terminal.get("cwd") or migrated_terminal.get("cwd") in (".", "auto", "cwd", ""): + migrated_terminal["cwd"] = raw_terminal["cwd"] + migrated_cfg["terminal"] = migrated_terminal + save_config(migrated_cfg) + if not quiet: + print(f" ✓ Preserved terminal.cwd={raw_terminal['cwd']} across migration") + + # Restore auxiliary.model / auxiliary.provider if set in raw but dropped + raw_aux = raw.get("auxiliary", {}) + migrated_aux = migrated_cfg.get("auxiliary", {}) + if isinstance(raw_aux, dict): + for key in ("model", "provider", "base_url"): + if key in raw_aux and raw_aux[key]: + if not migrated_aux.get(key): + migrated_aux[key] = raw_aux[key] + if migrated_aux: + migrated_cfg["auxiliary"] = migrated_aux + save_config(migrated_cfg) + if not quiet: + print(f" ✓ Preserved auxiliary config across migration") + if current_ver < latest_ver and not quiet: print(f"Config version: {current_ver} → {latest_ver}") @@ -3311,25 +3485,62 @@ def read_raw_config() -> Dict[str, Any]: be parsed. Use this for lightweight config reads where you just need a single value and don't want the overhead of ``load_config()``'s deep-merge + migration pipeline. + + Cached on the config file's (mtime_ns, size) — same strategy as + ``load_config()``. Returns a deepcopy on every call since some callers + mutate the result before passing to ``save_config()``. """ try: config_path = get_config_path() - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - return yaml.safe_load(f) or {} + st = config_path.stat() + cache_key = (st.st_mtime_ns, st.st_size) + except (FileNotFoundError, OSError): + return {} + + path_key = str(config_path) + cached = _RAW_CONFIG_CACHE.get(path_key) + if cached is not None and cached[:2] == cache_key: + return copy.deepcopy(cached[2]) + + try: + with open(config_path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} except Exception: - pass - return {} + return {} + + if not isinstance(data, dict): + data = {} + _RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data)) + return data def load_config() -> Dict[str, Any]: - """Load configuration from ~/.hermes/config.yaml.""" + """Load configuration from ~/.hermes/config.yaml. + + Cached on the config file's (mtime_ns, size). Returns a deepcopy of + the cached value when unchanged, since most call sites mutate the + result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``). + The cache is keyed on ``str(config_path)`` so profile switches + (which change ``HERMES_HOME`` and therefore ``get_config_path()``) + don't collide. + """ ensure_hermes_home() config_path = get_config_path() - + path_key = str(config_path) + + try: + st = config_path.stat() + cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size) + except FileNotFoundError: + cache_key = None + + cached = _LOAD_CONFIG_CACHE.get(path_key) + if cached is not None and cache_key is not None and cached[:2] == cache_key: + return copy.deepcopy(cached[2]) + config = copy.deepcopy(DEFAULT_CONFIG) - - if config_path.exists(): + + if cache_key is not None: try: with open(config_path, encoding="utf-8") as f: user_config = yaml.safe_load(f) or {} @@ -3347,20 +3558,26 @@ def load_config() -> Dict[str, Any]: normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) - _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(expanded) + _LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded) + if cache_key is not None: + _LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded)) + else: + _LOAD_CONFIG_CACHE.pop(path_key, None) return expanded _SECURITY_COMMENT = """ # ── Security ────────────────────────────────────────────────────────── -# API keys, tokens, and passwords are redacted from tool output by default. -# Set to false to see full values (useful for debugging auth issues). +# Secret redaction is OFF by default — tool output (terminal stdout, +# read_file results, web content) passes through unmodified. Set +# redact_secrets to true to mask strings that look like API keys, tokens, +# and passwords before they enter the model context and logs. # tirith pre-exec scanning is enabled by default when the tirith binary # is available. Configure via security.tirith_* keys or env vars # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN). # # security: -# redact_secrets: false +# redact_secrets: true # tirith_enabled: true # tirith_path: "tirith" # tirith_timeout: 5 @@ -3393,11 +3610,11 @@ def load_config() -> Dict[str, Any]: _COMMENTED_SECTIONS = """ # ── Security ────────────────────────────────────────────────────────── -# API keys, tokens, and passwords are redacted from tool output by default. -# Set to false to see full values (useful for debugging auth issues). +# Secret redaction is OFF by default. Set to true to mask strings that +# look like API keys, tokens, and passwords in tool output and logs. # # security: -# redact_secrets: false +# redact_secrets: true # ── Fallback Model ──────────────────────────────────────────────────── # Automatic provider failover when primary is unavailable. @@ -3448,7 +3665,12 @@ def save_config(config: Dict[str, Any]): if not sec or sec.get("redact_secrets") is None: parts.append(_SECURITY_COMMENT) fb = normalized.get("fallback_model", {}) - if not fb or not isinstance(fb, dict) or not (fb.get("provider") and fb.get("model")): + fb_is_valid = False + if isinstance(fb, list): + fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb) + elif isinstance(fb, dict): + fb_is_valid = bool(fb.get("provider") and fb.get("model")) + if not fb_is_valid: parts.append(_FALLBACK_COMMENT) atomic_yaml_write( @@ -3516,13 +3738,20 @@ def _sanitize_env_lines(lines: list) -> list: continue # Detect concatenated KEY=VALUE pairs on one line. - # Search for known KEY= patterns at any position in the line. + # Search for known KEY= patterns that appear at the start of the line + # or after a non-identifier character, so we don't match partial keys + # that are substrings of other registered keys (e.g. LM_API_KEY is a + # substring of GLM_API_KEY, but should not trigger a split). split_positions = [] for key_name in known_keys: needle = key_name + "=" idx = stripped.find(needle) while idx >= 0: - split_positions.append(idx) + # Only treat as a real KEY= separator if it starts at position 0 + # or follows a non-identifier character (not part of a longer key). + # e.g. "LM_API_KEY=" must not split inside "GLM_API_KEY=". + if idx == 0 or not stripped[idx - 1].isidentifier(): + split_positions.append(idx) idx = stripped.find(needle, idx + len(needle)) if len(split_positions) > 1: @@ -3574,7 +3803,7 @@ def sanitize_env_file() -> int: f.writelines(sanitized) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + atomic_replace(tmp_path, env_path) except BaseException: try: os.unlink(tmp_path) @@ -3637,7 +3866,7 @@ def save_env_value(key: str, value: str): value = _check_non_ascii_credential(key, value) ensure_hermes_home() env_path = get_env_path() - + # On Windows, open() defaults to the system locale (cp1252) which can # cause OSError errno 22 on UTF-8 .env files. read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} @@ -3649,7 +3878,7 @@ def save_env_value(key: str, value: str): lines = f.readlines() # Sanitize on every read: split concatenated keys, drop stale placeholders lines = _sanitize_env_lines(lines) - + # Find and update or append found = False for i, line in enumerate(lines): @@ -3657,7 +3886,7 @@ def save_env_value(key: str, value: str): lines[i] = f"{key}={value}\n" found = True break - + if not found: # Ensure there's a newline at the end of the file before appending if lines and not lines[-1].endswith("\n"): @@ -3677,7 +3906,7 @@ def save_env_value(key: str, value: str): f.writelines(lines) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + atomic_replace(tmp_path, env_path) # Restore original permissions before _secure_file may tighten them. if original_mode is not None: try: @@ -3733,7 +3962,7 @@ def remove_env_value(key: str) -> bool: f.writelines(new_lines) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, env_path) + atomic_replace(tmp_path, env_path) if original_mode is not None: try: os.chmod(env_path, original_mode) @@ -3805,14 +4034,16 @@ def reload_env() -> int: def get_env_value(key: str) -> Optional[str]: - """Get a value from ~/.hermes/.env or environment.""" - # Check environment first - if key in os.environ: - return os.environ[key] - - # Then check .env file + """Get a value from ~/.hermes/.env or environment. + + Hermes treats ~/.hermes/.env as the authoritative user-managed source. + Process-level environment variables are a fallback for ephemeral overrides. + """ env_vars = load_env() - return env_vars.get(key) + if key in env_vars: + return env_vars[key] + + return os.environ.get(key) # ============================================================================= diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 348e4efe83c8..64ea723159ba 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) # OAuth device code flow constants (same client ID as opencode/Copilot CLI) -COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz" +COPILOT_OAUTH_CLIENT_ID="Iv1.b507a08c87ecfe98" # Token type prefixes _CLASSIC_PAT_PREFIX = "ghp_" _SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index dc346ac9b231..a10ee7badaf1 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -253,7 +253,12 @@ def run_doctor(args): check_ok(f"{_DHH}/.env file exists") # Check for common issues - content = env_path.read_text() + try: + content = env_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + # Windows locales (e.g., GBK) can choke on UTF-8 .env files when + # default-decoding without an explicit encoding. + content = env_path.read_text(encoding="utf-8", errors="replace") if _has_provider_env_config(content): check_ok("API key or custom endpoint configured") else: diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 009f3de273b2..1cc322ae8fe6 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -143,13 +143,14 @@ def load_hermes_dotenv( hermes_home: str | os.PathLike | None = None, project_env: str | os.PathLike | None = None, ) -> list[Path]: - """Load Hermes environment files with user config taking precedence. + """Load Hermes environment files with runtime environment taking precedence. Behavior: - - `~/.hermes/.env` overrides stale shell-exported values when present. + - Existing process environment variables win over values from `.env` files. + - `~/.hermes/.env` is loaded first as the primary user config source. - project `.env` acts as a dev fallback and only fills missing values when the user env exists. - - if no user env exists, the project `.env` also overrides stale shell vars. + - if no user env exists, the project `.env` may still override stale shell vars. """ loaded: list[Path] = [] @@ -164,7 +165,7 @@ def load_hermes_dotenv( _sanitize_env_file_if_needed(project_env_path) if user_env.exists(): - _load_dotenv_with_fallback(user_env, override=True) + _load_dotenv_with_fallback(user_env, override=False) loaded.append(user_env) if project_env_path and project_env_path.exists(): diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index aede480bfed7..be7d7957772f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -275,12 +275,16 @@ def _matches_current_profile(command: str) -> bool: try: if is_windows(): - result = subprocess.run( - ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], - capture_output=True, - text=True, - timeout=10, - ) + try: + result = subprocess.run( + ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + except OSError: + return [] if result.returncode != 0: return [] current_cmd = "" @@ -678,11 +682,13 @@ def is_linux() -> bool: def _wsl_systemd_operational() -> bool: - """Check if systemd is actually running as PID 1 on WSL. + """Check whether systemd is usable on WSL. - WSL2 with ``systemd=true`` in wsl.conf has working systemd. - WSL2 without it (or WSL1) does not — systemctl commands fail. + Prefer user-scope first (common when user services are active), then + fall back to system scope for distros where only that probe succeeds. """ + if _systemd_operational(system=False): + return True return _systemd_operational(system=True) @@ -2953,7 +2959,7 @@ def _setup_sms(): def _setup_dingtalk(): """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.setup import ( - prompt_choice, prompt_yes_no, print_info, print_success, print_warning, + prompt_choice, prompt_yes_no, print_success, print_warning, ) dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk") @@ -3504,7 +3510,6 @@ def _setup_qqbot(): method_idx = prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0) credentials = None - used_qr = False if method_idx == 0: # ── QR scan-to-configure ── @@ -3515,8 +3520,6 @@ def _setup_qqbot(): print() print_warning(" QQ Bot setup cancelled.") return - if credentials: - used_qr = True if not credentials: print_info(" QR setup did not complete. Continuing with manual input.") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 375561ad6def..3ed349589b80 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3860,7 +3860,29 @@ def _model_flow_kimi(config, current_model=""): print("API key saved.") print() else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + print(f" {pconfig.name} API key: {existing_key[:8]}... (stored, not validated)") + from hermes_cli.setup import prompt_choice + action = prompt_choice( + "What would you like to do?", + [ + "Keep current key", + "Replace with a new key", + "Remove key", + ], + default=0, + ) + if action == 1: + new_key = getpass.getpass(f"{key_env}: ").strip() + if new_key: + save_env_value(key_env, new_key) + existing_key = new_key + print("API key replaced.") + else: + print("Key unchanged.") + elif action == 2: + save_env_value(key_env, "") + existing_key = "" + print("API key removed.") print() # Step 2: Auto-detect endpoint from key prefix @@ -3979,7 +4001,29 @@ def _model_flow_stepfun(config, current_model=""): print("API key saved.") print() else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + print(f" {pconfig.name} API key: {existing_key[:8]}... (stored, not validated)") + from hermes_cli.setup import prompt_choice + action = prompt_choice( + "What would you like to do?", + [ + "Keep current key", + "Replace with a new key", + "Remove key", + ], + default=0, + ) + if action == 1: + new_key = getpass.getpass(f"{key_env}: ").strip() + if new_key: + save_env_value(key_env, new_key) + existing_key = new_key + print("API key replaced.") + else: + print("Key unchanged.") + elif action == 2: + save_env_value(key_env, "") + existing_key = "" + print("API key removed.") print() current_base = "" @@ -4373,7 +4417,29 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print("API key saved.") print() else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + print(f" {pconfig.name} API key: {existing_key[:8]}... (stored, not validated)") + from hermes_cli.setup import prompt_choice + action = prompt_choice( + "What would you like to do?", + [ + "Keep current key", + "Replace with a new key", + "Remove key", + ], + default=0, + ) + if action == 1: + new_key = getpass.getpass(f"{key_env}: ").strip() + if new_key: + save_env_value(key_env, new_key) + existing_key = new_key + print("API key replaced.") + else: + print("Key unchanged.") + elif action == 2: + save_env_value(key_env, "") + existing_key = "" + print("API key removed.") print() # Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash) diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 88186b8ec662..fef70b12e8b5 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -393,7 +393,7 @@ def cmd_status(args) -> None: provider_name = mem_config.get("provider", "") print(f"\nMemory status\n" + "─" * 40) - print(f" Built-in: always active") + print(f" Framework: active") print(f" Provider: {provider_name or '(none — built-in only)'}") if provider_name: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index d9e1b04183a0..f5882dad6592 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1389,7 +1389,12 @@ def list_authenticated_providers( current_base_url and api_url == current_base_url.strip().rstrip("/") ): - slug = current_provider or custom_provider_slug(display_name) + # Check if current_provider is 'custom' (invalid bare slug) + # If so, don't use it — generate a proper slug from display name + if current_provider and current_provider.lower() == "custom": + slug = custom_provider_slug(display_name) + else: + slug = current_provider or custom_provider_slug(display_name) else: slug = custom_provider_slug(display_name) groups[group_key] = { diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 28ca6d7deae6..288e005ab828 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1872,7 +1872,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return live except Exception: pass - if normalized == "custom": + if normalized == "custom" or normalized.startswith("custom:"): base_url = _get_custom_base_url() if base_url: # Try common API key env vars for custom endpoints @@ -2651,6 +2651,30 @@ def validate_requested_model( normalized = normalize_provider(provider) if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url: normalized = "custom" + + # Custom providers may define an explicit models map/list in config. + # When present, trust that local declaration before probing /v1/models, + # because many custom endpoints return incomplete listings. + if normalized == "custom": + try: + cfg = load_config() or {} + custom = (cfg.get("custom_providers") or {}).get(provider) or {} + declared_models = custom.get("models") or {} + if isinstance(declared_models, dict): + declared = [str(k) for k in declared_models.keys() if k] + elif isinstance(declared_models, list): + declared = [str(m) for m in declared_models if m] + else: + declared = [] + if declared and requested in set(declared): + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + except Exception: + pass requested_for_lookup = requested if normalized == "copilot": requested_for_lookup = normalize_copilot_model_id( @@ -2674,7 +2698,41 @@ def validate_requested_model( "message": "Model names cannot contain spaces.", } - if normalized == "custom": + if normalized == "lmstudio": + from hermes_cli.auth import AuthError + # Use probe_lmstudio_models so we can distinguish None (unreachable + # / malformed response) from [] (reachable, but no chat-capable models + # are loaded). fetch_lmstudio_models collapses both to []. + try: + models = probe_lmstudio_models(api_key=api_key, base_url=base_url) + except AuthError as exc: + return { + "accepted": False, "persist": False, "recognized": False, + "message": ( + f"{exc} Set `LM_API_KEY` (or update it) to match the server's bearer token." + ), + } + if models is None: + return { + "accepted": False, "persist": False, "recognized": False, + "message": f"Could not reach LM Studio's `/api/v1/models` to validate `{requested}`.", + } + if not models: + return { + "accepted": False, "persist": False, "recognized": False, + "message": ( + f"LM Studio is reachable but no chat-capable models are loaded. " + f"Load `{requested}` in LM Studio (Developer tab → Load Model) and try again." + ), + } + if requested_for_lookup in set(models): + return {"accepted": True, "persist": True, "recognized": True, "message": None} + return { + "accepted": False, "persist": False, "recognized": False, + "message": f"Model `{requested}` was not found in LM Studio's model listing.", + } + + if normalized == "custom" or normalized.startswith("custom:"): # Try probing with correct auth for the api_mode. if api_mode == "anthropic_messages": probe = probe_api_models(api_key, base_url, api_mode=api_mode) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index bf6de16dffdd..027682633e05 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -101,7 +101,7 @@ # Names that cannot be used as profile aliases _RESERVED_NAMES = frozenset({ - "hermes", "default", "test", "tmp", "root", "sudo", + "hermes", "default", "main", "test", "tmp", "root", "sudo", }) # Hermes subcommands that cannot be used as profile names/aliases @@ -158,6 +158,7 @@ def _get_wrapper_dir() -> Path: def validate_profile_name(name: str) -> None: """Raise ``ValueError`` if *name* is not a valid profile identifier.""" + name = normalize_profile_name(name) if name == "default": return # special alias for ~/.hermes if not _PROFILE_ID_RE.match(name): @@ -169,6 +170,7 @@ def validate_profile_name(name: str) -> None: def get_profile_dir(name: str) -> Path: """Resolve a profile name to its HERMES_HOME directory.""" + name = normalize_profile_name(name) if name == "default": return _get_default_hermes_home() return _get_profiles_root() / name @@ -176,11 +178,22 @@ def get_profile_dir(name: str) -> Path: def profile_exists(name: str) -> bool: """Check whether a profile directory exists.""" + name = normalize_profile_name(name) if name == "default": return True return get_profile_dir(name).is_dir() +def normalize_profile_name(name: str) -> str: + """Canonicalize profile names for case-insensitive UI inputs. + + ``default`` remains a special alias; all other names are lowercased. + """ + if not isinstance(name, str): + return name + return "default" if name.lower() == "default" else name.lower() + + # --------------------------------------------------------------------------- # Alias / wrapper script management # --------------------------------------------------------------------------- @@ -399,9 +412,9 @@ def create_profile( """ validate_profile_name(name) - if name == "default": + if name in _RESERVED_NAMES: raise ValueError( - "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." + f"Cannot create a profile named '{name}' — it is reserved." ) profile_dir = get_profile_dir(name) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1fe5acc2b659..5be849963d24 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -799,7 +799,7 @@ def _resolve_explicit_runtime( if pconfig and pconfig.auth_type == "api_key": env_url = "" if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/") + env_url = (auth_mod.get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/") base_url = explicit_base_url if not base_url: @@ -816,21 +816,24 @@ def _resolve_explicit_runtime( if not base_url: base_url = creds.get("base_url", "").rstrip("/") - api_mode = "chat_completions" - if provider == "copilot": - api_mode = _copilot_runtime_api_mode(model_cfg, api_key) - elif provider == "xai": - api_mode = "codex_responses" + api_mode = "chat_completions" + if provider == "copilot": + api_mode = _copilot_runtime_api_mode(model_cfg, api_key) + elif provider == "xai": + api_mode = "codex_responses" + elif provider in ("kimi-coding", "kimi-coding-cn"): + # Kimi Coding endpoints speak anthropic_messages protocol + # Don't allow the fallback to "chat_completions" to take effect + configured_mode = _parse_api_mode(model_cfg.get("api_mode")) + if configured_mode: + api_mode = configured_mode else: - configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode: - api_mode = configured_mode + detected = _detect_api_mode_for_url(base_url) + if detected: + api_mode = detected else: - # Auto-detect from URL (Anthropic /anthropic suffix, - # api.openai.com → Responses, Kimi /coding, etc.). - detected = _detect_api_mode_for_url(base_url) - if detected: - api_mode = detected + # Kimi endpoints need anthropic_messages by default + api_mode = "anthropic_messages" return { "provider": provider, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 92d7c37cf6ff..7fa8e6e17262 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1516,7 +1516,7 @@ def setup_terminal_backend(config: dict): def _apply_default_agent_settings(config: dict): """Apply recommended defaults for all agent settings without prompting.""" config.setdefault("agent", {})["max_turns"] = 90 - save_env_value("HERMES_MAX_ITERATIONS", "90") + # Don't write to HERMES_MAX_ITERATIONS - config.yaml is the source of truth config.setdefault("display", {})["tool_progress"] = "all" @@ -1559,9 +1559,15 @@ def setup_agent_settings(config: dict): try: max_iter = int(max_iter_str) if max_iter > 0: - save_env_value("HERMES_MAX_ITERATIONS", str(max_iter)) + # Only write to config.yaml - HERMES_MAX_ITERATIONS in .env is legacy + # and should not be written by the wizard to avoid state drift. + # The config.yaml value takes precedence per CLI priority chain. config.setdefault("agent", {})["max_turns"] = max_iter config.pop("max_turns", None) + + # Clear any legacy env var to prevent drift + save_env_value("HERMES_MAX_ITERATIONS", "") + print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index d76f8a6e0604..4c7335b58e91 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -38,8 +38,7 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: return { "_metadata": { - "major_version": 1, - "minor_version": 1, + "version": "2.0.0", }, "display_information": { "name": bot_name[:35], diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 028575268187..c8c11daa06ad 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -304,8 +304,23 @@ def show_status(args): print(f" Daytona Image: {daytona_image}") sudo_password = os.getenv("SUDO_PASSWORD", "") - print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") - + passwordless_sudo = False + if not sudo_password: + try: + result = subprocess.run( + ["sudo", "-n", "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + ) + passwordless_sudo = result.returncode == 0 + except Exception: + passwordless_sudo = False + + sudo_enabled = bool(sudo_password) or passwordless_sudo + sudo_label = "enabled" if bool(sudo_password) else ("enabled (passwordless)" if passwordless_sudo else "disabled") + print(f" Sudo: {check_mark(sudo_enabled)} {sudo_label}") + # ========================================================================= # Messaging Platforms # ========================================================================= diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 13337a734223..822f9b6a39c8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -117,7 +117,7 @@ def _has_valid_session_token(request: Request) -> bool: accept the legacy Bearer path for backward compatibility with older dashboard bundles. """ - session_header = request.headers.get(_SESSION_HEADER_NAME, "") + session_header = request.headers.get(_SESSION_HEADER_NAME.lower(), "") if session_header and hmac.compare_digest( session_header.encode(), _SESSION_TOKEN.encode(), diff --git a/hermes_constants.py b/hermes_constants.py index 35dbf86ab229..3592156c366f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -11,11 +11,24 @@ def get_hermes_home() -> Path: """Return the Hermes home directory (default: ~/.hermes). - Reads HERMES_HOME env var, falls back to ~/.hermes. - This is the single source of truth — all other copies should import this. + Reads HERMES_HOME env var, falls back to ~/.hermes in classic mode. + In profile-mode subprocesses (HERMES_PROFILE/HERMES_PROFILE_NAME set), + missing HERMES_HOME is treated as a hard error to avoid silent + cross-profile reads/writes. """ val = os.environ.get("HERMES_HOME", "").strip() - return Path(val) if val else Path.home() / ".hermes" + if val: + return Path(val) + + # Guard profile-mode workers against silently falling back to ~/.hermes. + if os.environ.get("HERMES_PROFILE") or os.environ.get("HERMES_PROFILE_NAME"): + raise ValueError( + "HERMES_HOME is not set in a profile-scoped process. " + "Set HERMES_HOME to the active profile directory " + "(e.g. ~/.hermes/profiles/)." + ) + + return Path.home() / ".hermes" def get_default_hermes_root() -> Path: diff --git a/model_tools.py b/model_tools.py index 539b0e13b4a7..5d1ef699ec86 100644 --- a/model_tools.py +++ b/model_tools.py @@ -41,6 +41,39 @@ _tool_loop_lock = threading.Lock() _worker_thread_local = threading.local() # per-worker-thread persistent loops +# ============================================================================= +# Async Bridge for running-loop contexts +# ============================================================================= + +_bridge_loop = None # dedicated event loop for running-loop callers +_bridge_thread = None # thread that runs the bridge loop +_bridge_loop_ready = threading.Event() # set when the bridge loop is ready + + +def _start_bridge_loop(): + """Start the bridge loop in a dedicated thread.""" + global _bridge_loop, _bridge_thread + if _bridge_loop is not None: + return + def run_bridge(): + global _bridge_loop + _bridge_loop = asyncio.new_event_loop() + asyncio.set_event_loop(_bridge_loop) + _bridge_loop_ready.set() + _bridge_loop.run_forever() + _bridge_thread = threading.Thread(target=run_bridge, name='HermesBridgeLoop', daemon=True) + _bridge_thread.start() + # Wait for the loop to be ready + _bridge_loop_ready.wait() + +def _stop_bridge_loop(): + """Stop the bridge loop.""" + global _bridge_loop, _bridge_thread + if _bridge_loop is not None and _bridge_loop.is_running(): + _bridge_loop.call_soon_threadsafe(_bridge_loop.stop) + # Note: we don't wait for the thread to finish because it's a daemon thread. + + def _get_tool_loop(): """Return a long-lived event loop for running async tool handlers. @@ -106,19 +139,17 @@ def _run_async(coro): except RuntimeError: loop = None - if loop and loop.is_running(): - # Inside an async context (gateway, RL env) — run in a fresh thread. - import concurrent.futures - pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - future = pool.submit(asyncio.run, coro) - try: - return future.result(timeout=300) - except concurrent.futures.TimeoutError: - future.cancel() - raise - finally: - pool.shutdown(wait=False, cancel_futures=True) - + if loop and loop.is_running(): # Inside an async context (gateway, RL env) — run on the dedicated bridge loop. + _start_bridge_loop() + future = asyncio.run_coroutine_threadsafe(coro, _bridge_loop) + try: + return future.result(timeout=300) + except asyncio.TimeoutError: + future.cancel() + raise + except Exception: + future.cancel() + raise # If we're on a worker thread (e.g., parallel tool execution in # delegate_task), use a per-thread persistent loop. This avoids # contention with the main thread's shared loop while keeping cached diff --git a/run_agent.py b/run_agent.py index 42f1e6f9e5a8..cf8d93a16984 100644 --- a/run_agent.py +++ b/run_agent.py @@ -98,12 +98,17 @@ 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_context_limit_from_error, parse_available_output_tokens_from_error, save_context_length, is_local_endpoint, query_ollama_num_ctx, ) -from agent.context_compressor import ContextCompressor +from agent.context_compressor import ( + ContextCompressor, + SUMMARY_PREFIX, + _append_text_to_content, + _content_text_for_contains, +) from agent.subdirectory_hints import SubdirectoryHintTracker from agent.prompt_caching import apply_anthropic_cache_control from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE @@ -826,6 +831,7 @@ class AIAgent: "[hermes-agent: tool call arguments were corrupted in this session and " "have been dropped to keep the conversation alive. See issue #15236.]" ) + _MINIMAX_TOTAL_CONTEXT_MARGIN_TOKENS = 2048 @property def base_url(self) -> str: @@ -1387,33 +1393,69 @@ def __init__( if hasattr(_routed_client, '_default_headers') and _routed_client._default_headers: client_kwargs["default_headers"] = dict(_routed_client._default_headers) else: - # When the user explicitly chose a non-OpenRouter provider - # but no credentials were found, fail fast with a clear - # message instead of silently routing through OpenRouter. - _explicit = (self.provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): - # Look up the actual env var name from the provider - # config — some providers use non-standard names - # (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY). - _env_hint = f"{_explicit.upper()}_API_KEY" - try: - from hermes_cli.auth import PROVIDER_REGISTRY - _pcfg = PROVIDER_REGISTRY.get(_explicit) - if _pcfg and _pcfg.api_key_env_vars: - _env_hint = _pcfg.api_key_env_vars[0] - except Exception: - pass + # When the primary provider has no currently-available + # credentials (e.g. single-key pool in 429 cooldown), try + # configured fallback providers before failing init. + _fallback_chain = fallback_model if isinstance(fallback_model, list) else [] + _fallback_selected = False + for _fb in _fallback_chain: + if not isinstance(_fb, dict): + continue + _fb_provider = (_fb.get("provider") or "").strip().lower() + _fb_model = (_fb.get("model") or "").strip() + if not _fb_provider or not _fb_model: + continue + _fb_client, _fb_resolved_model = resolve_provider_client( + _fb_provider, + model=_fb_model, + raw_codex=True, + explicit_base_url=_fb.get("base_url"), + explicit_api_key=_fb.get("api_key"), + ) + if _fb_client is None: + continue + self.provider = _fb_provider + self.model = _fb_resolved_model or _fb_model + self._fallback_activated = True + client_kwargs = { + "api_key": _fb_client.api_key, + "base_url": str(_fb_client.base_url), + } + if _provider_timeout is not None: + client_kwargs["timeout"] = _provider_timeout + if hasattr(_fb_client, '_default_headers') and _fb_client._default_headers: + client_kwargs["default_headers"] = dict(_fb_client._default_headers) + _fallback_selected = True + break + + if not _fallback_selected: + # When the user explicitly chose a non-OpenRouter provider + # but no credentials were found, fail fast with a clear + # message instead of silently routing through OpenRouter. + _explicit = (self.provider or "").strip().lower() + if _explicit and _explicit not in ("auto", "openrouter", "custom"): + # Look up the actual env var name from the provider + # config — some providers use non-standard names + # (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY). + _env_hint = f"{_explicit.upper()}_API_KEY" + try: + from hermes_cli.auth import PROVIDER_REGISTRY + _pcfg = PROVIDER_REGISTRY.get(_explicit) + if _pcfg and _pcfg.api_key_env_vars: + _env_hint = _pcfg.api_key_env_vars[0] + except Exception: + pass + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_env_hint} environment " + f"variable, or switch to a different provider with `hermes model`." + ) + # No provider configured — reject with a clear message. raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_env_hint} environment " - f"variable, or switch to a different provider with `hermes model`." + "No LLM provider configured. Run `hermes model` to " + "select a provider, or run `hermes setup` for first-time " + "configuration." ) - # No provider configured — reject with a clear message. - raise RuntimeError( - "No LLM provider configured. Run `hermes model` to " - "select a provider, or run `hermes setup` for first-time " - "configuration." - ) self._client_kwargs = client_kwargs # stored for rebuilding after interrupt @@ -1601,6 +1643,7 @@ def __init__( # needed later by the startup feasibility check. Avoid exposing a # broad pseudo-public config object on the agent instance. self._aux_compression_context_length_config = None + self._last_successful_request_snapshot = None # Persistent memory (MEMORY.md + USER.md) -- loaded from disk self._memory_store = None @@ -3440,6 +3483,27 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: if isinstance(msg, dict) and msg.get("role") == "user": msg["content"] = override + def _capture_last_successful_request_snapshot( + self, + *, + model: str, + messages: List[Dict[str, Any]], + system_prompt: str, + tools: Optional[list], + api_messages: Optional[List[Dict[str, Any]]] = None, + approx_tokens: Optional[int] = None, + provider_tokens: Optional[int] = None, + ) -> None: + self._last_successful_request_snapshot = { + "model": model, + "messages": copy.deepcopy(messages), + "api_messages": copy.deepcopy(api_messages) if api_messages is not None else None, + "system_prompt": system_prompt or "", + "tools": copy.deepcopy(tools) if tools else None, + "approx_tokens": approx_tokens, + "provider_tokens": provider_tokens, + } + def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None): """Save session state to both JSON log and SQLite on any exit path. @@ -4675,6 +4739,22 @@ def _build_system_prompt(self, system_message: str = None) -> str: if context_files_prompt: prompt_parts.append(context_files_prompt) + # Optional constraints file from config.yaml (constraints_path). + try: + from hermes_cli.config import load_config as _load_constraints_cfg + _cfg = _load_constraints_cfg() or {} + _constraints_path = str(_cfg.get("constraints_path", "") or "").strip() + if _constraints_path: + _cp = Path(os.path.expanduser(_constraints_path)) + if _cp.exists() and _cp.is_file(): + _constraints_text = _cp.read_text(encoding="utf-8").strip() + if _constraints_text: + prompt_parts.append( + "# Additional constraints (from constraints_path)\n" + _constraints_text + ) + except Exception: + pass + from hermes_time import now as _hermes_now now = _hermes_now() timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y %I:%M %p')}" @@ -7722,6 +7802,81 @@ def _anthropic_preserve_dots(self) -> bool: or "bedrock-runtime." in base ) + def _is_minimax_route(self) -> bool: + """Return True for MiniMax direct and Anthropic-compatible routes.""" + provider = (getattr(self, "provider", "") or "").lower() + if provider in {"minimax", "minimax-cn"}: + return True + bases = [ + getattr(self, "base_url", "") or "", + getattr(self, "_anthropic_base_url", "") or "", + ] + return any("minimax" in base.lower() for base in bases) + + def _minimax_safe_output_cap(self, estimated_input_tokens: int | None) -> int | None: + """Compute an output cap that keeps MiniMax input + output under its window. + + MiniMax's documented 204,800 token context window is a total window: + input tokens plus requested output tokens. Its Anthropic-compatible + endpoint reports ``context window exceeds limit (2013)`` where 2013 is + an error code, not an overflow-token delta, so Hermes must derive the + safe cap from its own input estimate. + """ + if not isinstance(estimated_input_tokens, int) or estimated_input_tokens <= 0: + return None + compressor = getattr(self, "context_compressor", None) + context_length = getattr(compressor, "context_length", None) + if not isinstance(context_length, int) or context_length <= 0: + return None + + available = ( + context_length + - estimated_input_tokens + - self._MINIMAX_TOTAL_CONTEXT_MARGIN_TOKENS + ) + if available < 1: + return None + return available + + def _cap_minimax_total_context_output( + self, + api_kwargs: dict, + estimated_input_tokens: int | None, + ) -> dict: + """Clamp MiniMax max_tokens before the request when the prompt is large.""" + if not self._is_minimax_route() or not isinstance(api_kwargs, dict): + return api_kwargs + + token_key = None + for candidate in ("max_tokens", "max_completion_tokens"): + if candidate in api_kwargs: + token_key = candidate + break + if token_key is None: + return api_kwargs + + current_cap = api_kwargs.get(token_key) + if not isinstance(current_cap, int) or current_cap <= 0: + return api_kwargs + + safe_cap = self._minimax_safe_output_cap(estimated_input_tokens) + if safe_cap is None or safe_cap >= current_cap: + self._last_request_max_output_tokens = current_cap + return api_kwargs + + capped_kwargs = dict(api_kwargs) + capped_kwargs[token_key] = safe_cap + self._last_request_max_output_tokens = safe_cap + logger.info( + "MiniMax request output cap reduced from %s to %s based on " + "estimated input %s tokens and context window %s tokens.", + current_cap, + safe_cap, + estimated_input_tokens, + getattr(getattr(self, "context_compressor", None), "context_length", None), + ) + return capped_kwargs + def _is_qwen_portal(self) -> bool: """Return True when the base URL targets Qwen Portal.""" return base_url_host_matches(self._base_url_lower, "portal.qwen.ai") @@ -7797,7 +7952,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: self._ephemeral_max_output_tokens = None # consume immediately - return _transport.build_kwargs( + kwargs = _transport.build_kwargs( model=self.model, messages=anthropic_messages, tools=self.tools, @@ -7809,6 +7964,8 @@ def _build_api_kwargs(self, api_messages: list) -> dict: base_url=getattr(self, "_anthropic_base_url", None), fast_mode=(self.request_overrides or {}).get("speed") == "fast", ) + self._last_request_max_output_tokens = kwargs.get("max_tokens") + return kwargs # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. # The adapter handles message/tool conversion and boto3 calls directly. @@ -8130,9 +8287,10 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic if codex_message_items: msg["codex_message_items"] = codex_message_items - if assistant_message.tool_calls: + tool_calls_attr = getattr(assistant_message, "tool_calls", None) + if tool_calls_attr and hasattr(tool_calls_attr, "__iter__") and not isinstance(tool_calls_attr, (str, dict)): tool_calls = [] - for tool_call in assistant_message.tool_calls: + for tool_call in tool_calls_attr: raw_id = getattr(tool_call, "id", None) call_id = getattr(tool_call, "call_id", None) if not isinstance(call_id, str) or not call_id.strip(): @@ -8188,11 +8346,19 @@ def _needs_kimi_tool_reasoning(self) -> bool: ``reasoning_content`` on every assistant tool-call message; omitting it causes the next replay to fail with HTTP 400. """ + _is_moonshot_model = False + try: + from agent.moonshot_schema import is_moonshot_model + _is_moonshot_model = bool(is_moonshot_model(self.model or "")) + except Exception: + _is_moonshot_model = False + return ( self.provider in {"kimi-coding", "kimi-coding-cn"} or base_url_host_matches(self.base_url, "api.kimi.com") or base_url_host_matches(self.base_url, "moonshot.ai") or base_url_host_matches(self.base_url, "moonshot.cn") + or _is_moonshot_model ) def _needs_deepseek_tool_reasoning(self) -> bool: @@ -8419,7 +8585,17 @@ def _should_sanitize_tool_calls(self) -> bool: """ return self.api_mode != "codex_responses" - def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default", focus_topic: str = None) -> tuple: + def _compress_context( + self, + messages: list, + system_message: str, + *, + approx_tokens: int = None, + task_id: str = "default", + focus_topic: str = None, + overflow_snapshot: dict = None, + overflow_mode: bool = False, + ) -> tuple: """Compress conversation context and split the session in SQLite. Args: @@ -8446,7 +8622,13 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token pass try: - compressed = self.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic) + compressed = self.context_compressor.compress( + messages, + current_tokens=approx_tokens, + focus_topic=focus_topic, + overflow_snapshot=overflow_snapshot, + overflow_mode=overflow_mode, + ) except TypeError: # Plugin context engine with strict signature that doesn't accept # focus_topic — fall back to calling without it. @@ -8463,7 +8645,32 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token todo_snapshot = self._todo_store.format_for_injection() if todo_snapshot: - compressed.append({"role": "user", "content": todo_snapshot}) + todo_context = ( + "\n\n## Preserved Active Todo List\n" + f"{todo_snapshot}" + ) + injected_todo = False + for msg in compressed: + if SUMMARY_PREFIX in _content_text_for_contains(msg.get("content")): + msg["content"] = _append_text_to_content( + msg.get("content"), + todo_context, + ) + injected_todo = True + break + if not injected_todo: + # Keep preserved todo context away from the end of the list: the + # latest user message after a compaction summary remains the + # active request the model should answer. + insert_at = next( + (i for i in range(len(compressed) - 1, -1, -1) + if compressed[i].get("role") == "user"), + len(compressed), + ) + compressed.insert( + insert_at, + {"role": "assistant", "content": todo_context.strip()}, + ) self._invalidate_system_prompt() new_system_prompt = self._build_system_prompt(system_message) @@ -8556,7 +8763,7 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_ independent: read-only tools may always share the parallel path, while file reads/writes may do so only when their target paths do not overlap. """ - tool_calls = assistant_message.tool_calls + tool_calls = getattr(assistant_message, "tool_calls", None) or [] # Allow _vprint during tool execution even with stream consumers self._executing_tools = True @@ -8635,7 +8842,7 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i result = _memory_tool( action=function_args.get("action"), target=target, - content=function_args.get("content"), + content=function_args.get("content") or function_args.get("new_text"), old_text=function_args.get("old_text"), store=self._memory_store, ) @@ -9177,7 +9384,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe function_result = _memory_tool( action=function_args.get("action"), target=target, - content=function_args.get("content"), + content=function_args.get("content") or function_args.get("new_text"), old_text=function_args.get("old_text"), store=self._memory_store, ) @@ -9732,10 +9939,20 @@ def run_conversation( _should_review_memory = True self._turns_since_memory = 0 - # Add user message - user_msg = {"role": "user", "content": user_message} - messages.append(user_msg) - current_turn_user_idx = len(messages) - 1 + # Add user message. If the previous run failed before producing an + # assistant response, the pending user message is already the final + # persisted turn. Reuse it instead of appending an identical @file + # payload on every retry/resume. + if ( + messages + and messages[-1].get("role") == "user" + and messages[-1].get("content") == user_message + ): + current_turn_user_idx = len(messages) - 1 + else: + user_msg = {"role": "user", "content": user_message} + messages.append(user_msg) + current_turn_user_idx = len(messages) - 1 self._persist_user_message_idx = current_turn_user_idx if not self.quiet_mode: @@ -9794,6 +10011,51 @@ def run_conversation( active_system_prompt = self._cached_system_prompt + # MiniMax charges per request and enforces input + requested output as + # one total window. Before spending a failed API call or an auxiliary + # summarizer call, deterministically remove retry bloat: stale duplicate + # @file payloads, retained tool outputs, and oversized tool arguments. + if ( + self.compression_enabled + and self._is_minimax_route() + and hasattr(self.context_compressor, "compact_redundant_context") + ): + try: + _minimax_preflight_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=self.tools or None, + ) + if _minimax_preflight_tokens >= self.context_compressor.threshold_tokens: + _before_tokens = _minimax_preflight_tokens + _before_len = len(messages) + _trimmed_messages = self.context_compressor.compact_redundant_context( + messages, + target_tokens=self.context_compressor.threshold_tokens, + ) + if _trimmed_messages != messages: + messages = _trimmed_messages + conversation_history = None + _after_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=self.tools or None, + ) + logger.info( + "MiniMax preflight context hygiene: %s -> %s messages, " + "~%s -> ~%s request tokens", + _before_len, + len(messages), + f"{_before_tokens:,}", + f"{_after_tokens:,}", + ) + self._emit_status( + f"🧹 MiniMax preflight trimmed redundant context " + f"(~{_before_tokens:,} → ~{_after_tokens:,} tokens)" + ) + except Exception as exc: + logger.debug("MiniMax preflight context hygiene skipped: %s", exc) + # ── Preflight context compression ── # Before entering the main loop, check if the loaded conversation # history already exceeds the model's context threshold. This handles @@ -10202,9 +10464,14 @@ def run_conversation( # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle. _sanitize_messages_surrogates(api_messages) - # Calculate approximate request size for logging + # Calculate approximate request size for logging and preflight + # checks. Include tool schemas because providers enforce the full + # request, not just chat messages. total_chars = sum(len(str(msg)) for msg in api_messages) - approx_tokens = estimate_messages_tokens_rough(api_messages) + approx_tokens = estimate_request_tokens_rough( + api_messages, + tools=self.tools or None, + ) # Thinking spinner for quiet mode (animated during API call) thinking_spinner = None @@ -10246,6 +10513,7 @@ def run_conversation( thinking_sig_retry_attempted = False image_shrink_retry_attempted = False has_retried_429 = False + minimax_output_caps_tried = set() restart_with_compressed_messages = False restart_with_length_continuation = False @@ -10304,6 +10572,10 @@ def run_conversation( try: self._reset_stream_delivery_tracking() api_kwargs = self._build_api_kwargs(api_messages) + api_kwargs = self._cap_minimax_total_context_output( + api_kwargs, + approx_tokens, + ) if self._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) if self.api_mode == "codex_responses": @@ -10311,6 +10583,13 @@ def run_conversation( try: from hermes_cli.plugins import invoke_hook as _invoke_hook + _request_max_tokens = self.max_tokens + if isinstance(api_kwargs, dict): + _request_max_tokens = ( + api_kwargs.get("max_tokens") + or api_kwargs.get("max_completion_tokens") + or self.max_tokens + ) _invoke_hook( "pre_api_request", task_id=effective_task_id, @@ -10325,7 +10604,7 @@ def run_conversation( tool_count=len(self.tools or []), approx_input_tokens=approx_tokens, request_char_count=total_chars, - max_tokens=self.max_tokens, + max_tokens=_request_max_tokens, ) except Exception: pass @@ -10824,6 +11103,7 @@ def _stop_spinner(): } # Track actual token usage from response for context management + provider_prompt_tokens = None if hasattr(response, 'usage') and response.usage: canonical_usage = normalize_usage( response.usage, @@ -10833,6 +11113,7 @@ def _stop_spinner(): prompt_tokens = canonical_usage.prompt_tokens completion_tokens = canonical_usage.output_tokens total_tokens = canonical_usage.total_tokens + provider_prompt_tokens = prompt_tokens usage_dict = { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, @@ -10938,7 +11219,17 @@ def _stop_spinner(): f"{cached:,}/{prompt:,} tokens " f"({hit_pct:.0f}% hit, {written:,} written)" ) - + + self._capture_last_successful_request_snapshot( + model=self.model, + messages=messages, + system_prompt=active_system_prompt if isinstance(active_system_prompt, str) else "", + tools=self.tools or None, + api_messages=api_messages, + approx_tokens=approx_tokens, + provider_tokens=provider_prompt_tokens, + ) + has_retried_429 = False # Reset on success # Clear Nous rate limit state on successful request — # proves the limit has reset and other sessions can @@ -11625,36 +11916,50 @@ def _stop_spinner(): 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) - _provider_lower = (getattr(self, "provider", "") or "").lower() - _base_lower = (getattr(self, "base_url", "") or "").rstrip("/").lower() - is_minimax_provider = ( - _provider_lower in {"minimax", "minimax-cn"} - or _base_lower.startswith(( - "https://api.minimax.io/anthropic", - "https://api.minimaxi.com/anthropic", - )) - ) - minimax_delta_only_overflow = ( + is_minimax_provider = self._is_minimax_route() + current_output_cap = getattr(self, "_last_request_max_output_tokens", None) + safe_minimax_cap = self._minimax_safe_output_cap(approx_tokens) + if ( is_minimax_provider - and parsed_limit is None - and "context window exceeds limit (" in error_msg - ) + and isinstance(current_output_cap, int) + and current_output_cap > 1 + and isinstance(safe_minimax_cap, int) + and safe_minimax_cap < current_output_cap + and safe_minimax_cap not in minimax_output_caps_tried + ): + minimax_output_caps_tried.add(safe_minimax_cap) + self._ephemeral_max_output_tokens = safe_minimax_cap + self._vprint( + f"{self.log_prefix}⚠️ MiniMax total context error — " + f"retrying with max_tokens={safe_minimax_cap:,} based on " + f"estimated input ~{approx_tokens:,} tokens " + f"(context_length unchanged at {old_ctx:,})", + force=True, + ) + restart_with_compressed_messages = True + break + if is_minimax_provider and isinstance(current_output_cap, int) and current_output_cap > 0: + # If output capping was already as tight as our + # estimate allows, keep that cap across the + # compression retry rather than jumping back to the + # provider's large default output ceiling. + self._ephemeral_max_output_tokens = current_output_cap + + # Error is about the INPUT being too large. + # Only shrink context_length when the provider gave a + # real parsed limit. Generic overflow should keep the + # existing model metadata and fall back to compaction. + parsed_limit = parse_context_limit_from_error(error_msg) if parsed_limit and parsed_limit < old_ctx: new_ctx = parsed_limit self._vprint(f"{self.log_prefix}Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})", force=True) - elif minimax_delta_only_overflow: + else: new_ctx = old_ctx self._vprint( - f"{self.log_prefix}Provider reported overflow amount only; " + f"{self.log_prefix}Provider did not expose a parsed context limit; " f"keeping context_length at {old_ctx:,} tokens and compressing.", force=True, ) - else: - # Step down to the next probe tier - new_ctx = get_next_probe_tier(old_ctx) if new_ctx and new_ctx < old_ctx: compressor.update_model( @@ -11669,13 +11974,11 @@ def _stop_spinner(): if hasattr(compressor, "_context_probed"): compressor._context_probed = True # Only persist limits parsed from the provider's - # error message (a real number). Guessed fallback - # tiers from get_next_probe_tier() should stay - # in-memory only — persisting them pollutes the - # cache with wrong values. - compressor._context_probe_persistable = bool( - parsed_limit and parsed_limit == new_ctx - ) + # error message (a real number). Generic + # overflow no longer guesses fallback tiers, so + # unparsed provider errors must never be + # persisted as model metadata. + compressor._context_probe_persistable = bool(parsed_limit and parsed_limit == new_ctx) self._vprint(f"{self.log_prefix}⚠️ Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens", force=True) else: self._vprint(f"{self.log_prefix}⚠️ Context length exceeded at minimum tier — attempting compression...", force=True) @@ -11698,16 +12001,37 @@ def _stop_spinner(): self._emit_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...") original_len = len(messages) + try: + pre_compress_request_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=self.tools or None, + ) + except Exception: + pre_compress_request_tokens = estimate_messages_tokens_rough(messages) + messages, active_system_prompt = self._compress_context( messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, + overflow_snapshot=self._last_successful_request_snapshot, + overflow_mode=True, ) # Compression created a new session — clear history # so _flush_messages_to_session_db writes compressed # messages to the new session, not skipping them. conversation_history = None - if len(messages) < original_len or new_ctx and new_ctx < old_ctx: + try: + post_compress_request_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=self.tools or None, + ) + except Exception: + post_compress_request_tokens = estimate_messages_tokens_rough(messages) + compression_reduced = post_compress_request_tokens < pre_compress_request_tokens + + if compression_reduced or new_ctx and new_ctx < old_ctx: if len(messages) < original_len: self._emit_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") time.sleep(2) # Brief pause between compression retries @@ -12423,14 +12747,12 @@ def _stop_spinner(): if _tc_names == {"execute_code"}: self.iteration_budget.refund() - # Use real token counts from the API response to decide - # compression. prompt_tokens + completion_tokens is the - # actual context size the provider reported plus the - # assistant turn — a tight lower bound for the next prompt. - # Tool results appended above aren't counted yet, but the - # threshold (default 50%) leaves ample headroom; if tool - # results push past it, the next API call will report the - # real total and trigger compression then. + # Use the API-reported prompt tokens as the baseline, but + # also estimate the next request after appending tool + # results. A large terminal/read_file result can make the + # very next API call exceed context before the provider can + # return fresh usage, so waiting for the next response is + # too late. # # If last_prompt_tokens is 0 (stale after API disconnect # or provider returned no usage data), fall back to rough @@ -12446,13 +12768,23 @@ def _stop_spinner(): # causing premature compression. (#12026) _real_tokens = _compressor.last_prompt_tokens else: - _real_tokens = estimate_messages_tokens_rough(messages) + _real_tokens = 0 + + try: + _next_prompt_estimate = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=self.tools or None, + ) + except Exception: + _next_prompt_estimate = estimate_messages_tokens_rough(messages) + _real_tokens = max(_real_tokens, _next_prompt_estimate) if self.compression_enabled and _compressor.should_compress(_real_tokens): self._safe_print(" ⟳ compacting context…") messages, active_system_prompt = self._compress_context( messages, system_message, - approx_tokens=self.context_compressor.last_prompt_tokens, + approx_tokens=_real_tokens, task_id=effective_task_id, ) # Compression created a new session — clear history so @@ -12918,8 +13250,8 @@ def _stop_spinner(): # Extract reasoning from the last assistant message (if any) last_reasoning = None for msg in reversed(messages): - if msg.get("role") == "assistant" and msg.get("reasoning"): - last_reasoning = msg["reasoning"] + if msg.get("role") == "assistant": + last_reasoning = msg.get("reasoning") # None when current turn has no reasoning break # Build result with interrupt info if applicable diff --git a/setup-hermes.sh b/setup-hermes.sh index 5d0f2928ab47..c895e956f464 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -31,6 +31,11 @@ cd "$SCRIPT_DIR" PYTHON_VERSION="3.11" +# Check if running in an interactive terminal +is_interactive() { + [ -t 0 ] && [ -t 1 ] +} + is_termux() { [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] } @@ -220,10 +225,11 @@ if command -v rg &> /dev/null; then echo -e "${GREEN}✓${NC} ripgrep found" else echo -e "${YELLOW}⚠${NC} ripgrep not found (file search will use grep fallback)" - read -p "Install ripgrep for faster search? [Y/n] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - INSTALLED=false + if is_interactive; then + read -p "Install ripgrep for faster search? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + INSTALLED=false if is_termux; then pkg install -y ripgrep && INSTALLED=true @@ -386,14 +392,19 @@ else echo " hermes gateway install # Install gateway service (messaging + cron)" fi echo " hermes cron list # View scheduled jobs" -echo " hermes doctor # Diagnose issues" +echo " hermes doctor # Diagnose issues" echo "" -# Ask if they want to run setup wizard now -read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - echo "" - # Run directly with venv Python (no activation needed) - "$SCRIPT_DIR/venv/bin/python" -m hermes_cli.main setup +# Ask if they want to run setup wizard now (skip in non-interactive environments) +if is_interactive; then + read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then + echo "" + # Run directly with venv Python (no activation needed) + "$SCRIPT_DIR/venv/bin/python" -m hermes_cli.main setup + fi +else + echo -e "${CYAN}→${NC} Non-interactive environment detected. Skipping setup wizard prompt." + echo -e "${CYAN}→${NC} Run 'hermes setup' manually to configure API keys." fi diff --git a/skills/red-teaming/godmode/scripts/load_godmode.py b/skills/red-teaming/godmode/scripts/load_godmode.py index 71cb2f224753..66c00d3afd1c 100644 --- a/skills/red-teaming/godmode/scripts/load_godmode.py +++ b/skills/red-teaming/godmode/scripts/load_godmode.py @@ -1,11 +1,10 @@ -""" -Loader for G0DM0D3 scripts. Handles the exec-scoping issues. +"""Loader for G0DM0D3 scripts. Handles the exec-scoping issues. Usage in execute_code: exec(open(os.path.expanduser( os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/load_godmode.py") )).read()) - + # Now all functions are available: # - auto_jailbreak(), undo_jailbreak() # - race_models(), race_godmode_classic() @@ -15,6 +14,7 @@ """ import os, sys +import importlib.util from pathlib import Path _gm_scripts_dir = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "skills" / "red-teaming" / "godmode" / "scripts" @@ -23,11 +23,12 @@ sys.argv = ["_godmode_loader"] def _gm_load(path): - ns = dict(globals()) - ns["__name__"] = "_godmode_module" - ns["__file__"] = str(path) - exec(compile(open(path).read(), str(path), 'exec'), ns) - return ns + spec = importlib.util.spec_from_file_location("_godmode_module", path) + module = importlib.util.module_from_spec(spec) + module.__name__ = "_godmode_module" + module.__file__ = str(path) + spec.loader.exec_module(module) + return module.__dict__ for _gm_script in ["parseltongue.py", "godmode_race.py", "auto_jailbreak.py"]: _gm_path = _gm_scripts_dir / _gm_script diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 883745d6c840..e284c414db0a 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -92,6 +92,395 @@ def test_protects_first_and_last(self, compressor): # original content is present in either case. assert msgs[-2]["content"] in result[-2]["content"] + def test_summary_prompt_sees_unpruned_tool_output(self): + """Pruning final transcript tool output must not starve the summarizer. + + Regression: old tool results were replaced with one-line summaries + before `_generate_summary()`, so the auxiliary LLM never saw the actual + finding it needed to preserve. + """ + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + c.tail_token_budget = 10 + + tool_output = "CRITICAL_FINDING: config flag is inverted\n" + ("x" * 600) + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "config.py"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": tool_output}, + {"role": "assistant", "content": "noted"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "latest ask"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c.compress(msgs, current_tokens=90_000) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "CRITICAL_FINDING: config flag is inverted" in prompt + assert "[read_file] read config.py" not in prompt + + def test_overflow_does_not_tombstone_when_summary_request_fits(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + + tool_output = "CRITICAL_HEAD\n" + ("X" * 400000) + "\nCRITICAL_TAIL" + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "huge.txt"}', + }, + }], + }, + {"role": "tool", "tool_call_id": "call_1", "content": tool_output}, + {"role": "assistant", "content": "noted"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "latest ask"}, + ] + + with ( + patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call, + patch.object(c, "_summary_input_token_budget", return_value=100000), + ): + c.compress(msgs, current_tokens=90_000, overflow_mode=True) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "[tool result compacted]" not in prompt + assert "CRITICAL_HEAD" in prompt + + def test_overflow_tombstones_tool_output_when_summary_request_exceeds_budget(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + ] + for idx in range(2): + call_id = f"call_{idx}" + msgs.extend([ + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": "read_file", + "arguments": f'{{"path": "huge_{idx}.txt"}}', + }, + }], + }, + {"role": "tool", "tool_call_id": call_id, "content": "X" * 50000}, + {"role": "assistant", "content": f"noted {idx}"}, + ]) + msgs.extend([ + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "latest ask"}, + ]) + + with ( + patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call, + patch.object(c, "_summary_input_token_budget", return_value=1000), + ): + c.compress(msgs, current_tokens=90_000, overflow_mode=True) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "[tool result compacted]" in prompt + assert "tool: read_file" in prompt + assert "tool_call_id: call_0" in prompt or "tool_call_id: call_1" in prompt + + def test_overflow_tombstones_retained_tail_tool_output(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + c.threshold_tokens = 1000 + + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "middle"}, + {"role": "assistant", "content": "middle ack"}, + {"role": "user", "content": "latest request"}, + {"role": "assistant", "content": "will run command"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_tail", + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"cmd": "cat huge.log"}', + }, + }], + }, + {"role": "tool", "tool_call_id": "call_tail", "content": "X" * 200000}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs, current_tokens=90_000, overflow_mode=True) + + tool_msg = next(m for m in result if m.get("role") == "tool") + assert "[tool result compacted]" in tool_msg["content"] + assert "tool: terminal" in tool_msg["content"] + assert "refetchable: no" in tool_msg["content"] + assert "do not rerun side-effecting tools" in tool_msg["content"] + + def test_overflow_compacts_stale_large_user_context_in_protected_head(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + c.threshold_tokens = 1000 + + old_context = ( + "Review this old PRD\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (50000 tokens)\n```json\n" + + ("X" * 120000) + + "\n```" + ) + latest_context = "Current request with @file:prd.json content must remain intact" + msgs = [ + {"role": "user", "content": old_context}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "middle"}, + {"role": "assistant", "content": "middle ack"}, + {"role": "user", "content": latest_context}, + {"role": "assistant", "content": "working"}, + {"role": "user", "content": latest_context}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs, current_tokens=90_000, overflow_mode=True) + + assert "[large user context compacted]" in result[0]["content"] + assert "original_size:" in result[0]["content"] + assert result[-1]["content"] == latest_context + + def test_overflow_compacts_duplicate_pending_user_context(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True) + + huge_user = ( + "Task\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (60000 tokens)\n```json\n" + + ("X" * 60000) + + "\n```" + ) + msgs = [ + {"role": "user", "content": huge_user}, + {"role": "assistant", "content": "summary"}, + {"role": "user", "content": huge_user}, + {"role": "user", "content": huge_user}, + ] + + result = c._compact_stale_large_user_messages(msgs, target_tokens=1000) + + compacted = [m for m in result if "[large user context compacted]" in str(m.get("content"))] + assert len(compacted) >= 1 + assert result[-1]["content"] == huge_user + assert "duplicate_of_later_user_message: yes" in compacted[0]["content"] + assert "older_duplicate_file_path: yes" in compacted[0]["content"] + assert "attached_files: prd.json" in compacted[0]["content"] + + def test_overflow_compacts_older_same_file_context_by_path(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True) + + old_prd = ( + "Old task\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (400 tokens)\n```json\nold version\n```" + ) + new_prd = ( + "New task\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (450 tokens)\n```json\nnew version\n```" + ) + msgs = [ + {"role": "user", "content": old_prd}, + {"role": "assistant", "content": "handled old"}, + {"role": "user", "content": "unrelated"}, + {"role": "user", "content": new_prd}, + ] + + result = c._compact_stale_large_user_messages(msgs, target_tokens=999999) + + assert "[large user context compacted]" in result[0]["content"] + assert "older_duplicate_file_path: yes" in result[0]["content"] + assert "attached_files: prd.json" in result[0]["content"] + assert result[-1]["content"] == new_prd + + def test_overflow_tombstones_large_tool_output(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + + tool_output = "X" * 400000 + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "config.py"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": tool_output}, + {"role": "assistant", "content": "noted"}, + {"role": "user", "content": "latest ask"}, + {"role": "assistant", "content": "extra tail"}, + ] + + tombstoned = c._tombstone_tool_results_for_summary(msgs) + tool_msg = next(m for m in tombstoned if m.get("role") == "tool") + assert "[tool result compacted]" in tool_msg["content"] + assert "tool: read_file" in tool_msg["content"] + assert "tool_call_id: call_1" in tool_msg["content"] + assert "refetchable: yes" in tool_msg["content"] + assert 'args: {"path": "config.py"}' in tool_msg["content"] + + def test_sanitize_tool_pairs_moves_results_next_to_parent_call(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True) + + result = c._sanitize_tool_pairs([ + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }], + }, + {"role": "user", "content": "intervening message"}, + {"role": "tool", "tool_call_id": "call_1", "content": "file contents"}, + ]) + + assert result[0]["role"] == "assistant" + assert result[1] == {"role": "tool", "tool_call_id": "call_1", "content": "file contents"} + assert result[2] == {"role": "user", "content": "intervening message"} + + def test_recompression_does_not_feed_existing_summary_as_turn(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "updated summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + ) + c.tail_token_budget = 10 + c._previous_summary = "previous summary body" + + merged = ( + f"{SUMMARY_PREFIX}\nold merged summary\n\n" + "--- END OF CONTEXT SUMMARY — respond to the message below, " + "not the summary above ---\n\n" + "real tail content" + ) + msgs = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\nold standalone summary"}, + {"role": "user", "content": merged}, + {"role": "assistant", "content": "middle work"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "latest ask"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c.compress(msgs, current_tokens=90_000) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "previous summary body" in prompt + assert "old standalone summary" not in prompt + assert "old merged summary" not in prompt + assert "real tail content" in prompt + class TestGenerateSummaryNoneContent: """Regression: content=None (from tool-call-only assistant messages) must not crash.""" diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 4adf9f72e573..768748f2a50c 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -72,6 +72,17 @@ def test_tools_included(self, transport): kw = transport.build_kwargs(model="gpt-4o", messages=msgs, tools=tools) assert kw["tools"] == tools + def test_custom_provider_defaults_temperature(self, transport): + msgs = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs(model="qwen", messages=msgs, is_custom_provider=True) + assert kw["temperature"] == 0.2 + + def test_tools_enable_parallel_tool_calls_by_default(self, transport): + msgs = [{"role": "user", "content": "Hi"}] + tools = [{"type": "function", "function": {"name": "test", "parameters": {}}}] + kw = transport.build_kwargs(model="qwen", messages=msgs, tools=tools) + assert kw["parallel_tool_calls"] is True + def test_openrouter_provider_prefs(self, transport): msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py index 91627f92b947..a41dd2fccf1f 100644 --- a/tests/gateway/test_compress_command.py +++ b/tests/gateway/test_compress_command.py @@ -123,3 +123,52 @@ def _estimate(messages): assert "denser summaries" in result agent_instance.shutdown_memory_provider.assert_called_once() agent_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_compress_command_preserves_tool_history_for_summary(): + history = [ + {"role": "user", "content": "inspect config"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "config.py"}', + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "CRITICAL_FINDING: config flag is inverted", + }, + {"role": "assistant", "content": "I found the issue."}, + ] + runner = _make_runner(history) + agent_instance = MagicMock() + agent_instance.shutdown_memory_provider = MagicMock() + agent_instance.close = MagicMock() + agent_instance.context_compressor.has_content_to_compress.return_value = True + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (list(history), "") + + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance), + patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100), + ): + await runner._handle_compress_command(_make_event()) + + compressed_input = agent_instance._compress_context.call_args.args[0] + assert compressed_input[1]["tool_calls"][0]["id"] == "call_1" + assert compressed_input[2]["role"] == "tool" + assert "CRITICAL_FINDING" in compressed_input[2]["content"] + agent_instance.shutdown_memory_provider.assert_called_once() + agent_instance.close.assert_called_once() diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 77afc61705a1..81f7f60fca98 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -120,7 +120,7 @@ def test_huggingface_env_vars(self): def test_base_urls(self): assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com" assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot" - assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" + assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/anthropic" assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic" @@ -409,7 +409,7 @@ def test_resolve_zai_with_key(self, monkeypatch): creds = resolve_api_key_provider_credentials("zai") assert creds["provider"] == "zai" assert creds["api_key"] == "glm-secret-key" - assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + assert creds["base_url"] == "https://api.z.ai/api/anthropic" assert creds["source"] == "GLM_API_KEY" def test_resolve_copilot_with_github_token(self, monkeypatch): @@ -578,7 +578,7 @@ def test_runtime_zai(self, monkeypatch): from hermes_cli.runtime_provider import resolve_runtime_provider result = resolve_runtime_provider(requested="zai") assert result["provider"] == "zai" - assert result["api_mode"] == "chat_completions" + assert result["api_mode"] == "anthropic_messages" assert result["api_key"] == "glm-key" assert "z.ai" in result["base_url"] or "api.z.ai" in result["base_url"] @@ -952,7 +952,7 @@ def test_non_kimi_providers_unaffected(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "sk-kim...isnt") monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") - assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + assert creds["base_url"] == "https://api.z.ai/api/anthropic" class TestZaiEndpointAutoDetect: @@ -976,7 +976,7 @@ def test_probe_failure_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "glm-key") monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") - assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + assert creds["base_url"] == "https://api.z.ai/api/anthropic" def test_env_override_skips_probe(self, monkeypatch): """GLM_BASE_URL should always win without probing.""" diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 5c719cbc21fd..a53610c6bcad 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -319,6 +319,23 @@ def test_value_ending_with_digits_still_splits(self): assert result[0].startswith("OPENROUTER_API_KEY=") assert result[1].startswith("OPENAI_BASE_URL=") + def test_glm_suffix_collision_not_split(self): + """GLM_API_KEY / GLM_BASE_URL must not be mangled by LM_API_KEY / LM_BASE_URL suffixes (#17138).""" + lines = [ + "GLM_API_KEY=glm-secret\n", + "GLM_BASE_URL=https://api.z.ai/api/anthropic\n", + ] + result = _sanitize_env_lines(lines) + assert result == lines, f"GLM_* lines were corrupted by suffix collision: {result}" + + def test_suffix_collision_does_not_break_real_concatenation(self): + """A genuine concatenation that happens to start with a suffix-superset key still splits.""" + lines = ["GLM_API_KEY=glmLM_API_KEY=lm-key\n"] + result = _sanitize_env_lines(lines) + assert len(result) == 2 + assert result[0].startswith("GLM_API_KEY=") + assert result[1].startswith("LM_API_KEY=") + def test_save_env_value_fixes_corruption_on_write(self, tmp_path): """save_env_value sanitizes corrupted lines when writing a new key.""" env_file = tmp_path / ".env" diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 8bd357d3d288..9026341e2ab9 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -79,7 +79,8 @@ def _make_413_error(*, use_status_code=True, message="Request entity too large") @pytest.fixture() -def agent(): +def agent(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), patch("run_agent.check_toolset_requirements", return_value={}), @@ -340,6 +341,85 @@ def test_400_reduce_length_triggers_compression(self, agent): mock_compress.assert_called_once() assert result["completed"] is True + def test_generic_context_overflow_does_not_shrink_context_length(self, agent): + """Generic overflow should compress/retry without mutating context_length.""" + err_400 = Exception("Error code: 400 - prompt is too long") + err_400.status_code = 400 + ok_resp = _mock_response(content="Recovered", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [err_400, ok_resp] + original_ctx = agent.context_compressor.context_length + + with ( + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent.context_compressor, "update_model") as mock_update_model, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + mock_compress.return_value = ( + [{"role": "user", "content": "compressed summary"}], + "compressed prompt", + ) + result = agent.run_conversation("hello") + + assert agent.context_compressor.context_length == original_ctx + mock_update_model.assert_not_called() + + def test_overflow_compaction_uses_last_successful_snapshot(self, agent): + """Overflow compaction should pass the last successful request snapshot through.""" + agent._last_successful_request_snapshot = { + "model": "test/model", + "messages": [{"role": "user", "content": "snapshot request"}], + "system_prompt": "snapshot system", + "tools": _make_tool_defs("web_search"), + "approx_tokens": 120, + "provider_tokens": 120, + } + err_400 = Exception("Error code: 400 - prompt is too long") + err_400.status_code = 400 + ok_resp = _mock_response(content="Recovered", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [err_400, ok_resp] + + captured = {} + + def _compress(messages, current_tokens=None, focus_topic=None, **kwargs): + captured.update(kwargs) + return [{"role": "user", "content": "compressed summary"}] + + def _estimate(messages, **_kwargs): + if messages and messages[0].get("content") == "compressed summary": + return 10 + return 1000 + + with ( + patch.object(agent.context_compressor, "compress", side_effect=_compress), + patch("run_agent.estimate_request_tokens_rough", side_effect=_estimate), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert captured.get("overflow_mode") is True + assert captured.get("overflow_snapshot", {}).get("messages") + assert captured["overflow_snapshot"]["messages"][0]["content"] == "snapshot request" + + def test_last_successful_snapshot_keeps_canonical_messages_separate_from_api_payload(self, agent): + agent._capture_last_successful_request_snapshot( + model="test/model", + messages=[{"role": "user", "content": "canonical request"}], + api_messages=[{"role": "user", "content": "api-shaped request"}], + system_prompt="system", + tools=_make_tool_defs("web_search"), + approx_tokens=10, + provider_tokens=9, + ) + + snapshot = agent._last_successful_request_snapshot + assert snapshot["messages"][0]["content"] == "canonical request" + assert snapshot["api_messages"][0]["content"] == "api-shaped request" + def test_context_length_retry_rebuilds_request_after_compression(self, agent): """Retry must send the compressed transcript, not the stale oversized payload.""" err_400 = Exception( diff --git a/tests/run_agent/test_compression_boundary_hook.py b/tests/run_agent/test_compression_boundary_hook.py index 26bac74163b2..48ecc979408f 100644 --- a/tests/run_agent/test_compression_boundary_hook.py +++ b/tests/run_agent/test_compression_boundary_hook.py @@ -123,6 +123,48 @@ def test_no_hook_when_no_session_db(self): f"got {comp_calls!r}" ) + def test_todo_snapshot_is_not_appended_as_latest_user_message(self): + """Todo preservation must not become the active post-summary request.""" + from agent.context_compressor import SUMMARY_PREFIX + from run_agent import AIAgent + + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=None, + session_id="original-session", + skip_context_files=True, + skip_memory=True, + ) + + compressor = MagicMock() + compressor.compress.return_value = [ + {"role": "user", "content": f"{SUMMARY_PREFIX}\nsummary"}, + {"role": "user", "content": "tail question"}, + ] + compressor.compression_count = 1 + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor._last_summary_error = None + agent.context_compressor = compressor + agent._todo_store.write([ + {"id": "1", "content": "finish the patch", "status": "in_progress"}, + ]) + + compressed, _ = agent._compress_context( + [{"role": "user", "content": "m"}], + "sys", + approx_tokens=100, + ) + + assert len(compressed) == 2 + assert "Preserved Active Todo List" in compressed[0]["content"] + assert "finish the patch" in compressed[0]["content"] + assert compressed[-1] == {"role": "user", "content": "tail question"} + def test_hook_failure_does_not_break_compression(self): """If the context engine raises from on_session_start, compression still completes.""" from hermes_state import SessionDB diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index eb2b47f87af5..fa68018b7955 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2134,6 +2134,71 @@ def test_stop_finish_reason_returns_response(self, agent): assert result["final_response"] == "Final answer" assert result["completed"] is True + def test_reuses_identical_pending_user_message_on_retry(self, agent): + self._setup_agent(agent) + pending = "Do the task\n\n--- Attached Context ---\n\n📄 @file:prd.json (10 tokens)\n```json\n{}\n```" + resp = _mock_response(content="Done", finish_reason="stop") + agent.client.chat.completions.create.return_value = resp + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation( + pending, + conversation_history=[{"role": "user", "content": pending}], + ) + + user_messages = [m for m in result["messages"] if m.get("role") == "user"] + assert len(user_messages) == 1 + assert user_messages[0]["content"] == pending + sent_messages = agent.client.chat.completions.create.call_args.kwargs["messages"] + sent_user_messages = [m for m in sent_messages if m.get("role") == "user"] + assert len(sent_user_messages) == 1 + + def test_minimax_preflight_hygiene_compacts_older_same_file_context(self, agent): + self._setup_agent(agent) + agent.compression_enabled = True + agent.provider = "minimax" + agent.base_url = "https://api.minimax.io/anthropic" + agent.context_compressor.context_length = 204_800 + agent.context_compressor.threshold_tokens = 1000 + + old_prd = ( + "Old PRD task\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (40000 tokens)\n```json\n" + + ("X" * 40000) + + "\n```" + ) + new_prd = ( + "New PRD task\n\n--- Attached Context ---\n\n" + "📄 @file:prd.json (45000 tokens)\n```json\n" + + ("Y" * 4000) + + "\n```" + ) + resp = _mock_response(content="Done", finish_reason="stop") + agent.client.chat.completions.create.return_value = resp + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation( + new_prd, + conversation_history=[ + {"role": "user", "content": old_prd}, + {"role": "assistant", "content": "handled old"}, + ], + ) + + assert result["completed"] is True + sent_messages = agent.client.chat.completions.create.call_args.kwargs["messages"] + user_contents = [m.get("content", "") for m in sent_messages if m.get("role") == "user"] + assert any("[large user context compacted]" in content for content in user_contents) + assert user_contents[-1] == new_prd + def test_tool_calls_then_stop(self, agent): self._setup_agent(agent) tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") @@ -2653,16 +2718,36 @@ def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent): assert result["final_response"] == "Recovered after compression" assert result["completed"] is True - def test_minimax_delta_overflow_keeps_known_context_length(self, agent): - """MiniMax reports overflow deltas like 'limit (2013)' without the real window. + def test_minimax_preflight_caps_output_to_total_context_window(self, agent): + """MiniMax's 204,800-token limit is input + requested output.""" + self._setup_agent(agent) + agent.provider = "minimax" + agent.model = "MiniMax-M2.7-highspeed" + agent.base_url = "https://api.minimax.io/anthropic" + agent.context_compressor.context_length = 204_800 + agent.context_compressor.threshold_tokens = int( + agent.context_compressor.context_length * agent.context_compressor.threshold_percent + ) + + original_kwargs = {"model": "MiniMax-M2.7", "max_tokens": 131_072} + capped = agent._cap_minimax_total_context_output( + original_kwargs, + estimated_input_tokens=130_339, + ) - Keep the known 204,800-token window and compress instead of probing down - to the generic 128K fallback tier. - """ + assert capped is not original_kwargs + assert original_kwargs["max_tokens"] == 131_072 + assert capped["max_tokens"] == 72_413 + assert agent._last_request_max_output_tokens == 72_413 + assert agent.context_compressor.context_length == 204_800 + + def test_minimax_error_after_safe_cap_compresses_without_delta_decrement(self, agent): + """MiniMax '(2013)' is an error code, not a token overage delta.""" self._setup_agent(agent) agent.provider = "minimax" agent.model = "MiniMax-M2.7-highspeed" agent.base_url = "https://api.minimax.io/anthropic" + agent.max_tokens = 131_072 agent.context_compressor.context_length = 204_800 agent.context_compressor.threshold_tokens = int( agent.context_compressor.context_length * agent.context_compressor.threshold_percent @@ -2674,12 +2759,10 @@ def test_minimax_delta_overflow_keeps_known_context_length(self, agent): err_400.status_code = 400 ok_resp = _mock_response(content="Recovered after compression", finish_reason="stop") agent.client.chat.completions.create.side_effect = [err_400, ok_resp] - prefill = [ - {"role": "user", "content": "previous question"}, - {"role": "assistant", "content": "previous answer"}, - ] with ( + patch("run_agent.estimate_request_tokens_rough", side_effect=[130_339, 130_339, 100, 100]), + patch("run_agent.time.sleep"), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -2689,16 +2772,20 @@ def test_minimax_delta_overflow_keeps_known_context_length(self, agent): [{"role": "user", "content": "hello"}], "compressed system prompt", ) - result = agent.run_conversation("hello", conversation_history=prefill) + result = agent.run_conversation("hello") mock_compress.assert_called_once() + first_kwargs = agent.client.chat.completions.create.call_args_list[0].kwargs + retry_kwargs = agent.client.chat.completions.create.call_args_list[1].kwargs + assert first_kwargs["max_tokens"] == 72_413 + assert retry_kwargs["max_tokens"] == 72_413 assert agent.context_compressor.context_length == 204_800 assert agent.context_compressor._context_probed is False assert result["final_response"] == "Recovered after compression" assert result["completed"] is True - def test_non_minimax_delta_overflow_still_probes_down(self, agent): - """Non-MiniMax providers should keep the generic probe-down behavior.""" + def test_non_minimax_delta_overflow_compresses_without_probe_down(self, agent): + """Generic delta-shaped overflow should compress without fake context shrink.""" self._setup_agent(agent) agent.provider = "openrouter" agent.model = "some/unknown-model" @@ -2732,7 +2819,7 @@ def test_non_minimax_delta_overflow_still_probes_down(self, agent): result = agent.run_conversation("hello", conversation_history=prefill) mock_compress.assert_called_once() - assert agent.context_compressor.context_length == 128_000 + assert agent.context_compressor.context_length == 200_000 assert result["final_response"] == "Recovered after compression" assert result["completed"] is True diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 9918a826cbce..7f911ec75d49 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -566,3 +566,273 @@ def test_guard_flag_handles_config_error(self): with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): assert _guard_agent_created_enabled() is False + + def test_guard_flag_quoted_false_stays_disabled(self): + """Quoted 'false' from YAML edits must not enable the guard.""" + from tools.skill_manager_tool import _guard_agent_created_enabled + + for quoted in ("false", "False", "0", "no", "off"): + with patch("hermes_cli.config.load_config", + return_value={"skills": {"guard_agent_created": quoted}}): + assert _guard_agent_created_enabled() is False, \ + f"guard_agent_created={quoted!r} must coerce to False" + + def test_guard_flag_quoted_true_enables(self): + """Quoted truthy strings must enable the guard.""" + from tools.skill_manager_tool import _guard_agent_created_enabled + + for quoted in ("true", "True", "1", "yes", "on"): + with patch("hermes_cli.config.load_config", + return_value={"skills": {"guard_agent_created": quoted}}): + assert _guard_agent_created_enabled() is True, \ + f"guard_agent_created={quoted!r} must coerce to True" + + +# --------------------------------------------------------------------------- +# External skills directories (skills.external_dirs) — mutations in place +# --------------------------------------------------------------------------- + + +@contextmanager +def _two_roots(local_dir: Path, external_dir: Path): + """Patch the skill manager so local SKILLS_DIR = local_dir and + get_all_skills_dirs() returns [local_dir, external_dir] in order.""" + with patch("tools.skill_manager_tool.SKILLS_DIR", local_dir), \ + patch("agent.skill_utils.get_all_skills_dirs", + return_value=[local_dir, external_dir]): + yield + + +def _write_external_skill(external_dir: Path, name: str = "ext-skill") -> Path: + skill_dir = external_dir / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: An external skill.\n---\n\n" + "# External\n\nBody with OLD_MARKER here.\n" + ) + return skill_dir + + +class TestExternalSkillMutations: + """Verify skill_manage can patch/edit/write/remove/delete skills that live + under skills.external_dirs — in place, without duplicating to local. + + Regression for issues #4759 and #4381: the read-only gate used to refuse + with 'Skill X is in an external directory and cannot be modified', which + caused agents to create duplicate copies in ~/.hermes/skills/ as a + workaround. + """ + + def test_patch_external_skill_writes_in_place(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _patch_skill("ext-skill", "OLD_MARKER", "NEW_MARKER") + + assert result["success"] is True, result + assert "NEW_MARKER" in (skill_dir / "SKILL.md").read_text() + # No duplicate in local + assert not (local / "ext-skill").exists() + + def test_edit_external_skill_writes_in_place(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + new_content = ( + "---\nname: ext-skill\ndescription: Rewritten.\n---\n\n" + "# Rewritten\n\nBrand new body.\n" + ) + with _two_roots(local, external): + result = _edit_skill("ext-skill", new_content) + + assert result["success"] is True, result + assert "Brand new body" in (skill_dir / "SKILL.md").read_text() + assert not (local / "ext-skill").exists() + + def test_write_file_on_external_skill(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _write_file("ext-skill", "references/notes.md", "# Notes\n") + + assert result["success"] is True, result + assert (skill_dir / "references" / "notes.md").read_text() == "# Notes\n" + assert not (local / "ext-skill").exists() + + def test_remove_file_on_external_skill(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + (skill_dir / "references").mkdir() + (skill_dir / "references" / "notes.md").write_text("# Notes\n") + + with _two_roots(local, external): + result = _remove_file("ext-skill", "references/notes.md") + + assert result["success"] is True, result + assert not (skill_dir / "references" / "notes.md").exists() + + def test_delete_external_skill_removes_skill_not_root(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _delete_skill("ext-skill") + + assert result["success"] is True, result + assert not skill_dir.exists() + # The external root must NOT be rmdir'd, even when empty after deletion + assert external.exists() and external.is_dir() + + def test_delete_external_skill_cleans_empty_category(self, tmp_path): + """When a skill lives under external//, deleting the + last skill in the category should rmdir the empty category dir but + stop at the external root.""" + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + cat_dir = external / "team" + cat_dir.mkdir() + skill_dir = cat_dir / "ext-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: ext-skill\ndescription: An external skill.\n---\n\n" + "# External\n\nBody.\n" + ) + + with _two_roots(local, external): + result = _delete_skill("ext-skill") + + assert result["success"] is True, result + assert not skill_dir.exists() + assert not cat_dir.exists() # empty category cleaned up + assert external.exists() # but never the external root + + def test_create_still_writes_to_local_root(self, tmp_path): + """Creating a new skill always lands in local SKILLS_DIR, never + external_dirs — create is unchanged by this PR.""" + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + + with _two_roots(local, external): + result = _create_skill("fresh-skill", VALID_SKILL_CONTENT.replace( + "name: test-skill", "name: fresh-skill")) + + assert result["success"] is True, result + assert (local / "fresh-skill" / "SKILL.md").exists() + assert not (external / "fresh-skill").exists() + + + +# --------------------------------------------------------------------------- +# Pinned-skill guard — pinned skills are deletion-protected. +# Edits/patches/writes are allowed; delete/remove_file require unpin. +# --------------------------------------------------------------------------- + +class TestPinnedGuard: + """Pinned skills block destructive deletes but still allow maintenance.""" + + @staticmethod + def _pin(name: str): + """Return a patch context that marks *name* as pinned in skill_usage.""" + def _fake_get_record(skill_name, _name=name): + return {"pinned": True} if skill_name == _name else {"pinned": False} + return patch("tools.skill_usage.get_record", side_effect=_fake_get_record) + + def test_edit_allows_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + with self._pin("my-skill"): + result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2) + assert result["success"] is True + content = (tmp_path / "my-skill" / "SKILL.md").read_text() + assert "Updated description." in content + + def test_patch_allows_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + with self._pin("my-skill"): + result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.") + assert result["success"] is True + content = (tmp_path / "my-skill" / "SKILL.md").read_text() + assert "Do the new thing." in content + + def test_patch_supporting_file_allows_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + _write_file("my-skill", "references/api.md", "original") + with self._pin("my-skill"): + result = _patch_skill( + "my-skill", "original", "modified", + file_path="references/api.md", + ) + assert result["success"] is True + assert (tmp_path / "my-skill" / "references" / "api.md").read_text() == "modified" + + def test_delete_refuses_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + with self._pin("my-skill"): + result = _delete_skill("my-skill") + assert result["success"] is False + assert "pinned" in result["error"].lower() + # Skill still exists + assert (tmp_path / "my-skill" / "SKILL.md").exists() + + def test_write_file_allows_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + with self._pin("my-skill"): + result = _write_file("my-skill", "references/api.md", "content") + assert result["success"] is True + assert (tmp_path / "my-skill" / "references" / "api.md").exists() + + def test_remove_file_refuses_pinned(self, tmp_path): + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + _write_file("my-skill", "references/api.md", "content") + with self._pin("my-skill"): + result = _remove_file("my-skill", "references/api.md") + assert result["success"] is False + assert "pinned" in result["error"].lower() + # File still there + assert (tmp_path / "my-skill" / "references" / "api.md").exists() + + def test_unpinned_skills_still_editable(self, tmp_path): + """Sanity check: the guard doesn't fire for unpinned skills. + + Only destructive operations are refused; normal edits should still work. + """ + with _skill_dir(tmp_path): + _create_skill("pinned-one", VALID_SKILL_CONTENT) + _create_skill("free-one", VALID_SKILL_CONTENT) + with self._pin("pinned-one"): + blocked = _edit_skill("pinned-one", VALID_SKILL_CONTENT_2) + allowed = _edit_skill("free-one", VALID_SKILL_CONTENT_2) + assert blocked["success"] is True + assert allowed["success"] is True + + def test_broken_sidecar_fails_open(self, tmp_path): + """If skill_usage.get_record raises, we allow the write through. + + Rationale: a corrupted telemetry file shouldn't lock the agent out + of skills it would otherwise be allowed to touch. + """ + with _skill_dir(tmp_path): + _create_skill("my-skill", VALID_SKILL_CONTENT) + with patch("tools.skill_usage.get_record", + side_effect=RuntimeError("sidecar broken")): + result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2) + assert result["success"] is True diff --git a/tools/approval.py b/tools/approval.py index c31c764d6ff1..150704a6dc49 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -173,7 +173,7 @@ def _hardline_block_result(description: str) -> dict: # ========================================================================= DANGEROUS_PATTERNS = [ - (r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"), + (r'\brm\s+(-[^\s]*\s+)*/(\s|\*|$|\.\*|(bin|boot|dev|etc|lib|lib64|opt|proc|root|run|sbin|srv|sys|usr|var)(/|\s|$))', "delete in root path"), (r'\brm\s+-[^\s]*r', "recursive delete"), (r'\brm\s+--recursive\b', "recursive delete (long flag)"), (r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"), diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 397b7c958be6..66ca4dbd6b8e 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -909,7 +909,41 @@ def _build_child_agent( if toolsets: # Intersect with parent — subagent must not gain tools the parent lacks - child_toolsets = [t for t in toolsets if t in parent_toolsets] + # Expand composite toolsets (like hermes-cli) into their constituent tools + # so that intersection works correctly at the tool level, not toolset-name level. + import model_tools + + # Get all tools available to parent via their toolsets + parent_tool_names = set() + for ts_name in parent_toolsets: + ts = TOOLSETS.get(ts_name) + if ts: + # Add tools defined directly in this toolset + parent_tool_names.update(ts.get("tools", [])) + # Add tools from included toolsets + for included_ts in ts.get("includes", []): + inc_ts = TOOLSETS.get(included_ts) + if inc_ts: + parent_tool_names.update(inc_ts.get("tools", [])) + + # For each requested toolset, check if parent has access to its tools + child_toolsets = [] + for ts_name in toolsets: + if ts_name in parent_toolsets: + # Direct match (e.g., parent has "terminal", child asks for "terminal") + child_toolsets.append(ts_name) + else: + # Check if this toolset's tools are available in parent + ts = TOOLSETS.get(ts_name) + if ts: + ts_tools = set(ts.get("tools", [])) + for included_ts in ts.get("includes", []): + inc_ts = TOOLSETS.get(included_ts) + if inc_ts: + ts_tools.update(inc_ts.get("tools", [])) + # If parent's tools include all tools from this toolset, add it + if ts_tools and ts_tools.issubset(parent_tool_names): + child_toolsets.append(ts_name) if _get_inherit_mcp_toolsets(): child_toolsets = _preserve_parent_mcp_toolsets( child_toolsets, parent_toolsets @@ -2238,7 +2272,10 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: try: from hermes_cli.runtime_provider import resolve_runtime_provider - runtime = resolve_runtime_provider(requested=configured_provider) + runtime = resolve_runtime_provider( + requested=configured_provider, + target_model=configured_model or None, + ) except Exception as exc: raise ValueError( f"Cannot resolve delegation provider '{configured_provider}': {exc}. " diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 65c33b349c8d..55b4ea2b87ab 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -154,7 +154,8 @@ def find_docker() -> Optional[str]: # the drop, so the security posture is preserved. # Block privilege escalation and limit PIDs. # /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds). -_SECURITY_ARGS = [ +# Sizes are configurable via DockerEnvironment constructor parameters. +_SECURITY_ARGS_BASE = [ "--cap-drop", "ALL", "--cap-add", "DAC_OVERRIDE", "--cap-add", "CHOWN", @@ -163,10 +164,30 @@ def find_docker() -> Optional[str]: "--cap-add", "SETGID", "--security-opt", "no-new-privileges", "--pids-limit", "256", - "--tmpfs", "/tmp:rw,nosuid,size=512m", - "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", - "--tmpfs", "/run:rw,noexec,nosuid,size=64m", ] +_DEFAULT_TMPFS_SIZE = "512m" +_DEFAULT_VAR_TMPFS_SIZE = "256m" +_DEFAULT_RUN_TMPFS_SIZE = "64m" + + +def _build_security_args( + tmpfs_tmp_size: str | None = None, + tmpfs_var_tmp_size: str | None = None, + tmpfs_run_size: str | None = None, +) -> list[str]: + """Build the security-related tmpfs args, with configurable sizes.""" + tmp_size = tmpfs_tmp_size or _DEFAULT_TMPFS_SIZE + var_tmp_size = tmpfs_var_tmp_size or _DEFAULT_VAR_TMPFS_SIZE + run_size = tmpfs_run_size or _DEFAULT_RUN_TMPFS_SIZE + return list(_SECURITY_ARGS_BASE) + [ + "--tmpfs", f"/tmp:rw,nosuid,size={tmp_size}", + "--tmpfs", f"/var/tmp:rw,noexec,nosuid,size={var_tmp_size}", + "--tmpfs", f"/run:rw,noexec,nosuid,size={run_size}", + ] + + +# Legacy constant for backwards compatibility with existing code/tests +_SECURITY_ARGS = _build_security_args() _storage_opt_ok: Optional[bool] = None # cached result across instances @@ -266,6 +287,10 @@ def __init__( network: bool = True, host_cwd: str = None, auto_mount_cwd: bool = False, + # Configurable tmpfs sizes (in MB or with k/m/g suffix, e.g. "512m", "1g") + tmpfs_tmp_size: str | None = None, + tmpfs_var_tmp_size: str | None = None, + tmpfs_run_size: str | None = None, ): if cwd == "~": cwd = "/root" @@ -275,6 +300,9 @@ def __init__( self._forward_env = _normalize_forward_env_names(forward_env) self._env = _normalize_env_dict(env) self._container_id: Optional[str] = None + self._tmpfs_tmp_size = tmpfs_tmp_size + self._tmpfs_var_tmp_size = tmpfs_var_tmp_size + self._tmpfs_run_size = tmpfs_run_size logger.info(f"DockerEnvironment volumes: {volumes}") # Ensure volumes is a list (config.yaml could be malformed) if volumes is not None and not isinstance(volumes, list): @@ -422,7 +450,10 @@ def __init__( env_args.extend(["-e", f"{key}={self._env[key]}"]) logger.info(f"Docker volume_args: {volume_args}") - all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args + env_args + all_run_args = ( + _build_security_args(self._tmpfs_tmp_size, self._tmpfs_var_tmp_size, self._tmpfs_run_size) + + writable_args + resource_args + volume_args + env_args + ) logger.info(f"Docker run_args: {all_run_args}") # Resolve the docker executable once so it works even when diff --git a/tools/environments/local.py b/tools/environments/local.py index 4aa6b64e2df0..037918422310 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -155,7 +155,14 @@ def _find_bash() -> str: found = shutil.which("bash") if found: - return found + _norm = found.lower().replace("/", "\\") + # Ignore known Windows shims that are not real Git Bash executables. + if ( + "windowsapps" not in _norm + and not _norm.endswith("\\system32\\bash.exe") + and not _norm.endswith("\\sysnative\\bash.exe") + ): + return found for candidate in ( os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"), diff --git a/tools/file_operations.py b/tools/file_operations.py index 9e0b44c145cb..ae97fe67591d 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -440,9 +440,13 @@ def _expand_path(self, path: str) -> str: if not path: return path - # Handle ~ and ~user + # Use Python expansion first (cross-platform, shell-independent). + expanded = os.path.expanduser(path) + if expanded != path: + return expanded + + # Fallback for environments where ~ expansion may be delegated to shell. if path.startswith('~'): - # Get home directory via the terminal environment result = self._exec("echo $HOME") if result.exit_code == 0 and result.stdout.strip(): home = result.stdout.strip() @@ -504,17 +508,13 @@ def read_file(self, path: str, offset: int = 1, limit: int = 500) -> ReadResult: offset, limit = normalize_read_pagination(offset, limit) - # Check if file exists and get size (wc -c is POSIX, works on Linux + macOS) - stat_cmd = f"wc -c < {self._escape_shell_arg(path)} 2>/dev/null" - stat_result = self._exec(stat_cmd) - - if stat_result.exit_code != 0: - # File not found - try to suggest similar files + # Cross-platform existence + size check (works on Windows too). + if not os.path.exists(path): return self._suggest_similar_files(path) - + try: - file_size = int(stat_result.stdout.strip()) - except ValueError: + file_size = os.path.getsize(path) + except OSError: file_size = 0 # Check if file is too large @@ -535,32 +535,34 @@ def read_file(self, path: str, offset: int = 1, limit: int = 500) -> ReadResult: ) # Read a sample to check for binary content - sample_cmd = f"head -c 1000 {self._escape_shell_arg(path)} 2>/dev/null" - sample_result = self._exec(sample_cmd) - - if self._is_likely_binary(path, sample_result.stdout): + try: + with open(path, "rb") as fh: + sample_bytes = fh.read(1000) + sample_text = sample_bytes.decode("utf-8", errors="ignore") + except OSError as exc: + return ReadResult(error=f"Failed to read file sample: {exc}") + + if self._is_likely_binary(path, sample_text): return ReadResult( is_binary=True, file_size=file_size, error="Binary file - cannot display as text. Use appropriate tools to handle this file type." ) - # Read with pagination using sed + # Read with pagination (cross-platform Python implementation) end_line = offset + limit - 1 - read_cmd = f"sed -n '{offset},{end_line}p' {self._escape_shell_arg(path)}" - read_result = self._exec(read_cmd) - - if read_result.exit_code != 0: - return ReadResult(error=f"Failed to read file: {read_result.stdout}") - - # Get total line count - wc_cmd = f"wc -l < {self._escape_shell_arg(path)}" - wc_result = self._exec(wc_cmd) try: - total_lines = int(wc_result.stdout.strip()) - except ValueError: - total_lines = 0 - + with open(path, "r", encoding="utf-8", errors="replace") as fh: + all_lines = fh.read().splitlines() + except OSError as exc: + return ReadResult(error=f"Failed to read file: {exc}") + + total_lines = len(all_lines) + selected = all_lines[offset - 1:end_line] + read_content = "\n".join(selected) + if selected: + read_content += "\n" + # Check if truncated truncated = total_lines > end_line hint = None @@ -568,7 +570,7 @@ def read_file(self, path: str, offset: int = 1, limit: int = 500) -> ReadResult: hint = f"Use offset={end_line + 1} to continue reading (showing {offset}-{end_line} of {total_lines} lines)" return ReadResult( - content=self._add_line_numbers(read_result.stdout, offset), + content=self._add_line_numbers(read_content, offset), total_lines=total_lines, file_size=file_size, truncated=truncated, @@ -583,41 +585,42 @@ def _suggest_similar_files(self, path: str) -> ReadResult: ext = os.path.splitext(filename)[1].lower() lower_name = filename.lower() - # List files in the target directory - ls_cmd = f"ls -1 {self._escape_shell_arg(dir_path)} 2>/dev/null | head -50" - ls_result = self._exec(ls_cmd) - + # List files in the target directory (cross-platform) scored: list = [] # (score, filepath) — higher is better - if ls_result.exit_code == 0 and ls_result.stdout.strip(): - for f in ls_result.stdout.strip().split('\n'): - if not f: - continue - lf = f.lower() - score = 0 - - # Exact match (shouldn't happen, but guard) - if lf == lower_name: - score = 100 - # Same base name, different extension (e.g. config.yml vs config.yaml) - elif os.path.splitext(f)[0].lower() == basename_no_ext.lower(): - score = 90 - # Target is prefix of candidate or vice-versa - elif lf.startswith(lower_name) or lower_name.startswith(lf): - score = 70 - # Substring match (candidate contains query) - elif lower_name in lf: - score = 60 - # Reverse substring (query contains candidate name) - elif lf in lower_name and len(lf) > 2: - score = 40 - # Same extension with some overlap - elif ext and os.path.splitext(f)[1].lower() == ext: - common = set(lower_name) & set(lf) - if len(common) >= max(len(lower_name), len(lf)) * 0.4: - score = 30 - - if score > 0: - scored.append((score, os.path.join(dir_path, f))) + try: + entries = os.listdir(dir_path) + except OSError: + entries = [] + + for f in entries[:200]: + if not f: + continue + lf = f.lower() + score = 0 + + # Exact match (shouldn't happen, but guard) + if lf == lower_name: + score = 100 + # Same base name, different extension (e.g. config.yml vs config.yaml) + elif os.path.splitext(f)[0].lower() == basename_no_ext.lower(): + score = 90 + # Target is prefix of candidate or vice-versa + elif lf.startswith(lower_name) or lower_name.startswith(lf): + score = 70 + # Substring match (candidate contains query) + elif lower_name in lf: + score = 60 + # Reverse substring (query contains candidate name) + elif lf in lower_name and len(lf) > 2: + score = 40 + # Same extension with some overlap + elif ext and os.path.splitext(f)[1].lower() == ext: + common = set(lower_name) & set(lf) + if len(common) >= max(len(lower_name), len(lf)) * 0.4: + score = 30 + + if score > 0: + scored.append((score, os.path.join(dir_path, f))) scored.sort(key=lambda x: -x[0]) similar = [fp for _, fp in scored[:5]] @@ -1165,9 +1168,9 @@ def _search_with_grep(self, pattern: str, path: str, file_glob: Optional[str], """Fallback search using grep.""" cmd_parts = ["grep", "-rnH"] # -H forces filename even for single-file searches - # Exclude hidden directories (matching ripgrep's default behavior). - # This prevents searching inside .hub/index-cache/, .git/, etc. - cmd_parts.append("--exclude-dir='.*'") + # Exclude hidden subdirectories under the search root (but not the + # root path itself, which may legitimately be hidden like ~/.hermes). + cmd_parts.append("--exclude-dir='*/.*'") # Add context if requested if context > 0: diff --git a/tools/file_tools.py b/tools/file_tools.py index 38801362e94d..70f37e693d24 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -5,8 +5,9 @@ import json import logging import os +import re import threading -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Optional from agent.file_safety import get_read_block_error @@ -119,7 +120,17 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None: def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path: """Resolve *filepath* against the task's live terminal cwd when possible.""" - p = Path(filepath).expanduser() + # Normalize quoted Windows-style paths copied from chats (e.g. "C:\\foo\\bar") + # before Path() resolution. + _raw = (filepath or "").strip().strip('"').strip("'") + + # On non-Windows runtimes, pathlib treats "D:\\foo" as relative text. + # Detect Windows drive paths explicitly so read_file/write_file work for + # cross-platform gateway sessions where the target machine is Windows. + if re.match(r"^[A-Za-z]:[\\/]", _raw): + return Path(PureWindowsPath(_raw)) + + p = Path(_raw).expanduser() if not p.is_absolute(): base = _get_live_tracking_cwd(task_id) or os.environ.get( "TERMINAL_CWD", os.getcwd() diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e02219d7bcbe..db7db237e708 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2031,9 +2031,11 @@ def _call_once(): "MCP tool %s/%s call failed: %s", server_name, tool_name, exc, ) + # Handle exceptions with empty message (e.g., ClosedResourceError) + exc_str = str(exc).strip() if str(exc) else repr(exc) return json.dumps({ "error": _sanitize_error( - f"MCP call failed: {type(exc).__name__}: {exc}" + f"MCP call failed: {type(exc).__name__}: {exc_str}" ) }, ensure_ascii=False) @@ -2090,7 +2092,7 @@ def _call_once(): ) return json.dumps({ "error": _sanitize_error( - f"MCP call failed: {type(exc).__name__}: {exc}" + f"MCP call failed: {type(exc).__name__}: {(str(exc).strip() if str(exc) else repr(exc))}" ) }, ensure_ascii=False) @@ -2149,7 +2151,7 @@ def _call_once(): ) return json.dumps({ "error": _sanitize_error( - f"MCP call failed: {type(exc).__name__}: {exc}" + f"MCP call failed: {type(exc).__name__}: {(str(exc).strip() if str(exc) else repr(exc))}" ) }, ensure_ascii=False) @@ -2211,7 +2213,7 @@ def _call_once(): ) return json.dumps({ "error": _sanitize_error( - f"MCP call failed: {type(exc).__name__}: {exc}" + f"MCP call failed: {type(exc).__name__}: {(str(exc).strip() if str(exc) else repr(exc))}" ) }, ensure_ascii=False) @@ -2281,7 +2283,7 @@ def _call_once(): ) return json.dumps({ "error": _sanitize_error( - f"MCP call failed: {type(exc).__name__}: {exc}" + f"MCP call failed: {type(exc).__name__}: {(str(exc).strip() if str(exc) else repr(exc))}" ) }, ensure_ascii=False) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index a2321c2e501d..e71fcee4acc6 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -350,6 +350,12 @@ def _parse_target_ref(platform_name: str, target_ref: str): if target_ref.strip().isdigit(): return f"group:{target_ref.strip()}", None, True return None, None, False + if platform_name == "whatsapp": + _trimmed = target_ref.strip() + # WhatsApp group/user JIDs (e.g., 12345@g.us, 12345@s.whatsapp.net) + # are explicit chat targets and must not fall back to home channel. + if "@" in _trimmed and "/" not in _trimmed: + return _trimmed, None, True if platform_name in _PHONE_PLATFORMS: match = _E164_TARGET_RE.fullmatch(target_ref) if match: @@ -556,11 +562,15 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Slack: use the native adapter helper when media is present --- + if platform == Platform.SLACK and media_files: + return await _send_slack_via_adapter(pconfig, chat_id, message, media_files, thread_id=thread_id) + # --- Non-media platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal and yuanbao; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, slack and yuanbao; " f"target {platform.value} had only media attachments" ) } @@ -568,7 +578,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal and yuanbao" + "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, slack and yuanbao" ) last_result = None @@ -1289,6 +1299,66 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, pass +async def _send_slack_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): + """Send via the Slack adapter so native Slack file uploads are preserved.""" + try: + from gateway.platforms.slack import SlackAdapter + except ImportError: + return {"error": "SlackAdapter not available"} + + media_files = media_files or [] + + try: + adapter = SlackAdapter(pconfig) + connected = await adapter.connect() + if not connected: + return _error("Slack connect failed") + + metadata = {"thread_ts": thread_id} if thread_id else None + last_result = None + + if message.strip(): + last_result = await adapter.send(chat_id, message, metadata=metadata) + if not last_result.success: + return _error(f"Slack send failed: {last_result.error}") + + for media_path, is_voice in media_files: + if not os.path.exists(media_path): + return _error(f"Media file not found: {media_path}") + + ext = os.path.splitext(media_path)[1].lower() + if ext in _IMAGE_EXTS: + last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata) + elif ext in _VIDEO_EXTS: + last_result = await adapter.send_video(chat_id, media_path, metadata=metadata) + elif ext in _VOICE_EXTS and is_voice: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + elif ext in _AUDIO_EXTS: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + else: + last_result = await adapter.send_document(chat_id, media_path, metadata=metadata) + + if not last_result.success: + return _error(f"Slack media send failed: {last_result.error}") + + if last_result is None: + return {"error": "No deliverable text or media remained after processing MEDIA tags"} + + return { + "success": True, + "platform": "slack", + "chat_id": chat_id, + "message_id": getattr(last_result, "message_id", None), + } + except Exception as e: + return _error(f"Slack send failed: {e}") + finally: + try: + await adapter.disconnect() + except Exception: + pass + + async def _send_homeassistant(token, extra, chat_id, message): """Send via Home Assistant notify service.""" try: diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index ff3153afafa2..7eab96d22c94 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -209,7 +209,7 @@ async def _summarize_session( "that would be useful to recall. Write in past tense as a factual recap." ) - source = session_meta.get("source", "unknown") + source = session_meta.get("platform") or session_meta.get("source", "unknown") started = _format_timestamp(session_meta.get("started_at")) user_prompt = ( @@ -295,7 +295,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str results.append({ "session_id": sid, "title": s.get("title") or None, - "source": s.get("source", ""), + "source": s.get("platform") or s.get("source", ""), "started_at": s.get("started_at", ""), "last_active": s.get("last_active", ""), "message_count": s.get("message_count", 0), diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index c28f421a7f98..0488ab452184 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -42,6 +42,8 @@ from hermes_constants import get_hermes_home, display_hermes_home from typing import Dict, Any, Optional, Tuple +from utils import is_truthy_value + logger = logging.getLogger(__name__) # Import security scanner — external hub installs always get scanned; @@ -64,7 +66,10 @@ def _guard_agent_created_enabled() -> bool: try: from hermes_cli.config import load_config cfg = load_config() - return bool(cfg.get("skills", {}).get("guard_agent_created", False)) + return is_truthy_value( + (cfg.get("skills") or {}).get("guard_agent_created"), + default=False, + ) except Exception: return False @@ -96,6 +101,7 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]: return None import yaml +import hermes_cli.config # Ensure hermes_cli.config is importable for patching/tests. # All skills live in ~/.hermes/skills/ (single source of truth) @@ -106,16 +112,51 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]: MAX_DESCRIPTION_LENGTH = 1024 -def _is_local_skill(skill_path: Path) -> bool: - """Check if a skill path is within the local SKILLS_DIR. +def _containing_skills_root(skill_path: Path) -> Path: + """Return the skills root directory (local or external_dirs entry) that + contains ``skill_path``. Falls back to the local ``SKILLS_DIR`` if no + match is found (defensive — callers should have located the skill via + ``_find_skill`` first). + """ + from agent.skill_utils import get_all_skills_dirs + + try: + resolved = skill_path.resolve() + except OSError: + resolved = skill_path + + for root in get_all_skills_dirs(): + try: + resolved.relative_to(root.resolve()) + return root + except (ValueError, OSError): + continue + return SKILLS_DIR + + +def _pinned_guard(name: str, action: str = "modify") -> Optional[str]: + """Return a refusal message when a pinned skill is being deleted. + + Pinned skills are protected from deletion/removal operations, but they can + still be patched/edited so maintenance does not require unpin/re-pin cycles. - Skills found in external_dirs are read-only from the agent's perspective. + Best-effort: if the sidecar is unreadable we let the write through + rather than block on a broken telemetry file. """ try: - skill_path.resolve().relative_to(SKILLS_DIR.resolve()) - return True - except ValueError: - return False + from tools import skill_usage + rec = skill_usage.get_record(name) + if rec.get("pinned") and action in {"delete", "remove_file"}: + return ( + f"Skill '{name}' is pinned and cannot be deleted by " + f"skill_manage. Ask the user to run " + f"`hermes curator unpin {name}` if they want the deletion." + ) + except Exception: + logger.debug("pinned-guard lookup failed for %s", name, exc_info=True) + return None + + MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file @@ -394,8 +435,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + pinned_err = _pinned_guard(name) + if pinned_err: + return {"success": False, "error": pinned_err} skill_md = existing["path"] / "SKILL.md" # Back up original content for rollback @@ -437,8 +479,9 @@ def _patch_skill( if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + pinned_err = _pinned_guard(name) + if pinned_err: + return {"success": False, "error": pinned_err} skill_dir = existing["path"] @@ -519,15 +562,17 @@ def _delete_skill(name: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be deleted."} + pinned_err = _pinned_guard(name, action="delete") + if pinned_err: + return {"success": False, "error": pinned_err} skill_dir = existing["path"] + skills_root = _containing_skills_root(skill_dir) shutil.rmtree(skill_dir) - # Clean up empty category directories (don't remove SKILLS_DIR itself) + # Clean up empty category directories (don't remove the skills root itself) parent = skill_dir.parent - if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()): + if parent != skills_root and parent.exists() and not any(parent.iterdir()): parent.rmdir() return { @@ -564,8 +609,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + pinned_err = _pinned_guard(name) + if pinned_err: + return {"success": False, "error": pinned_err} target, err = _resolve_skill_target(existing["path"], file_path) if err: @@ -601,8 +647,9 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified."} + pinned_err = _pinned_guard(name, action="remove_file") + if pinned_err: + return {"success": False, "error": pinned_err} skill_dir = existing["path"] @@ -698,6 +745,17 @@ def skill_manage( clear_skills_system_prompt_cache(clear_snapshot=True) except Exception: pass + # Curator telemetry: bump patch_count on edit/patch/write_file (the actions + # that mutate an existing skill's guidance), drop the record on delete. + # Best-effort; telemetry failures never break the tool. + try: + from tools.skill_usage import bump_patch, forget + if action in ("patch", "edit", "write_file", "remove_file"): + bump_patch(name) + elif action == "delete": + forget(name) + except Exception: + pass return json.dumps(result, ensure_ascii=False) @@ -725,7 +783,10 @@ def skill_manage( "After difficult/iterative tasks, offer to save as a skill. " "Skip for simple one-offs. Confirm with user before creating/deleting.\n\n" "Good skills: trigger conditions, numbered steps with exact commands, " - "pitfalls section, verification steps. Use skill_view() to see format examples." + "pitfalls section, verification steps. Use skill_view() to see format examples.\n\n" + "Pinned skills are deletion-protected — delete/remove_file actions refuse " + "with a message pointing the user to `hermes curator unpin `. " + "Edits/patches are allowed so pinned skills can still be maintained." ), "parameters": { "type": "object", diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 89fe698a76db..fee81bd0d55c 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -960,7 +960,7 @@ def skill_view( skill_md = direct_path.with_suffix(".md") break - # Search by directory name across all dirs + # Search by directory name (canonical) and frontmatter name (display name) if not skill_md: for search_dir in all_dirs: from agent.skill_utils import iter_skill_index_files @@ -970,6 +970,18 @@ def skill_view( skill_dir = found_skill_md.parent skill_md = found_skill_md break + + # Also allow the frontmatter `name` shown by skills_list. + try: + found_content = found_skill_md.read_text(encoding="utf-8") + found_frontmatter, _ = _parse_frontmatter(found_content) + listed_name = str(found_frontmatter.get("name") or "").strip() + if listed_name and listed_name == name: + skill_dir = found_skill_md.parent + skill_md = found_skill_md + break + except Exception: + continue if skill_md: break diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 105c5aa85878..110c59565129 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -963,6 +963,9 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, volumes = cc.get("docker_volumes", []) docker_forward_env = cc.get("docker_forward_env", []) docker_env = cc.get("docker_env", {}) + tmpfs_tmp_size = cc.get("container_tempfs_tmp_size", None) + tmpfs_var_tmp_size = cc.get("container_tempfs_var_tmp_size", None) + tmpfs_run_size = cc.get("container_tempfs_run_size", None) if env_type == "local": return _LocalEnvironment(cwd=cwd, timeout=timeout) @@ -977,6 +980,9 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False), forward_env=docker_forward_env, env=docker_env, + tmpfs_tmp_size=tmpfs_tmp_size, + tmpfs_var_tmp_size=tmpfs_var_tmp_size, + tmpfs_run_size=tmpfs_run_size, ) elif env_type == "singularity": diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index b1c5b7600c7d..12d12fef036f 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -101,11 +101,21 @@ def resolve_modal_backend_state( def resolve_openai_audio_api_key() -> str: - """Prefer the voice-tools key, but fall back to the normal OpenAI key.""" - return ( - os.getenv("VOICE_TOOLS_OPENAI_KEY", "") - or os.getenv("OPENAI_API_KEY", "") - ).strip() + """Prefer the voice-tools key, but fall back to the normal OpenAI key. + + Consults os.environ first (covers direct hermes launches where dotenv + may not yet be loaded), then falls back to get_env_value for CLI paths + that read from ~/.hermes/.env (see issue #17140). + """ + value = os.getenv("VOICE_TOOLS_OPENAI_KEY") or os.getenv("OPENAI_API_KEY") or "" + if value: + return value.strip() + # CLI paths: .env may not be loaded into os.environ yet + try: + from hermes_cli.config import get_env_value + return (get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY") or "").strip() + except Exception: + return "" def prefers_gateway(config_section: str) -> bool: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index a7ca57fab104..d32e292b6002 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -208,7 +208,7 @@ def _load_tts_config() -> Dict[str, Any]: for any missing fields. """ try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config, get_env_value config = load_config() return config.get("tts", {}) except ImportError: @@ -312,7 +312,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] Returns: Path to the saved audio file. """ - api_key = os.getenv("ELEVENLABS_API_KEY", "") + api_key = get_env_value("ELEVENLABS_API_KEY") or "" if not api_key: raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/") @@ -406,7 +406,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - """ import requests - api_key = os.getenv("XAI_API_KEY", "").strip() + api_key = get_env_value("XAI_API_KEY") or "" if not api_key: raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/") @@ -479,7 +479,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any """ import requests - api_key = os.getenv("MINIMAX_API_KEY", "") + api_key = get_env_value("MINIMAX_API_KEY") or "" if not api_key: raise ValueError("MINIMAX_API_KEY not set. Get one at https://platform.minimax.io/") @@ -556,7 +556,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any and writes the raw bytes to *output_path*. Supports native Opus output for Telegram voice bubbles. """ - api_key = os.getenv("MISTRAL_API_KEY", "") + api_key = get_env_value("MISTRAL_API_KEY") or "" if not api_key: raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/") @@ -651,7 +651,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] """ import requests - api_key = (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip() + api_key = get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY") or "" if not api_key: raise ValueError( "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey" @@ -1148,28 +1148,39 @@ def check_tts_requirements() -> bool: pass try: _import_elevenlabs() - if os.getenv("ELEVENLABS_API_KEY"): - return True except ImportError: pass + else: + # Use get_env_value so .env file keys are also found + try: + from hermes_cli.config import get_env_value as _get_env_val + except ImportError: + _get_env_val = os.getenv + if _get_env_val("ELEVENLABS_API_KEY"): + return True try: _import_openai_client() if _has_openai_audio_backend(): return True except ImportError: pass - if os.getenv("MINIMAX_API_KEY"): + try: + from hermes_cli.config import get_env_value as _get_env_val + except ImportError: + _get_env_val = os.getenv + if _get_env_val("MINIMAX_API_KEY"): return True - if os.getenv("XAI_API_KEY"): + if _get_env_val("XAI_API_KEY"): return True - if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"): + if _get_env_val("GEMINI_API_KEY") or _get_env_val("GOOGLE_API_KEY"): return True try: _import_mistral_client() - if os.getenv("MISTRAL_API_KEY"): - return True except ImportError: pass + else: + if _get_env_val("MISTRAL_API_KEY"): + return True if _check_neutts_available(): return True if _check_kittentts_available(): @@ -1278,7 +1289,7 @@ def stream_tts_to_speaker( {**tts_config, "elevenlabs": {**el_config, "model_id": model_id}}, ) - api_key = os.getenv("ELEVENLABS_API_KEY", "") + api_key = get_env_value("ELEVENLABS_API_KEY") or "" if not api_key: logger.warning("ELEVENLABS_API_KEY not set; streaming TTS audio disabled") else: @@ -1464,13 +1475,13 @@ def _check(importer, label): print("\nProvider availability:") print(f" Edge TTS: {'installed' if _check(_import_edge_tts, 'edge') else 'not installed (pip install edge-tts)'}") print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}") - print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}") + print(f" API Key: {'set' if get_env_value('ELEVENLABS_API_KEY') else 'not set'}") print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}") print( " API Key: " f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}" ) - print(f" MiniMax: {'API key set' if os.getenv('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}") + print(f" MiniMax: {'API key set' if get_env_value('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}") print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}") print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}") diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 66ecb242c672..4a1aca4a2d93 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -40,13 +40,22 @@ def _import_audio(): return sd, np -def _audio_available() -> bool: - """Return True if audio libraries can be imported.""" +def _audio_import_status() -> tuple[bool, str | None]: + """Return (available, reason) for local audio capture imports.""" try: _import_audio() - return True - except (ImportError, OSError): - return False + return True, None + except ImportError: + return False, "missing_python_packages" + except OSError: + # sounddevice installed but PortAudio shared lib missing/misconfigured. + return False, "missing_portaudio_runtime" + + +def _audio_available() -> bool: + """Return True if audio libraries can be imported.""" + ok, _ = _audio_import_status() + return ok from hermes_constants import is_termux as _is_termux_environment @@ -54,8 +63,8 @@ def _audio_available() -> bool: 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" + return "pkg install python-numpy portaudio && uv sync --extra voice (or python -m pip install sounddevice)" + return "uv sync --extra voice" def _termux_microphone_command() -> Optional[str]: @@ -937,9 +946,10 @@ def check_voice_requirements() -> Dict[str, Any]: missing: List[str] = [] termux_capture = _termux_voice_capture_available() - has_audio = _audio_available() or termux_capture + audio_ok, audio_reason = _audio_import_status() + has_audio = audio_ok or termux_capture - if not has_audio: + if not has_audio and audio_reason == "missing_python_packages": missing.extend(["sounddevice", "numpy"]) # Environment detection @@ -953,7 +963,10 @@ def check_voice_requirements() -> Dict[str, Any]: elif has_audio: details_parts.append("Audio capture: OK") else: - details_parts.append(f"Audio capture: MISSING ({_voice_capture_install_hint()})") + if audio_reason == "missing_portaudio_runtime": + details_parts.append("Audio capture: FAILED (PortAudio runtime not found)") + else: + 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)") diff --git a/toolsets.py b/toolsets.py index a444713f5760..288a8b09cd26 100644 --- a/toolsets.py +++ b/toolsets.py @@ -493,7 +493,20 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]: """ toolset = TOOLSETS.get(name) if toolset: - return toolset + # Built-in toolsets can be extended by plugins at runtime. Merge the + # static TOOLSETS definition with live registry membership so plugin + # tools targeting existing toolsets are not dropped. + try: + from tools.registry import registry + static_tools = list(toolset.get("tools", [])) + dynamic_tools = registry.get_tool_names_for_toolset(name) + merged_tools = list(dict.fromkeys([*static_tools, *dynamic_tools])) + return { + **toolset, + "tools": merged_tools, + } + except Exception: + return toolset try: from tools.registry import registry diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2a1456e41be4..39b3ec931d40 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,3 +1,4 @@ +import shlex import atexit import concurrent.futures import contextvars @@ -1727,6 +1728,7 @@ def _build() -> None: rid, { "session_id": sid, + "session_key": key, "info": { "model": _resolve_model(), "tools": {}, @@ -3551,8 +3553,8 @@ def _(rid, params: dict) -> dict: qc = qcmds[name] if qc.get("type") == "exec": r = subprocess.run( - qc.get("command", ""), - shell=True, + shlex.split(qc.get("command", "")), + shell=False, capture_output=True, text=True, timeout=30, @@ -5026,7 +5028,7 @@ def _(rid, params: dict) -> dict: pass try: r = subprocess.run( - cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd() + shlex.split(cmd), shell=False, capture_output=True, text=True, timeout=30, cwd=os.getcwd() ) return _ok( rid, diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 49a7fd7d67e8..af1f6e23e2cd 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -423,7 +423,7 @@ class TurnController { recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { this.closeReasoningSegment() - const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() + const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart() const split = splitReasoning(rawText) const finalText = finalTail(split.text, this.segmentMessages) const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 84978b98c40d..0c9b4af2f81c 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -67,9 +67,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (overlay.approval) { - return gateway - .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) - .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) + return terminal.selection.copySelectionNoClear().then((selected) => { + if ((selected ?? '').trim().length > 0) { + return + } + return gateway + .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) + .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) + }) } if (overlay.sudo) { diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index ccec8220049b..33f1d51bc3e6 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -145,7 +145,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { resetSession() setSessionStartedAt(Date.now()) - writeActiveSessionFile(r.session_id) + writeActiveSessionFile(r.session_key ?? r.session_id) patchUiState({ info, sid: r.session_id, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 605d51213f7c..9b3e1f48009b 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -96,6 +96,7 @@ export interface SetupStatusResponse { export interface SessionCreateResponse { info?: SessionInfo & { config_warning?: string; credential_warning?: string } session_id: string + session_key?: string } export interface SessionResumeResponse { diff --git a/utils.py b/utils.py index f3d38006d145..14627dcf8855 100644 --- a/utils.py +++ b/utils.py @@ -58,6 +58,15 @@ def _restore_file_mode(path: Path, mode: "int | None") -> None: pass +def atomic_replace(src: Union[str, Path], dst: Union[str, Path]) -> None: + """Atomically replace ``dst`` with ``src`` while preserving dst mode when present.""" + src_path = Path(src) + dst_path = Path(dst) + original_mode = _preserve_file_mode(dst_path) + os.replace(src_path, dst_path) + _restore_file_mode(dst_path, original_mode) + + def atomic_json_write( path: Union[str, Path], data: Any, diff --git a/uv.lock b/uv.lock index dfb2f786b07a..d68cf9c81545 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-17T16:49:45.944715922Z" +exclude-newer = "2026-04-22T20:36:54.382633607Z" exclude-newer-span = "P7D" [[package]] @@ -1759,6 +1759,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.194.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.73.0" @@ -1912,6 +1983,9 @@ all = [ { name = "elevenlabs" }, { name = "fastapi" }, { name = "faster-whisper" }, + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, { name = "honcho-ai" }, { name = "lark-oapi" }, { name = "markdown", marker = "sys_platform == 'linux'" }, @@ -1965,6 +2039,11 @@ feishu = [ { name = "lark-oapi" }, { name = "qrcode" }, ] +google = [ + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, +] homeassistant = [ { name = "aiohttp" }, ] @@ -2064,6 +2143,9 @@ requires-dist = [ { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, { name = "fire", specifier = ">=0.7.1,<1" }, { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, + { name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" }, + { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" }, + { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, @@ -2075,6 +2157,7 @@ requires-dist = [ { 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 = ["google"], 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'" }, @@ -2136,7 +2219,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?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, ] -provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "bedrock", "termux", "dingtalk", "feishu", "web", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "bedrock", "termux", "dingtalk", "feishu", "google", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -2238,6 +2321,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -3277,6 +3372,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "obstore" version = "0.8.2" @@ -3855,6 +3959,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, +] + [[package]] name = "protobuf" version = "6.33.5" @@ -3929,6 +4045,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -4529,6 +4666,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -5268,6 +5418,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" }, ] +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index f324edf160e8..967e7da33306 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -30,7 +30,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) | | `ZAI_API_KEY` | Alias for `GLM_API_KEY` | | `Z_AI_API_KEY` | Alias for `GLM_API_KEY` | -| `GLM_BASE_URL` | Override z.ai base URL (default: `https://api.z.ai/api/paas/v4`) | +| `GLM_BASE_URL` | Override z.ai base URL (default: `https://api.z.ai/api/anthropic`) | | `KIMI_API_KEY` | Kimi / Moonshot AI API key ([moonshot.ai](https://platform.moonshot.ai)) | | `KIMI_BASE_URL` | Override Kimi base URL (default: `https://api.moonshot.ai/v1`) | | `KIMI_CN_API_KEY` | Kimi / Moonshot China API key ([moonshot.cn](https://platform.moonshot.cn)) |