diff --git a/.env.example b/.env.example index e6763f18fd23..80e2286caec6 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,14 @@ # LLM_MODEL is no longer read from .env — this line is kept for reference only. # LLM_MODEL=anthropic/claude-opus-4.6 +# ============================================================================= +# LLM PROVIDER (NovitaAI) +# ============================================================================= +# NovitaAI — 90+ models, pay-per-use +# Get your key at: https://novita.ai/settings/key-management +# NOVITA_API_KEY= +# NOVITA_BASE_URL=https://api.novita.ai/openai/v1 # Override default base URL + # ============================================================================= # LLM PROVIDER (Google AI Studio / Gemini) # ============================================================================= @@ -273,6 +281,13 @@ BROWSER_SESSION_TIMEOUT=300 # Browser sessions are automatically closed after this period of no activity BROWSER_INACTIVITY_TIMEOUT=120 +# Extra Chromium launch flags passed to agent-browser, comma- or newline-separated. +# Hermes auto-injects "--no-sandbox,--disable-dev-shm-usage" when it detects root +# or AppArmor-restricted unprivileged user namespaces (Ubuntu 23.10+, DGX Spark, +# many container images), so leave this unset unless you need extra flags. +# Setting this disables the auto-injection. +# AGENT_BROWSER_ARGS=--no-sandbox + # Camofox local anti-detection browser (Camoufox-based Firefox). # Set CAMOFOX_URL to route the browser tools through a local Camofox server # instead of agent-browser/Browserbase. See docs/user-guide/features/browser.md. diff --git a/AGENTS.md b/AGENTS.md index d8ba934c5219..da9f903eefb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -513,6 +513,17 @@ generic plugin surface (new hook, new ctx method) — never hardcode plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded honcho argparse from `main.py` for exactly this reason. +**No new in-tree memory providers (policy, May 2026):** the set of +built-in memory providers under `plugins/memory/` is closed. New memory +backends must ship as **standalone plugin repos** that users install +into `~/.hermes/plugins/` (or via pip entry points) — they implement +the same `MemoryProvider` ABC, register through the same discovery +path, and integrate via `hermes memory setup` / `post_setup()` without +landing in this tree. PRs that add a new directory under +`plugins/memory/` will be closed with a pointer to publish the +provider as its own repo. Existing in-tree providers stay; bug fixes +to them are welcome. + ### Model-provider plugins (`plugins/model-providers//`) Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …) @@ -580,6 +591,86 @@ during setup, injected at load time). Top-level `tags:` and `category:` are also accepted and mirrored from `metadata.hermes.*` by the loader. +### Skill authoring standards (HARDLINE) + +Every new or modernized skill — bundled, optional, or contributed — +must meet these standards before merge. Reviewers reject PRs that +violate them. + +1. **`description` ≤ 60 characters, one sentence, ends with a period.** + Long descriptions bloat skill listings and dilute the model's + attention when many skills are loaded. State the capability, not + the implementation. No marketing words ("powerful", + "comprehensive", "seamless", "advanced"). Don't repeat the skill + name. Verify with: + ```python + import re, pathlib + m = re.search(r'^description: (.*)$', + pathlib.Path('skills///SKILL.md').read_text(), + re.MULTILINE) + assert len(m.group(1)) <= 60, len(m.group(1)) + ``` + +2. **Tools referenced in SKILL.md prose must be native Hermes tools or + MCP servers the skill explicitly expects.** When the skill needs a + capability, point at the proper tool by name in backticks + (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``, + `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``, + `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT + name shell utilities the agent already has wrapped — `grep` → + `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` → + `patch`, `find`/`ls` → `search_files target='files'`. If the skill + depends on an MCP server, name the MCP server and document the + expected setup in `## Prerequisites`. Anything else (third-party + CLIs, shell pipelines, etc.) is fair game inside script files but + should not be the headline interaction surface in the prose. + +3. **`platforms:` gating audited against actual script imports.** + Skills that use POSIX-only primitives (`fcntl`, `termios`, + `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp` + hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`, + `systemctl`) must declare their supported platforms. Default + posture: try to fix it cross-platform first — `tempfile.gettempdir`, + `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead + of `grep`. Gate to a narrower set only when the dependency is + genuinely platform-bound. + +4. **`author` credits the human contributor first.** For external + contributions, the contributor's real name + GitHub handle goes + first; "Hermes Agent" is the secondary collaborator. If the + contributor's commit shows "Hermes Agent" as author (because they + used Hermes to draft the skill), replace it with their actual name + — credit the human, not the tool. + +5. **SKILL.md body uses the modern section order.** `# Skill` + title, 2-3 sentence intro stating what it does and doesn't do, + `## When to Use`, `## Prerequisites`, `## How to Run`, + `## Quick Reference`, `## Procedure`, `## Pitfalls`, + `## Verification`. Target ~200 lines for a complex skill, + ~100 lines for a simple one. Cut redundant intro fluff, marketing + prose, and re-explanations of env vars already in + `## Prerequisites`. + +6. **Scripts go in `scripts/`, references in `references/`, + templates in `templates/`.** Don't expect the model to inline-write + parsers, XML walkers, or non-trivial logic every call — ship a + helper script. Reference it from SKILL.md by path relative to the + skill directory. + +7. **Tests live at `tests/skills/test__skill.py`** and use only + stdlib + pytest + `unittest.mock`. No live network calls. Run via + `scripts/run_tests.sh tests/skills/test__skill.py -q`. + +8. **`.env.example` additions are isolated to a clearly delimited + block.** Don't touch the surrounding file — contributor-supplied + `.env.example` versions are usually stale and edits outside the + skill's own block must be dropped during salvage. + +The full salvage / modernization checklist for external skill PRs +lives in the `hermes-agent-dev` skill at +`references/new-skill-pr-salvage.md` — load it before polishing +contributor skill PRs. + --- ## Toolsets diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56f0c8ff0169..4bbc3c67c70b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,24 @@ If your skill is specialized, community-contributed, or niche, it's better suite --- +## Memory Providers: Ship as a Standalone Plugin + +**We are no longer accepting new memory providers into this repo.** The set of built-in providers under `plugins/memory/` (honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb) is closed. If you want to add a new memory backend, publish it as a **standalone plugin repo** that users install into `~/.hermes/plugins/` (or via a pip entry point). + +Standalone memory plugins: + +- Implement the same `MemoryProvider` ABC (`agent/memory_provider.py`) — `sync_turn`, `prefetch`, `shutdown`, and optionally `post_setup(hermes_home, config)` for setup-wizard integration +- Use the same discovery system — `discover_memory_providers()` picks them up from user/project plugin directories and pip entry points +- Integrate with `hermes memory setup` via `post_setup()` — no need to touch core code +- Can register their own CLI subcommands via `register_cli(subparser)` in a `cli.py` file +- Get all the same lifecycle hooks and config plumbing as in-tree providers + +PRs that add a new directory under `plugins/memory/` will be closed with a pointer to publish the provider as its own repo. Existing in-tree providers stay; bug fixes to them are welcome. + +This isn't a quality bar — it's a coupling-and-maintenance decision. Memory providers are the most common plugin type and they shouldn't all live in this tree. + +--- + ## Development Setup ### Prerequisites @@ -461,6 +479,58 @@ Gateway and messaging sessions never collect secrets in-band; they instruct the See `skills/gifs/gif-search/` and `skills/email/himalaya/` for examples. +### Skill authoring standards (HARDLINE) + +Every new or modernized skill — bundled, optional, or contributed — must meet these standards before merge. Reviewers reject PRs that violate them. + +1. **`description` ≤ 60 characters, one sentence, ends with a period.** Long descriptions bloat the skill listing UI and dilute the model's attention when many skills are loaded. State the capability, not the implementation. No marketing words ("powerful", "comprehensive", "seamless", "advanced"). Don't repeat the skill name. Verify with: + ```python + import re, pathlib + m = re.search(r'^description: (.*)$', + pathlib.Path('skills///SKILL.md').read_text(), + re.MULTILINE) + assert len(m.group(1)) <= 60, len(m.group(1)) + ``` + + Good: `Search arXiv papers by keyword, author, category, or ID.` + Bad: `A powerful and comprehensive skill that allows the agent to search arXiv for relevant academic papers using various criteria including keywords, authors, and categories.` + +2. **Tools referenced in SKILL.md prose must be native Hermes tools or MCP servers the skill explicitly expects.** When the skill needs a capability, point at the proper tool by name in backticks: `` `terminal` ``, `` `web_extract` ``, `` `web_search` ``, `` `read_file` ``, `` `write_file` ``, `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``, `` `browser_navigate` ``, `` `delegate_task` ``, `` `image_generate` ``, `` `text_to_speech` ``, `` `cronjob` ``, `` `memory` ``, `` `skill_view` ``, `` `todo` ``, `` `execute_code` ``. + + Do NOT name shell utilities the agent already has wrapped: + + | Don't say | Say | + |---|---| + | `grep`, `rg` | `search_files` | + | `cat`, `head`, `tail` | `read_file` | + | `sed`, `awk` | `patch` | + | `find`, `ls` | `search_files` (with `target='files'`) | + | `curl` for content extraction | `web_extract` | + | `echo > file`, `cat < Skill` title, 2-3 sentence intro stating what it does and what it doesn't do, then: + - `## When to Use` — trigger conditions + - `## Prerequisites` — env vars, install steps, MCP setup, API key sourcing + - `## How to Run` — canonical invocation through the `terminal` tool + - `## Quick Reference` — flat command/API reference + - `## Procedure` — numbered steps with copy-paste commands + - `## Pitfalls` — known limits, rate limits, things that look broken but aren't + - `## Verification` — single command that proves the skill works + + Target ~200 lines for a complex skill, ~100 lines for a simple one. Cut redundant intro fluff, marketing prose, and re-explanations of env vars already documented in `## Prerequisites`. + +6. **Scripts go in `scripts/`, references in `references/`, templates in `templates/`.** Don't expect the model to inline-write parsers, XML walkers, or non-trivial logic every call — ship a helper script. Reference scripts from SKILL.md by path relative to the skill directory. + +7. **Tests live at `tests/skills/test__skill.py`** and use only stdlib + pytest + `unittest.mock`. No live network calls. Run via `scripts/run_tests.sh tests/skills/test__skill.py -q`. Must pass under the hermetic CI env (no API keys leaking through). Use `monkeypatch` and `tmp_path` for any env-var or filesystem dependencies. + +8. **`.env.example` additions are isolated to a clearly delimited block.** Don't touch the surrounding file — contributor-supplied `.env.example` versions are usually stale, and edits outside the skill's own block will be dropped during salvage. Comment all values with `#` (it's documentation, not live config). + ### Skill guidelines - **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`). diff --git a/README.md b/README.md index 8b8a078b2507..7e71632c3101 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ **The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM. -Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. +Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NovitaAI](https://novita.ai) (AI-native cloud for Model API, Agent Sandbox, and GPU Cloud), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index c2e1a598269b..44aead28742e 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -1,10 +1,11 @@ -"""ACP permission bridging — maps ACP approval requests to hermes approval callbacks.""" +"""ACP permission bridging for Hermes dangerous-command approvals.""" from __future__ import annotations import asyncio import logging from concurrent.futures import TimeoutError as FutureTimeout +from itertools import count from typing import Callable from acp.schema import ( @@ -14,24 +15,87 @@ logger = logging.getLogger(__name__) -# Maps ACP PermissionOptionKind -> hermes approval result strings -_KIND_TO_HERMES = { +# Maps ACP permission option ids to Hermes approval result strings. +# Option ids are stable across both the ``allow_permanent=True`` and +# ``allow_permanent=False`` paths even though the option list differs. +_OPTION_ID_TO_HERMES = { "allow_once": "once", + "allow_session": "session", "allow_always": "always", - "reject_once": "deny", - "reject_always": "deny", + "deny": "deny", } +_PERMISSION_REQUEST_IDS = count(1) + + +def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]: + """Return ACP options that match Hermes approval semantics.""" + options = [ + PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"), + PermissionOption( + option_id="allow_session", + # ACP has no session-scoped kind, so use the closest persistent + # hint while keeping Hermes semantics in the option id. + kind="allow_always", + name="Allow for session", + ), + ] + if allow_permanent: + options.append( + PermissionOption( + option_id="allow_always", + kind="allow_always", + name="Allow always", + ), + ) + options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) + return options + + +def _build_permission_tool_call(command: str, description: str): + """Return the ACP tool-call update attached to a permission request. + + ``request_permission`` expects a ``ToolCallUpdate`` payload — produced + by ``_acp.update_tool_call`` — not a ``ToolCallStart``. Each request + gets a unique ``perm-check-N`` id so concurrent requests don't collide. + """ + import acp as _acp + + tool_call_id = f"perm-check-{next(_PERMISSION_REQUEST_IDS)}" + return _acp.update_tool_call( + tool_call_id, + title=description, + kind="execute", + status="pending", + content=[_acp.tool_content(_acp.text_block(f"$ {command}"))], + raw_input={"command": command, "description": description}, + ) + + +def _map_outcome_to_hermes(outcome: object, *, allowed_option_ids: set[str]) -> str: + """Map an ACP permission outcome into Hermes approval strings.""" + if not isinstance(outcome, AllowedOutcome): + return "deny" + + option_id = outcome.option_id + if option_id not in allowed_option_ids: + logger.warning("Permission request returned unknown option_id: %s", option_id) + return "deny" + return _OPTION_ID_TO_HERMES.get(option_id, "deny") + def make_approval_callback( request_permission_fn: Callable, loop: asyncio.AbstractEventLoop, session_id: str, timeout: float = 60.0, -) -> Callable[[str, str], str]: +) -> Callable[..., str]: """ - Return a hermes-compatible ``approval_callback(command, description) -> str`` - that bridges to the ACP client's ``request_permission`` call. + Return a Hermes-compatible approval callback that bridges to ACP. + + The callback accepts ``command`` and ``description`` plus optional + keyword arguments such as ``allow_permanent`` used by + ``tools.approval.prompt_dangerous_approval()``. Args: request_permission_fn: The ACP connection's ``request_permission`` coroutine. @@ -40,41 +104,38 @@ def make_approval_callback( timeout: Seconds to wait for a response before auto-denying. """ - def _callback(command: str, description: str) -> str: - options = [ - PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"), - PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"), - PermissionOption(option_id="deny", kind="reject_once", name="Deny"), - ] - import acp as _acp - - tool_call = _acp.start_tool_call("perm-check", command, kind="execute") - - coro = request_permission_fn( - session_id=session_id, - tool_call=tool_call, - options=options, - ) + def _callback( + command: str, + description: str, + *, + allow_permanent: bool = True, + **_: object, + ) -> str: + options = _build_permission_options(allow_permanent=allow_permanent) + future = None try: + tool_call = _build_permission_tool_call(command, description) + coro = request_permission_fn( + session_id=session_id, + tool_call=tool_call, + options=options, + ) future = asyncio.run_coroutine_threadsafe(coro, loop) response = future.result(timeout=timeout) except (FutureTimeout, Exception) as exc: + if future is not None: + future.cancel() logger.warning("Permission request timed out or failed: %s", exc) return "deny" if response is None: return "deny" - outcome = response.outcome - if isinstance(outcome, AllowedOutcome): - option_id = outcome.option_id - # Look up the kind from our options list - for opt in options: - if opt.option_id == option_id: - return _KIND_TO_HERMES.get(opt.kind, "deny") - return "once" # fallback for unknown option_id - else: - return "deny" + allowed_option_ids = {option.option_id for option in options} + return _map_outcome_to_hermes( + response.outcome, + allowed_option_ids=allowed_option_ids, + ) return _callback diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index de7b6db2b1dc..ee0ec917f5da 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1407,6 +1407,7 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt if pool_present: or_key = explicit_api_key or _pool_runtime_api_key(entry) if not or_key: + _mark_provider_unhealthy("openrouter", ttl=60) return None, None base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL logger.debug("Auxiliary client: OpenRouter via pool") @@ -1415,6 +1416,7 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: + _mark_provider_unhealthy("openrouter", ttl=60) return None, None logger.debug("Auxiliary client: OpenRouter") return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL, @@ -1446,6 +1448,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: "Auxiliary: skipping Nous Portal (rate-limited, resets in %.0fs)", _remaining, ) + _mark_provider_unhealthy("nous", ttl=_remaining) return None, None except Exception: pass @@ -1453,6 +1456,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: nous = _read_nous_auth() runtime = _resolve_nous_runtime_api(force_refresh=False) if runtime is None and not nous: + _mark_provider_unhealthy("nous", ttl=60) return None, None global auxiliary_is_nous auxiliary_is_nous = True @@ -4432,7 +4436,7 @@ def extract_content_or_reasoning(response) -> str: 1. ``message.content`` — strip inline think/reasoning blocks, check for remaining non-whitespace text. 2. ``message.reasoning`` / ``message.reasoning_content`` — direct - structured reasoning fields (DeepSeek, Moonshot, Novita, etc.). + structured reasoning fields (DeepSeek, Moonshot, NovitaAI, etc.). 3. ``message.reasoning_details`` — OpenRouter unified array format. Returns the best available text, or ``""`` if nothing found. diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d16236737c40..e7a14faf51b7 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1185,6 +1185,26 @@ def _align_boundary_forward(self, messages: List[Dict[str, Any]], idx: int) -> i idx += 1 return idx + def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int: + """Total count of head messages to protect. + + ``protect_first_n`` is defined as *additional* messages protected + beyond the system prompt. The system prompt (if present at index 0) + is always implicitly protected — it's load-bearing context that + must never be summarised away. This keeps semantics stable across + call paths where the system prompt may or may not be included in + the ``messages`` list (e.g. the gateway ``/compress`` handler + strips it before calling compress()). + + Examples: + protect_first_n=0 → system prompt only (or nothing if no system msg) + protect_first_n=3 → system + first 3 non-system messages + """ + head = 0 + if messages and messages[0].get("role") == "system": + head = 1 + return head + self.protect_first_n + def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int: """Pull a compress-end boundary backward to avoid splitting a tool_call / result group. @@ -1343,7 +1363,7 @@ def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool: skip the LLM call when the transcript is still entirely inside the protected head/tail. """ - compress_start = self._align_boundary_forward(messages, self.protect_first_n) + compress_start = self._align_boundary_forward(messages, self._protect_head_size(messages)) compress_end = self._find_tail_cut_by_tokens(messages, compress_start) return compress_start < compress_end @@ -1379,7 +1399,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f self._last_aux_model_failure_model = None n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) - _min_for_compress = self.protect_first_n + 3 + 1 + _min_for_compress = self._protect_head_size(messages) + 3 + 1 if n_messages <= _min_for_compress: if not self.quiet_mode: logger.warning( @@ -1399,7 +1419,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f logger.info("Pre-compression: pruned %d old tool result(s)", pruned_count) # Phase 2: Determine boundaries - compress_start = self.protect_first_n + compress_start = self._protect_head_size(messages) compress_start = self._align_boundary_forward(messages, compress_start) # Use token-budget tail protection instead of fixed message count @@ -1409,15 +1429,23 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f return messages turns_to_summarize = messages[compress_start:compress_end] + # A persisted handoff summary can sit in the protected head after a + # resume (commonly immediately after the system prompt). Search from + # the first non-system message through the compression window so we can + # rehydrate iterative-summary state without serializing that handoff as + # a new turn. Protected messages after the handoff remain live context, + # so only summarize messages that are both after the handoff and inside + # the current compression window. + summary_search_start = 1 if messages and messages[0].get("role") == "system" else 0 summary_idx, summary_body = self._find_latest_context_summary( messages, - compress_start, + summary_search_start, compress_end, ) if summary_idx is not None: if summary_body and not self._previous_summary: self._previous_summary = summary_body - turns_to_summarize = messages[summary_idx + 1:compress_end] + turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end] if not self.quiet_mode: logger.info( diff --git a/agent/context_engine.py b/agent/context_engine.py index bbafcd29c017..2947da54d8c5 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -55,6 +55,11 @@ def name(self) -> str: # These control the preflight compression check. Subclasses may # override via __init__ or property; defaults are sensible for most # engines. + # + # protect_first_n semantics (since PR #13754): count of non-system head + # messages always preserved verbatim, IN ADDITION to the system prompt + # which is always implicitly protected. Default 3 keeps the + # historical "system + first 3 non-system messages" head shape. threshold_percent: float = 0.75 protect_first_n: int = 3 diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index 5bc42e3aad75..222327807be3 100644 --- a/agent/gemini_cloudcode_adapter.py +++ b/agent/gemini_cloudcode_adapter.py @@ -450,7 +450,13 @@ def _make_stream_chunk( finish_reason: Optional[str] = None, reasoning: str = "", ) -> _GeminiStreamChunk: - delta_kwargs: Dict[str, Any] = {"role": "assistant"} + delta_kwargs: Dict[str, Any] = { + "role": "assistant", + "content": None, + "tool_calls": None, + "reasoning": None, + "reasoning_content": None, + } if content: delta_kwargs["content"] = content if tool_call_delta is not None: diff --git a/agent/image_gen_registry.py b/agent/image_gen_registry.py index 715133231cb8..5d14a6f1ece4 100644 --- a/agent/image_gen_registry.py +++ b/agent/image_gen_registry.py @@ -77,6 +77,17 @@ def get_active_provider() -> Optional[ImageGenProvider]: Reads ``image_gen.provider`` from config.yaml; falls back per the module docstring. + + **Availability semantics** (mirrors :mod:`agent.web_search_registry`): + + - When ``image_gen.provider`` is explicitly set, the configured + provider is returned even if :meth:`ImageGenProvider.is_available` + reports False — the dispatcher surfaces a precise "X_API_KEY is not + set" error rather than silently switching backends. + - When ``image_gen.provider`` is unset, the fallback path (single- + provider shortcut and the FAL legacy preference) is filtered by + ``is_available()`` so we don't pick a provider the user has no + credentials for. """ configured: Optional[str] = None try: @@ -94,6 +105,17 @@ def get_active_provider() -> Optional[ImageGenProvider]: with _lock: snapshot = dict(_providers) + def _is_available_safe(p: ImageGenProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("image_gen provider %s.is_available() raised %s", p.name, exc) + return False + + # 1. Explicit config wins — return regardless of is_available() so the + # user gets a precise downstream error message rather than a silent + # backend switch. if configured: provider = snapshot.get(configured) if provider is not None: @@ -103,13 +125,16 @@ def get_active_provider() -> Optional[ImageGenProvider]: configured, ) - # Fallback: single-provider case - if len(snapshot) == 1: - return next(iter(snapshot.values())) + # 2. Fallback: single registered provider — but only if it's actually + # available (no credentials = don't surface it as "active"). + available = [p for p in snapshot.values() if _is_available_safe(p)] + if len(available) == 1: + return available[0] - # Fallback: prefer legacy FAL for backward compat - if "fal" in snapshot: - return snapshot["fal"] + # 3. Fallback: prefer legacy FAL for backward compat, when available. + fal = snapshot.get("fal") + if fal is not None and _is_available_safe(fal): + return fal return None diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index a0d3eb98c300..34c0b0ba92b4 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -40,7 +40,7 @@ import threading import time from concurrent.futures import Future as ConcurrentFuture -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from agent.lsp import eventlog from agent.lsp.client import ( @@ -305,6 +305,7 @@ def get_diagnostics_sync( *, delta: bool = True, timeout: Optional[float] = None, + line_shift: Optional[Callable[[int], Optional[int]]] = None, ) -> List[Dict[str, Any]]: """Synchronously open ``file_path`` in the right server, wait for diagnostics, return them. @@ -314,6 +315,18 @@ def get_diagnostics_sync( Diagnostics present in the baseline are removed so the caller only sees errors introduced by the current edit. + When ``line_shift`` is provided, baseline diagnostics are + remapped through it before the set-difference. This handles + the case where the edit deleted or inserted lines, causing + pre-existing diagnostics below the edit point to surface at + different line numbers in the post-edit snapshot — without + the shift, they'd all look "introduced by this edit". Pass + a callable built by + :func:`agent.lsp.range_shift.build_line_shift` (pre_text, + post_text). Omit when pre/post content isn't available; + the unshifted comparison still catches diagnostics that + didn't move. + Returns an empty list when LSP is disabled, when no workspace can be detected, when no server matches, or when the server can't be spawned. Never raises. @@ -344,6 +357,14 @@ def get_diagnostics_sync( if delta: baseline = self._delta_baseline.get(abs_path) or [] if baseline: + if line_shift is not None: + # Remap baseline diagnostics into post-edit + # coordinates so shifted-but-otherwise-identical + # entries hash equal under _diag_key. Entries + # that mapped into a deleted region drop out + # silently — they no longer apply. + from agent.lsp.range_shift import shift_baseline + baseline = shift_baseline(baseline, line_shift) seen = {_diag_key(d) for d in baseline} diags = [d for d in diags if _diag_key(d) not in seen] # Roll baseline forward — next call returns deltas relative @@ -585,8 +606,19 @@ def get_status(self) -> Dict[str, Any]: def _diag_key(d: Dict[str, Any]) -> str: - """Content equality key used for delta filtering. Mirrors - :func:`agent.lsp.client._diagnostic_key`.""" + """Content equality key used for cross-edit delta filtering. + + Includes the diagnostic's position range — when used together + with :func:`agent.lsp.range_shift.shift_baseline`, the baseline + is line-shifted into post-edit coordinates BEFORE this key is + computed, so identical-but-shifted diagnostics hash equal. Two + genuinely distinct diagnostics at different lines (e.g. the same + error class introduced at a second site) hash differently and + are surfaced as new. + + Mirrors :func:`agent.lsp.client._diagnostic_key`; intentionally + identical so the two layers agree on diagnostic identity. + """ rng = d.get("range") or {} start = rng.get("start") or {} end = rng.get("end") or {} diff --git a/agent/lsp/range_shift.py b/agent/lsp/range_shift.py new file mode 100644 index 000000000000..8efdfc309821 --- /dev/null +++ b/agent/lsp/range_shift.py @@ -0,0 +1,149 @@ +"""Diff-aware line-shift map for cross-edit LSP delta filtering. + +When an edit deletes or inserts lines in the middle of a file, every +diagnostic below the edit point shifts to a new line number. The +LSPService delta filter subtracts the pre-edit baseline from the +post-edit diagnostics keyed on ``(severity, code, source, message, +range)`` — without an adjustment, the shifted-but-otherwise-identical +diagnostics look brand-new and the agent gets flooded with noise. + +The fix used here is the same trick git's blame and unified diff use: +build a piecewise-linear map from pre-edit line numbers to post-edit +line numbers, then apply that map to baseline diagnostics before the +set-difference. Diagnostics whose pre-edit line is in a region the +edit deleted return ``None`` and are dropped from the baseline (they +genuinely no longer apply). + +Trade-off vs. dropping range from the key entirely (the previous +fix): preserves the "new instance of an identical error at a +different line" signal — if the model introduces a second instance +of the same error class at a different location, that one will be +surfaced as new instead of swallowed by content-only dedup. + +The map is derived from ``difflib.SequenceMatcher.get_opcodes()`` and +exposed as a single callable so callers don't have to reason about +diff regions. +""" +from __future__ import annotations + +import difflib +from typing import Any, Callable, Dict, List, Optional + + +def build_line_shift(pre_text: str, post_text: str) -> Callable[[int], Optional[int]]: + """Build a function mapping pre-edit line numbers to post-edit line numbers. + + Lines are 0-indexed to match the LSP wire format + (``range.start.line`` is 0-indexed). + + The returned callable takes a pre-edit 0-indexed line number and + returns the corresponding post-edit 0-indexed line number, or + ``None`` if that line was deleted by the edit (no post-edit + counterpart exists). + + Cost: one ``SequenceMatcher.get_opcodes()`` call up front; the + returned closure is O(log n) per call (binary search over opcode + regions). Cheap enough to call once per write/patch and apply to + every baseline diagnostic. + """ + pre_lines = pre_text.splitlines() if pre_text else [] + post_lines = post_text.splitlines() if post_text else [] + + # Trivial case: identical content or no content — identity map. + if pre_lines == post_lines: + return lambda line: line + + # SequenceMatcher.get_opcodes() returns a list of + # (tag, i1, i2, j1, j2) where tag is 'equal', 'replace', 'delete', + # or 'insert'. i1:i2 is the range in pre, j1:j2 is the range in + # post. We build a list of (i1, i2, j1, j2, tag) tuples and + # binary-search by i for each lookup. + sm = difflib.SequenceMatcher(a=pre_lines, b=post_lines, autojunk=False) + opcodes = sm.get_opcodes() + + def shift(line: int) -> Optional[int]: + # Find the opcode region whose i1 <= line < i2. + # Linear scan is fine — typical opcode count is small (single + # digits for a typical patch-tool edit). + for tag, i1, i2, j1, j2 in opcodes: + if i1 <= line < i2: + if tag == "equal": + # Pre-line N → post-line (N - i1 + j1). + return line - i1 + j1 + if tag == "delete": + # Pre-line is in a deleted region — no post counterpart. + return None + if tag == "replace": + # Replace == delete + insert; the pre-line has no + # post counterpart in any meaningful sense. Drop. + return None + # 'insert' has i1 == i2 so line < i2 can't be hit. + if line < i1: + # Past the relevant region — handled in earlier iteration. + break + # Past the last opcode region (line >= len(pre_lines)). + # Anchor at end of post. + return max(0, len(post_lines) - 1) if post_lines else None + + return shift + + +def shift_diagnostic_range(diag: Dict[str, Any], + shift: Callable[[int], Optional[int]]) -> Optional[Dict[str, Any]]: + """Return a copy of ``diag`` with its line range remapped through ``shift``. + + Returns ``None`` if the diagnostic's start line maps to ``None`` + (the line was deleted by the edit) — caller drops it from the + baseline since the diagnostic no longer applies. + + Both ``start.line`` and ``end.line`` are remapped independently; + when only the end maps to ``None`` (rare, multi-line diagnostic + straddling the edit boundary) we collapse to a single-line range + at the shifted start to keep the diagnostic in the baseline. + + The original ``diag`` is not mutated. + """ + rng = diag.get("range") or {} + start = rng.get("start") or {} + end = rng.get("end") or {} + + pre_start_line = int(start.get("line", 0)) + pre_end_line = int(end.get("line", pre_start_line)) + + new_start_line = shift(pre_start_line) + if new_start_line is None: + return None + + new_end_line = shift(pre_end_line) + if new_end_line is None: + # Diagnostic straddled the deletion — collapse to start. + new_end_line = new_start_line + + shifted = dict(diag) + shifted["range"] = { + "start": { + "line": new_start_line, + "character": int(start.get("character", 0)), + }, + "end": { + "line": new_end_line, + "character": int(end.get("character", 0)), + }, + } + return shifted + + +def shift_baseline(baseline: List[Dict[str, Any]], + shift: Callable[[int], Optional[int]]) -> List[Dict[str, Any]]: + """Apply ``shift`` to every diagnostic in ``baseline``, dropping deleted entries.""" + out: List[Dict[str, Any]] = [] + for d in baseline: + if not isinstance(d, dict): + continue + shifted = shift_diagnostic_range(d, shift) + if shifted is not None: + out.append(shifted) + return out + + +__all__ = ["build_line_shift", "shift_diagnostic_range", "shift_baseline"] diff --git a/agent/model_metadata.py b/agent/model_metadata.py index f5e34fc18c65..c390a11ea621 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,10 +47,11 @@ def _resolve_requests_verify() -> bool | str: _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", - "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", + "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita", "qwen-oauth", "xiaomi", "arcee", + "auriko", "gmi", "tencent-tokenhub", "custom", "local", @@ -63,10 +64,11 @@ def _resolve_requests_verify() -> bool | str: "mimo", "xiaomi-mimo", "tencent", "tokenhub", "tencent-cloud", "tencentmaas", "arcee-ai", "arceeai", + "auriko-ai", "gmi-cloud", "gmicloud", "xai", "x-ai", "x.ai", "grok", "nvidia", "nim", "nvidia-nim", "nemotron", - "qwen-portal", + "qwen-portal", "novita-ai", "novitaai", }) @@ -104,6 +106,8 @@ def _strip_provider_prefix(model: str) -> str: _model_metadata_cache: Dict[str, Dict[str, Any]] = {} _model_metadata_cache_time: float = 0 +_novita_metadata_cache: Dict[str, Dict[str, Any]] = {} +_novita_metadata_cache_time: float = 0 _MODEL_CACHE_TTL = 3600 _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} @@ -285,6 +289,7 @@ def grok_supports_reasoning_effort(model: str) -> bool: _CONTEXT_LENGTH_KEYS = ( "context_length", "context_window", + "context_size", "max_context_length", "max_position_embeddings", "max_model_len", @@ -361,6 +366,7 @@ def _is_custom_endpoint(base_url: str) -> bool: "api.xiaomimimo.com": "xiaomi", "xiaomimimo.com": "xiaomi", "api.gmi-serving.com": "gmi", + "api.novita.ai": "novita", "tokenhub.tencentmaas.com": "tencent-tokenhub", "ollama.com": "ollama-cloud", } @@ -557,6 +563,16 @@ def _extract_max_completion_tokens(payload: Dict[str, Any]) -> Optional[int]: def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: + novita_input = payload.get("input_token_price_per_m") + novita_output = payload.get("output_token_price_per_m") + if novita_input is not None or novita_output is not None: + pricing: Dict[str, Any] = {} + if novita_input is not None: + pricing["prompt"] = str(float(novita_input) / 10_000 / 1_000_000) + if novita_output is not None: + pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000) + return pricing + alias_map = { "prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"), "completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"), @@ -1527,6 +1543,13 @@ def get_model_context_length( except ImportError: pass # boto3 not installed — fall through to generic resolution + if provider == "novita" or (base_url and base_url_host_matches(base_url, "api.novita.ai")): + ctx = _resolve_endpoint_context_length(model, base_url or "https://api.novita.ai/openai/v1", api_key=api_key) + if ctx is not None: + if base_url: + save_context_length(model, base_url, ctx) + return ctx + # 2. Active endpoint metadata for truly custom/unknown endpoints. # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this — their # /models endpoint may report a provider-imposed limit (e.g. Copilot diff --git a/agent/models_dev.py b/agent/models_dev.py index d709d7176d49..8fabb2766459 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -141,6 +141,7 @@ class ProviderInfo: # Hermes provider names → models.dev provider IDs PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", + "novita": "novita-ai", "anthropic": "anthropic", "openai": "openai", "openai-codex": "openai", diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py new file mode 100644 index 000000000000..b1aeaa007866 --- /dev/null +++ b/agent/transports/codex_app_server.py @@ -0,0 +1,368 @@ +"""Codex app-server JSON-RPC client. + +Speaks the protocol documented in codex-rs/app-server/README.md (codex 0.125+). +Transport is newline-delimited JSON-RPC 2.0 over stdio: spawn `codex app-server`, +do an `initialize` handshake, then drive `thread/start` + `turn/start` and +consume streaming `item/*` notifications until `turn/completed`. + +This module is the wire-level speaker only. Higher-level concerns (event +projection into Hermes' display, approval bridging, transcript projection into +AIAgent.messages, plugin migration) live in sibling modules. + +Status: optional opt-in runtime gated behind `model.openai_runtime == +"codex_app_server"`. Hermes' default tool dispatch is unchanged when this +runtime is not selected. +""" + +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +# Default minimum codex version we test against. The PR sets this from the +# `codex --version` parsed at install time; bumping is a one-line change here. +MIN_CODEX_VERSION = (0, 125, 0) + + +@dataclass +class CodexAppServerError(RuntimeError): + """Raised on JSON-RPC errors from the app-server.""" + + code: int + message: str + data: Optional[Any] = None + + def __str__(self) -> str: # pragma: no cover - trivial + return f"codex app-server error {self.code}: {self.message}" + + +@dataclass +class _Pending: + queue: queue.Queue + method: str + sent_at: float = field(default_factory=time.time) + + +class CodexAppServerClient: + """Minimal JSON-RPC 2.0 client for `codex app-server` over stdio. + + Threading model: + - Spawning thread (caller) drives request/response pairs synchronously. + - One reader thread parses stdout, dispatches replies to the right + pending future, and routes notifications + server-initiated requests + to bounded queues that the caller drains on their own cadence. + - One reader thread captures stderr for diagnostics; codex emits + tracing logs there at RUST_LOG-controlled levels. + + Intentionally NOT async. AIAgent.run_conversation() is synchronous and + runs on the main thread; layering asyncio just to drive a stdio child + creates surprising interrupt semantics. We use blocking queues with + timeouts and rely on `turn/interrupt` for cancellation. + """ + + def __init__( + self, + codex_bin: str = "codex", + codex_home: Optional[str] = None, + extra_args: Optional[list[str]] = None, + env: Optional[dict[str, str]] = None, + ) -> None: + self._codex_bin = codex_bin + cmd = [codex_bin, "app-server"] + list(extra_args or []) + spawn_env = os.environ.copy() + if env: + spawn_env.update(env) + if codex_home: + spawn_env["CODEX_HOME"] = codex_home + # Codex emits tracing to stderr; default WARN keeps it quiet for users. + spawn_env.setdefault("RUST_LOG", "warn") + + self._proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + env=spawn_env, + ) + self._next_id = 1 + self._pending: dict[int, _Pending] = {} + self._pending_lock = threading.Lock() + self._notifications: queue.Queue = queue.Queue() + self._server_requests: queue.Queue = queue.Queue() + self._stderr_lines: list[str] = [] + self._stderr_lock = threading.Lock() + self._closed = False + self._initialized = False + + self._reader = threading.Thread(target=self._read_stdout, daemon=True) + self._reader.start() + self._stderr_reader = threading.Thread(target=self._read_stderr, daemon=True) + self._stderr_reader.start() + + # ---------- lifecycle ---------- + + def initialize( + self, + client_name: str = "hermes", + client_title: str = "Hermes Agent", + client_version: str = "0.1", + capabilities: Optional[dict] = None, + timeout: float = 10.0, + ) -> dict: + """Send `initialize` + `initialized` handshake. Returns the server's + InitializeResponse (userAgent, codexHome, platformFamily, platformOs).""" + if self._initialized: + raise RuntimeError("already initialized") + params = { + "clientInfo": { + "name": client_name, + "title": client_title, + "version": client_version, + }, + "capabilities": capabilities or {}, + } + result = self.request("initialize", params, timeout=timeout) + self.notify("initialized") + self._initialized = True + return result + + def close(self, timeout: float = 3.0) -> None: + """Close stdin and wait for the subprocess to exit, escalating to kill.""" + if self._closed: + return + self._closed = True + try: + if self._proc.stdin and not self._proc.stdin.closed: + self._proc.stdin.close() + except Exception: + pass + try: + self._proc.terminate() + self._proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + self._proc.kill() + self._proc.wait(timeout=1.0) + except Exception: + pass + + def __enter__(self) -> "CodexAppServerClient": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + # ---------- send/receive ---------- + + def request( + self, + method: str, + params: Optional[dict] = None, + timeout: float = 30.0, + ) -> dict: + """Send a JSON-RPC request and block on the response. Returns `result`, + raises CodexAppServerError on `error`.""" + rid = self._take_id() + q: queue.Queue = queue.Queue(maxsize=1) + with self._pending_lock: + self._pending[rid] = _Pending(queue=q, method=method) + self._send({"id": rid, "method": method, "params": params or {}}) + try: + msg = q.get(timeout=timeout) + except queue.Empty: + with self._pending_lock: + self._pending.pop(rid, None) + raise TimeoutError( + f"codex app-server method {method!r} timed out after {timeout}s" + ) + if "error" in msg: + err = msg["error"] + raise CodexAppServerError( + code=err.get("code", -1), + message=err.get("message", ""), + data=err.get("data"), + ) + return msg.get("result", {}) + + def notify(self, method: str, params: Optional[dict] = None) -> None: + """Send a JSON-RPC notification (no id, no response expected).""" + self._send({"method": method, "params": params or {}}) + + def respond(self, request_id: Any, result: dict) -> None: + """Reply to a server-initiated request (e.g. approval prompts).""" + self._send({"id": request_id, "result": result}) + + def respond_error( + self, request_id: Any, code: int, message: str, data: Optional[Any] = None + ) -> None: + """Reply to a server-initiated request with an error.""" + err: dict[str, Any] = {"code": code, "message": message} + if data is not None: + err["data"] = data + self._send({"id": request_id, "error": err}) + + def take_notification(self, timeout: float = 0.0) -> Optional[dict]: + """Pop the next streaming notification, or return None on timeout. + + timeout=0.0 means non-blocking. Use small positive timeouts inside the + AIAgent turn loop to interleave reads with interrupt checks.""" + try: + if timeout <= 0: + return self._notifications.get_nowait() + return self._notifications.get(timeout=timeout) + except queue.Empty: + return None + + def take_server_request(self, timeout: float = 0.0) -> Optional[dict]: + """Pop the next server-initiated request (e.g. exec/applyPatch approval).""" + try: + if timeout <= 0: + return self._server_requests.get_nowait() + return self._server_requests.get(timeout=timeout) + except queue.Empty: + return None + + # ---------- diagnostics ---------- + + def stderr_tail(self, n: int = 20) -> list[str]: + """Return last n lines of codex's stderr (for error reports).""" + with self._stderr_lock: + return list(self._stderr_lines[-n:]) + + def is_alive(self) -> bool: + return self._proc.poll() is None + + # ---------- internals ---------- + + def _take_id(self) -> int: + # JSON-RPC ids only need to be unique per-connection. A simple + # monotonically increasing int is the common choice and matches what + # codex's own clients use. + rid = self._next_id + self._next_id += 1 + return rid + + def _send(self, obj: dict) -> None: + if self._closed: + raise RuntimeError("codex app-server client is closed") + if self._proc.stdin is None: + raise RuntimeError("codex app-server stdin not available") + try: + self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) + self._proc.stdin.flush() + except (BrokenPipeError, ValueError) as exc: + raise RuntimeError( + f"codex app-server stdin closed unexpectedly: {exc}" + ) from exc + + def _read_stdout(self) -> None: + if self._proc.stdout is None: + return + try: + for line in iter(self._proc.stdout.readline, b""): + if not line: + break + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + # Non-JSON output is unexpected on stdout; tracing belongs + # on stderr. Surface it via stderr buffer for diagnostics. + with self._stderr_lock: + self._stderr_lines.append( + f" {line[:200]!r}" + ) + continue + self._dispatch(msg) + except Exception as exc: + with self._stderr_lock: + self._stderr_lines.append(f" {exc}") + + def _dispatch(self, msg: dict) -> None: + # Reply (has id + result/error, no method) + if "id" in msg and ("result" in msg or "error" in msg): + with self._pending_lock: + pending = self._pending.pop(msg["id"], None) + if pending is not None: + try: + pending.queue.put_nowait(msg) + except queue.Full: # pragma: no cover - defensive + pass + return + # Server-initiated request (has id + method) + if "id" in msg and "method" in msg: + self._server_requests.put(msg) + return + # Notification (no id) + if "method" in msg: + self._notifications.put(msg) + + def _read_stderr(self) -> None: + if self._proc.stderr is None: + return + try: + for line in iter(self._proc.stderr.readline, b""): + if not line: + break + with self._stderr_lock: + self._stderr_lines.append( + line.decode("utf-8", "replace").rstrip() + ) + # Bound memory: keep last 500 lines. + if len(self._stderr_lines) > 500: + self._stderr_lines = self._stderr_lines[-500:] + except Exception: # pragma: no cover + pass + + +def parse_codex_version(output: str) -> Optional[tuple[int, int, int]]: + """Parse `codex --version` output. Returns (major, minor, patch) or None.""" + # Output format: "codex-cli 0.130.0" possibly followed by metadata. + import re + + match = re.search(r"(\d+)\.(\d+)\.(\d+)", output or "") + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def check_codex_binary( + codex_bin: str = "codex", min_version: tuple[int, int, int] = MIN_CODEX_VERSION +) -> tuple[bool, str]: + """Verify codex CLI is installed and meets minimum version. + + Returns (ok, message). Used by setup wizard and runtime startup.""" + try: + proc = subprocess.run( + [codex_bin, "--version"], + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + return False, ( + f"codex CLI not found at {codex_bin!r}. Install with: " + f"npm i -g @openai/codex" + ) + except subprocess.TimeoutExpired: + return False, "codex --version timed out" + if proc.returncode != 0: + return False, f"codex --version exited {proc.returncode}: {proc.stderr.strip()}" + version = parse_codex_version(proc.stdout) + if version is None: + return False, f"could not parse codex version from: {proc.stdout!r}" + if version < min_version: + return False, ( + f"codex {'.'.join(map(str, version))} is older than required " + f"{'.'.join(map(str, min_version))}. Run: npm i -g @openai/codex" + ) + return True, ".".join(map(str, version)) diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py new file mode 100644 index 000000000000..f0cd0a196c46 --- /dev/null +++ b/agent/transports/codex_app_server_session.py @@ -0,0 +1,810 @@ +"""Session adapter for codex app-server runtime. + +Owns one Codex thread per Hermes session. Drives `turn/start`, consumes +streaming notifications via CodexEventProjector, handles server-initiated +approval requests (apply_patch, exec command), translates cancellation, +and returns a clean turn result that AIAgent.run_conversation() can splice +into its `messages` list. + +Lifecycle: + session = CodexAppServerSession(cwd="/home/x/proj") + session.ensure_started() # spawns + handshake + thread/start + result = session.run_turn(user_input="hello") # blocks until turn/completed + # result.final_text → assistant text returned to caller + # result.projected_messages → list of {role, content, ...} for messages list + # result.tool_iterations → how many tool-shaped items completed (skill nudge counter) + # result.interrupted → True if Ctrl+C / interrupt_requested fired mid-turn + session.close() # tears down subprocess + +Threading model: the adapter is single-threaded from the caller's perspective. +The underlying CodexAppServerClient owns its own reader threads but exposes +blocking-with-timeout queues that this adapter polls in a loop, so the run_turn +call is synchronous and behaves like AIAgent's existing chat_completions loop. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from agent.redact import redact_sensitive_text +from agent.transports.codex_app_server import ( + CodexAppServerClient, + CodexAppServerError, +) +from agent.transports.codex_event_projector import CodexEventProjector + +logger = logging.getLogger(__name__) + + +# How many tailing stderr lines from the codex subprocess to attach to a +# user-facing error when we don't have a more specific classification (OAuth, +# wedge watchdog, etc.). Small enough to keep error messages legible, large +# enough to surface a config/provider/auth diagnostic. +_STDERR_TAIL_LINES = 12 + + +# Permission profile mapping mirrors the docstring in PR proposal: +# Hermes' tools.terminal.security_mode → Codex's permissions profile id. +# Defaults if config is missing → workspace-write (matches Codex's own default). +_HERMES_TO_CODEX_PERMISSION_PROFILE = { + "auto": "workspace-write", + "approval-required": "read-only-with-approval", + "unrestricted": "full-access", + # Backstop alias used by some skills/tests. + "yolo": "full-access", +} + + +@dataclass +class TurnResult: + """Result of one user→assistant→tool turn through the codex app-server.""" + + final_text: str = "" + projected_messages: list[dict] = field(default_factory=list) + tool_iterations: int = 0 + interrupted: bool = False + error: Optional[str] = None # Set if turn ended in a non-recoverable error + turn_id: Optional[str] = None + thread_id: Optional[str] = None + # Hint to the caller that the underlying codex subprocess is likely + # wedged (turn-level timeout fired, post-tool watchdog tripped, or + # token-refresh failure killed the child). The caller should retire + # the session so the next turn respawns codex from scratch instead + # of riding a CPU-spinning or auth-broken process. Mirrors openclaw + # beta.8's "retire timed-out app-server clients" fix. + should_retire: bool = False + + +# Markers we accept as terminal even when codex never emits turn/completed. +# Some codex versions stream `` as raw text in agentMessage +# items when an interrupt or upstream error tears the turn down before the +# normal completion path fires. Mirrors openclaw beta.8 fix. +_TURN_ABORTED_MARKERS = ("", "") + + +# Substrings in codex stderr / JSON-RPC error messages that signal the +# subprocess died because its OAuth credentials are no longer valid. +# Kept conservative: we only redirect users to `codex login` when we're +# reasonably sure that's the actual failure, otherwise we surface the +# original error verbatim. Mirrors openclaw beta.8's auth-refresh +# classification. +_OAUTH_REFRESH_FAILURE_HINTS = ( + "invalid_grant", + "invalid grant", + "refresh token", + "refresh_token", + "token refresh", + "token_refresh", + "token has expired", + "expired_token", + "expired token", + "not authenticated", + "unauthenticated", + "unauthorized", + "401 unauthorized", + "re-authenticate", + "reauthenticate", + "please log in", + "please login", + "auth profile", + "no auth profile", + "oauth", +) + + +def _classify_oauth_failure(*parts: str) -> Optional[str]: + """Return a user-friendly re-auth hint if any of the provided strings + look like a codex OAuth/token-refresh failure; otherwise None. + + Used for both `turn/start` JSON-RPC errors and post-mortem stderr + inspection when the subprocess exits unexpectedly. Conservative on + purpose — we only redirect users to `codex login` when the signal + is strong, so unrelated runtime failures still surface verbatim. + """ + haystack = " ".join(p for p in parts if p).lower() + if not haystack: + return None + for needle in _OAUTH_REFRESH_FAILURE_HINTS: + if needle in haystack: + return ( + "Codex authentication failed — your ChatGPT/Codex login " + "looks expired or invalid. Run `codex login` to refresh, " + "then retry. (Fall back to default runtime with " + "`/codex-runtime auto` if the issue persists.)" + ) + return None + + +@dataclass +class _ServerRequestRouting: + """Default policies for codex-side approval requests when no interactive + callback is wired in. These are only used by tests + cron / non-interactive + contexts; the live CLI path passes an approval_callback that defers to + tools.approval.prompt_dangerous_approval().""" + + auto_approve_exec: bool = False + auto_approve_apply_patch: bool = False + + +class CodexAppServerSession: + """One Codex thread per Hermes session, lifetime owned by AIAgent. + + Not thread-safe — one caller drives it at a time, matching how AIAgent's + run_conversation() loop is structured today. The codex client itself can + handle interleaved reads/writes via its own threads, but the adapter's + state (projector, thread_id, turn counter) is owned by the caller thread. + """ + + def __init__( + self, + *, + cwd: Optional[str] = None, + codex_bin: str = "codex", + codex_home: Optional[str] = None, + permission_profile: Optional[str] = None, + approval_callback: Optional[Callable[..., str]] = None, + on_event: Optional[Callable[[dict], None]] = None, + request_routing: Optional[_ServerRequestRouting] = None, + client_factory: Optional[Callable[..., CodexAppServerClient]] = None, + ) -> None: + self._cwd = cwd or os.getcwd() + self._codex_bin = codex_bin + self._codex_home = codex_home + self._permission_profile = ( + permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get( + os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"), + "workspace-write", + ) + ) + self._approval_callback = approval_callback + self._on_event = on_event # Display hook (kawaii spinner ticks etc.) + self._routing = request_routing or _ServerRequestRouting() + self._client_factory = client_factory or CodexAppServerClient + + self._client: Optional[CodexAppServerClient] = None + self._thread_id: Optional[str] = None + self._interrupt_event = threading.Event() + # Pending file-change items, keyed by item id. Populated on + # item/started for fileChange items; consumed by the approval + # bridge when codex sends item/fileChange/requestApproval. The + # approval params don't carry the changeset, so we cache here + # to surface a real summary in the approval prompt (quirk #4). + self._pending_file_changes: dict[str, str] = {} + self._closed = False + + # ---------- lifecycle ---------- + + def ensure_started(self) -> str: + """Spawn the subprocess, do the initialize handshake, and start a + thread. Returns the codex thread id. Idempotent — repeated calls + return the same thread id.""" + if self._thread_id is not None: + return self._thread_id + if self._client is None: + self._client = self._client_factory( + codex_bin=self._codex_bin, codex_home=self._codex_home + ) + self._client.initialize( + client_name="hermes", + client_title="Hermes Agent", + client_version=_get_hermes_version(), + ) + # Permission selection is intentionally NOT sent on thread/start. + # Two reasons (live-tested against codex 0.130.0): + # 1. `thread/start.permissions` is gated behind the experimentalApi + # capability on this codex version — we'd have to opt in during + # initialize and accept the unstable surface. + # 2. Even with experimentalApi declared and the correct shape + # (`{"type": "profile", "id": "..."}`, not `{"profileId": ...}`), + # codex requires a matching `[permissions]` table in + # ~/.codex/config.toml or it fails the request with + # 'default_permissions requires a [permissions] table'. + # Letting codex pick its default (`:read-only` unless the user has + # configured otherwise in their codex config.toml) is the standard + # codex CLI workflow and avoids fighting codex's own validation. + # Users who want a write-capable profile configure it in their + # ~/.codex/config.toml the same way they would for any codex usage. + params: dict[str, Any] = {"cwd": self._cwd} + result = self._client.request("thread/start", params, timeout=15) + # Cross-fill thread.id/sessionId — different codex versions have + # serialized this under either key. Mirrors openclaw beta.8's + # tolerance fix so future codex drops/renames don't KeyError us + # at handshake time. + thread_obj = result.get("thread") or {} + thread_id = ( + thread_obj.get("id") + or thread_obj.get("sessionId") + or result.get("sessionId") + or result.get("threadId") + ) + if not thread_id: + raise CodexAppServerError( + code=-32603, + message=( + "codex thread/start returned no thread id " + f"(payload keys: {sorted(result.keys())})" + ), + ) + self._thread_id = thread_id + logger.info( + "codex app-server thread started: id=%s profile=%s cwd=%s", + self._thread_id[:8], + self._permission_profile, + self._cwd, + ) + return self._thread_id + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._client is not None: + try: + self._client.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + self._client = None + self._thread_id = None + + def __enter__(self) -> "CodexAppServerSession": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + # ---------- interrupt ---------- + + def request_interrupt(self) -> None: + """Idempotent: signal the active turn loop to issue turn/interrupt + and unwind. Called by AIAgent's _interrupt_requested path.""" + self._interrupt_event.set() + + # ---------- diagnostics ---------- + + def _format_error_with_stderr( + self, + prefix: str, + exc: Any = "", + *, + tail_lines: int = _STDERR_TAIL_LINES, + ) -> str: + """Build a user-facing error string for codex failures. + + Appends the last few lines of codex's stderr buffer when available, + passed through agent.redact with force=True so secrets in provider + error responses (auth headers, query-string tokens, sk-* keys) never + leak into chat output or trajectories. The codex CLI's own error + text ('Internal error', 'turn/start failed: ...') is otherwise + opaque and forces users to re-run with verbose flags to diagnose + config / provider / auth-bridge problems. + + Use this for the generic / catch-all branches. Specific + classifications (OAuth via _classify_oauth_failure, post-tool wedge + watchdog) already produce a clean hint and should be used instead. + """ + exc_str = str(exc) if exc != "" and exc is not None else "" + base = f"{prefix}: {exc_str}" if exc_str else prefix + if self._client is None: + return base + try: + tail = self._client.stderr_tail(tail_lines) + except Exception: # pragma: no cover - diagnostic best-effort + return base + if not tail: + return base + joined = "\n".join(line.rstrip() for line in tail if line) + if not joined.strip(): + return base + redacted = redact_sensitive_text(joined, force=True) + return f"{base}\ncodex stderr (last {len(tail)} lines):\n{redacted}" + + # ---------- per-turn ---------- + + def run_turn( + self, + user_input: str, + *, + turn_timeout: float = 600.0, + notification_poll_timeout: float = 0.25, + post_tool_quiet_timeout: float = 90.0, + ) -> TurnResult: + """Send a user message and block until turn/completed, while + forwarding server-initiated approval requests and projecting items + into Hermes' messages shape. + + post_tool_quiet_timeout: if codex emits a tool completion and then + goes quiet for this many seconds without emitting another item or + `turn/completed`, fast-fail and mark the session for retirement. + Mirrors openclaw beta.8's post-tool completion watchdog (#81697) + so a wedged codex doesn't burn the full turn deadline. + """ + # Pre-create the result so startup failures (codex subprocess can't + # spawn, initialize handshake rejects, thread/start blows up) surface + # the same way per-turn failures do — with a TurnResult.error string + # the caller can render — instead of bubbling raw codex exceptions + # up to AIAgent.run_conversation. + result = TurnResult() + try: + self.ensure_started() + except (CodexAppServerError, TimeoutError) as exc: + result.error = self._format_error_with_stderr( + "codex app-server startup failed", exc + ) + # Subprocess almost certainly unhealthy — retire so the next + # turn re-spawns cleanly. + result.should_retire = True + return result + assert self._client is not None and self._thread_id is not None + result.thread_id = self._thread_id + + self._interrupt_event.clear() + projector = CodexEventProjector() + + # Send turn/start with the user input. Text-only for now (codex + # supports rich content but Hermes' text path is the common case). + try: + ts = self._client.request( + "turn/start", + { + "threadId": self._thread_id, + "input": [{"type": "text", "text": user_input}], + }, + timeout=10, + ) + except CodexAppServerError as exc: + # Classify auth/refresh failures so the user gets a clear + # `codex login` pointer instead of a raw RPC error string. + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(exc.message, stderr_blob) + if hint is not None: + result.error = hint + # Subprocess is fine on a JSON-RPC level here, but the + # token store is broken — retire so the next turn does a + # clean handshake (and the user has a chance to re-auth + # via `codex login` between turns). + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + "turn/start failed", exc + ) + return result + except TimeoutError as exc: + # turn/start hanging is a strong signal the subprocess is wedged. + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(stderr_blob) + result.error = hint or self._format_error_with_stderr( + "turn/start timed out", exc + ) + result.should_retire = True + return result + + result.turn_id = (ts.get("turn") or {}).get("id") + deadline = time.time() + turn_timeout + turn_complete = False + # Post-tool watchdog state. last_tool_completion_at is set whenever + # a tool-shaped item completes; if no further notification arrives + # within post_tool_quiet_timeout and the turn hasn't completed, we + # fast-fail and retire the session. + last_tool_completion_at: Optional[float] = None + + while time.time() < deadline and not turn_complete: + if self._interrupt_event.is_set(): + self._issue_interrupt(result.turn_id) + result.interrupted = True + break + + # Detect a dead subprocess between iterations. If codex exited + # (e.g. crashed, segfaulted, or its auth refresh thread killed + # the process), we won't get any more notifications — bail out + # rather than waiting for the full turn deadline. + if not self._client.is_alive(): + stderr_blob = "\n".join(self._client.stderr_tail(60)) + hint = _classify_oauth_failure(stderr_blob) + if hint is not None: + result.error = hint + else: + result.error = self._format_error_with_stderr( + "codex app-server subprocess exited unexpectedly", + tail_lines=20, + ) + result.should_retire = True + break + + # Post-tool watchdog: if a tool completion was the most recent + # signal and codex has been silent past the quiet timeout, give + # up on this turn instead of waiting for the outer deadline. + if ( + last_tool_completion_at is not None + and (time.time() - last_tool_completion_at) + > post_tool_quiet_timeout + ): + self._issue_interrupt(result.turn_id) + result.interrupted = True + result.error = ( + f"codex went silent for " + f"{post_tool_quiet_timeout:.0f}s after a tool result; " + f"retiring app-server session." + ) + result.should_retire = True + break + + # Drain any server-initiated requests (approvals) before + # reading notifications, so the codex side isn't blocked. + sreq = self._client.take_server_request(timeout=0) + if sreq is not None: + # Drain any pending notifications first so per-turn state + # (e.g. _pending_file_changes for fileChange approvals) is + # up to date when we make the approval decision. Bounded + # to avoid starving the server-request response. + for _ in range(8): + pending = self._client.take_notification(timeout=0) + if pending is None: + break + self._track_pending_file_change(pending) + proj = projector.project(pending) + if proj.messages: + result.projected_messages.extend(proj.messages) + if proj.is_tool_iteration: + result.tool_iterations += 1 + last_tool_completion_at = time.time() + if proj.final_text is not None: + result.final_text = proj.final_text + if _has_turn_aborted_marker(proj.final_text): + turn_complete = True + result.interrupted = True + result.error = ( + result.error + or "codex reported turn_aborted" + ) + self._handle_server_request(sreq) + # Activity counts as live signal — reset the post-tool + # quiet timer so an approval round-trip doesn't trip it. + last_tool_completion_at = None + continue + + note = self._client.take_notification( + timeout=notification_poll_timeout + ) + if note is None: + continue + + method = note.get("method", "") + if self._on_event is not None: + try: + self._on_event(note) + except Exception: # pragma: no cover - display callback + logger.debug("on_event callback raised", exc_info=True) + + # Track in-progress fileChange items so the approval bridge + # can surface a real change summary when codex requests + # approval (the approval params themselves don't carry the + # changeset). Quirk #4 fix. + self._track_pending_file_change(note) + + # Project into messages + projection = projector.project(note) + if projection.messages: + result.projected_messages.extend(projection.messages) + if projection.is_tool_iteration: + result.tool_iterations += 1 + # Arm/refresh the post-tool quiet watchdog whenever a + # tool-shaped item completes. + last_tool_completion_at = time.time() + else: + # Any non-tool projected activity (assistant message, + # status update, etc.) means codex is still producing + # output — clear the quiet timer so we don't fast-fail. + if projection.messages or projection.final_text is not None: + last_tool_completion_at = None + if projection.final_text is not None: + # Codex can emit multiple agentMessage items in one turn + # (e.g. partial then final). Take the last one as canonical. + result.final_text = projection.final_text + # Some codex builds tear a turn down by emitting a + # `` marker in the agent message text and + # never sending turn/completed. Treat the marker itself + # as terminal so we don't burn the full deadline. + if _has_turn_aborted_marker(projection.final_text): + turn_complete = True + result.interrupted = True + result.error = ( + result.error or "codex reported turn_aborted" + ) + + if method == "turn/completed": + turn_complete = True + turn_status = ( + (note.get("params") or {}).get("turn") or {} + ).get("status") + if turn_status and turn_status not in ("completed", "interrupted"): + err_obj = ( + (note.get("params") or {}).get("turn") or {} + ).get("error") + if err_obj: + err_msg = err_obj.get("message") or str(err_obj) + # If the turn failed for an auth/refresh reason, + # rewrite the error into a re-auth hint AND mark + # the session for retirement. + stderr_blob = "\n".join( + self._client.stderr_tail(40) + ) + hint = _classify_oauth_failure(err_msg, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + f"turn ended status={turn_status}", err_msg + ) + + if not turn_complete and not result.interrupted: + # Hit the deadline. Issue interrupt to stop wasted compute, and + # tell the caller to retire the session — a turn that never + # finished is a strong sign codex is wedged in a way the next + # turn shouldn't inherit. + self._issue_interrupt(result.turn_id) + result.interrupted = True + if not result.error: + result.error = self._format_error_with_stderr( + f"turn timed out after {turn_timeout}s" + ) + result.should_retire = True + + return result + + # ---------- internals ---------- + + def _issue_interrupt(self, turn_id: Optional[str]) -> None: + if self._client is None or self._thread_id is None or turn_id is None: + return + try: + self._client.request( + "turn/interrupt", + {"threadId": self._thread_id, "turnId": turn_id}, + timeout=5, + ) + except CodexAppServerError as exc: + # "no active turn to interrupt" is fine — already done. + logger.debug("turn/interrupt non-fatal: %s", exc) + except TimeoutError: + logger.warning("turn/interrupt timed out") + + def _handle_server_request(self, req: dict) -> None: + """Translate a codex server request (approval) into Hermes' approval + flow, then send the response. + + Method names verified live against codex 0.130.0 (Apr 2026): + item/commandExecution/requestApproval — exec approvals + item/fileChange/requestApproval — apply_patch approvals + item/permissions/requestApproval — permissions changes + (we decline; user controls + permission profile in + ~/.codex/config.toml). + """ + if self._client is None: + return + method = req.get("method", "") + rid = req.get("id") + params = req.get("params") or {} + + if method == "item/commandExecution/requestApproval": + decision = self._decide_exec_approval(params) + self._client.respond(rid, {"decision": decision}) + elif method == "item/fileChange/requestApproval": + decision = self._decide_apply_patch_approval(params) + self._client.respond(rid, {"decision": decision}) + elif method == "item/permissions/requestApproval": + # Codex sometimes asks to escalate permissions mid-turn. We + # always decline — the user already chose their permission + # profile in ~/.codex/config.toml and surprise escalations + # shouldn't be silently accepted. + self._client.respond(rid, {"decision": "decline"}) + elif method == "mcpServer/elicitation/request": + # Codex's MCP layer asks the user for structured input on + # behalf of an MCP server (e.g. tool-call confirmation, + # OAuth, form data). For our own hermes-tools callback we + # auto-accept — the user already approved Hermes' tools + # by enabling the runtime, and we never expose anything + # codex's built-in shell can't already do. For other MCP + # servers we decline so the user explicitly opts in via + # codex's own auth flow. + server_name = params.get("serverName") or "" + if server_name == "hermes-tools": + self._client.respond( + rid, + {"action": "accept", "content": None, "_meta": None}, + ) + else: + self._client.respond( + rid, + {"action": "decline", "content": None, "_meta": None}, + ) + else: + # Unknown server request — codex can extend this surface. Reject + # cleanly so codex doesn't hang waiting for us. + logger.warning("Unknown codex server request: %s", method) + self._client.respond_error( + rid, code=-32601, message=f"Unsupported method: {method}" + ) + + def _decide_exec_approval(self, params: dict) -> str: + if self._routing.auto_approve_exec: + return "accept" + command = params.get("command") or "" + # Codex's CommandExecutionRequestApprovalParams has cwd as Optional — + # fall back to the session's cwd when codex doesn't include it so the + # approval prompt is never empty (quirk #10 fix). + cwd = params.get("cwd") or self._cwd or "" + reason = params.get("reason") + description = f"Codex requests exec in {cwd}" + if reason: + description += f" — {reason}" + if self._approval_callback is not None: + try: + choice = self._approval_callback( + command, description, allow_permanent=False + ) + return _approval_choice_to_codex_decision(choice) + except Exception: + logger.exception("approval_callback raised on exec request") + return "decline" + return "decline" # fail-closed when no callback wired + + def _decide_apply_patch_approval(self, params: dict) -> str: + if self._routing.auto_approve_apply_patch: + return "accept" + if self._approval_callback is not None: + # FileChangeRequestApprovalParams gives us reason + grantRoot. + # The actual changeset lives on the corresponding fileChange + # item which the projector has already cached for us — look it + # up by item_id so the user sees what's actually changing. + reason = params.get("reason") + grant_root = params.get("grantRoot") + item_id = params.get("itemId") or "" + change_summary = self._lookup_pending_file_change(item_id) + description_parts = [] + if reason: + description_parts.append(reason) + if change_summary: + description_parts.append(change_summary) + if grant_root: + description_parts.append(f"grants write to {grant_root}") + description = ( + "; ".join(description_parts) + if description_parts + else "Codex requests to apply a patch" + ) + command_label = ( + f"apply_patch: {change_summary}" if change_summary + else f"apply_patch: {reason}" if reason + else "apply_patch" + ) + try: + choice = self._approval_callback( + command_label, + description, + allow_permanent=False, + ) + return _approval_choice_to_codex_decision(choice) + except Exception: + logger.exception("approval_callback raised on apply_patch") + return "decline" + return "decline" + + def _track_pending_file_change(self, note: dict) -> None: + """Maintain self._pending_file_changes from item/started + item/completed + notifications. Lets the apply_patch approval prompt show what's + actually changing — codex's approval params don't carry the data.""" + method = note.get("method", "") + params = note.get("params") or {} + item = params.get("item") or {} + if item.get("type") != "fileChange": + return + item_id = item.get("id") or "" + if not item_id: + return + if method == "item/started": + changes = item.get("changes") or [] + if not changes: + self._pending_file_changes[item_id] = "1 change pending" + return + kinds: dict[str, int] = {} + paths: list[str] = [] + for ch in changes: + if not isinstance(ch, dict): + continue + kind = (ch.get("kind") or {}).get("type") or "update" + kinds[kind] = kinds.get(kind, 0) + 1 + p = ch.get("path") or "" + if p: + paths.append(p) + counts = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items())) + preview = ", ".join(paths[:3]) + if len(paths) > 3: + preview += f", +{len(paths) - 3} more" + self._pending_file_changes[item_id] = ( + f"{counts}: {preview}" if preview else counts + ) + elif method == "item/completed": + self._pending_file_changes.pop(item_id, None) + + def _lookup_pending_file_change(self, item_id: str) -> Optional[str]: + """Look up an in-progress fileChange item by id and summarize its + changes for the approval prompt. Returns None when we don't have + the item cached (e.g. approval arrived before item/started, or + fileChange item content not tracked yet).""" + if not item_id: + return None + cached = self._pending_file_changes.get(item_id) + if not cached: + return None + return cached + + +def _approval_choice_to_codex_decision(choice: str) -> str: + """Map Hermes approval choices onto codex's CommandExecutionApprovalDecision + / FileChangeApprovalDecision wire values. + + Hermes returns 'once', 'session', 'always', or 'deny'. + Codex expects 'accept', 'acceptForSession', 'decline', or 'cancel' + (verified against codex-rs/app-server-protocol/src/protocol/v2/item.rs + on codex 0.130.0). + """ + if choice in ("once",): + return "accept" + if choice in ("session", "always"): + return "acceptForSession" + return "decline" + + +def _has_turn_aborted_marker(text: str) -> bool: + """Return True if `text` contains any of the raw markers codex uses + to signal a turn was aborted without emitting `turn/completed`. + + Codex emits `` (and sometimes ``) as raw + text inside agentMessage items when an interrupt or upstream error + tears the turn down before the normal completion path fires. Mirrors + openclaw beta.8's terminal-marker fix so we don't burn the full turn + deadline waiting for a turn/completed that never comes. + """ + if not text: + return False + for marker in _TURN_ABORTED_MARKERS: + if marker in text: + return True + return False + + +def _get_hermes_version() -> str: + """Best-effort Hermes version string for codex's userAgent line.""" + try: + from importlib.metadata import version + + return version("hermes-agent") + except Exception: # pragma: no cover + return "0.0.0" diff --git a/agent/transports/codex_event_projector.py b/agent/transports/codex_event_projector.py new file mode 100644 index 000000000000..0a388a60cfb1 --- /dev/null +++ b/agent/transports/codex_event_projector.py @@ -0,0 +1,312 @@ +"""Projects codex app-server events into Hermes' messages list. + +The translator that lets Hermes' memory/skill review keep working under the +Codex runtime: it converts Codex `item/*` notifications into the standard +OpenAI-shaped `{role, content, tool_calls, tool_call_id}` entries that +`agent/curator.py` already knows how to read. + +Codex emits items with a discriminator field `type`: + - userMessage → {role: "user", content} + - agentMessage → {role: "assistant", content} + - reasoning → stashed in the assistant's "reasoning" field + - commandExecution → assistant tool_call(name="exec") + tool result + - fileChange → assistant tool_call(name="apply_patch") + tool result + - mcpToolCall → assistant tool_call(name=f"mcp.{server}.{tool}") + tool result + - dynamicToolCall → assistant tool_call(name=tool) + tool result + - plan/hookPrompt/collabAgentToolCall → recorded as opaque assistant notes + +Each item maps to AT MOST one assistant entry + one tool entry, preserving +Hermes' message-alternation invariants (system → user → assistant → user/tool +→ assistant → ...). Multiple Codex tool calls within one Codex turn produce +multiple consecutive (assistant, tool) pairs, which is the same shape Hermes +already produces for parallel tool calls. + +Counters tracked alongside projection: + - tool_iterations: ticks once per completed tool-shaped item. Used by + AIAgent._iters_since_skill (skill nudge gate, default threshold 10). +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any, Optional + + +def _deterministic_call_id(item_type: str, item_id: str) -> str: + """Stable id for tool_call message correlation. + + Uses the codex item id directly when present (already a uuid); falls back + to a content hash so replay produces the same id across sessions and + prefix caches stay valid. See AGENTS.md Pitfall #16 (deterministic IDs in + tool call history).""" + if item_id: + return f"codex_{item_type}_{item_id}" + digest = hashlib.sha256(f"{item_type}".encode()).hexdigest()[:16] + return f"codex_{item_type}_{digest}" + + +def _format_tool_args(d: dict) -> str: + """Format a dict as JSON the way Hermes' existing tool_calls path does.""" + return json.dumps(d, ensure_ascii=False, sort_keys=True) + + +@dataclass +class ProjectionResult: + """Output of projecting one Codex item. + + `messages` is a list because some Codex items produce two messages + (assistant tool_call + tool result). Empty list = item ignored (e.g. a + streaming `outputDelta` that doesn't materialize into messages until the + `item/completed` event).""" + + messages: list[dict] = field(default_factory=list) + is_tool_iteration: bool = False + final_text: Optional[str] = None # Set when an agentMessage completes + + +class CodexEventProjector: + """Stateful projector consuming Codex notifications in arrival order. + + Owns the in-progress reasoning content (codex emits reasoning as separate + items but Hermes stashes it on the next assistant message).""" + + def __init__(self) -> None: + self._pending_reasoning: list[str] = [] + + def project(self, notification: dict) -> ProjectionResult: + """Project a single notification. Idempotent for non-completion events; + only `item/completed` and `turn/completed` materialize messages.""" + method = notification.get("method", "") + params = notification.get("params", {}) or {} + + # We only materialize messages on `item/completed`. Streaming deltas + # (`item//outputDelta`, `item//delta`) are display-only and + # don't enter the messages list — same way Hermes already only writes + # the assistant message after the streaming completion event. + if method != "item/completed": + return ProjectionResult() + + item = params.get("item") or {} + item_type = item.get("type") or "" + item_id = item.get("id") or "" + + if item_type == "agentMessage": + return self._project_agent_message(item) + if item_type == "reasoning": + self._pending_reasoning.extend(item.get("summary") or []) + self._pending_reasoning.extend(item.get("content") or []) + return ProjectionResult() + if item_type == "commandExecution": + return self._project_command(item, item_id) + if item_type == "fileChange": + return self._project_file_change(item, item_id) + if item_type == "mcpToolCall": + return self._project_mcp_tool_call(item, item_id) + if item_type == "dynamicToolCall": + return self._project_dynamic_tool_call(item, item_id) + if item_type == "userMessage": + return self._project_user_message(item) + + # Unknown / rare items (plan, hookPrompt, collabAgentToolCall, etc.) + # — record as opaque assistant note so memory review can still see + # *something* happened, but don't fabricate tool_call structure. + return self._project_opaque(item, item_type) + + # ---------- per-type projections ---------- + + def _project_agent_message(self, item: dict) -> ProjectionResult: + text = item.get("text") or "" + msg: dict[str, Any] = {"role": "assistant", "content": text} + if self._pending_reasoning: + msg["reasoning"] = "\n".join(self._pending_reasoning) + self._pending_reasoning = [] + return ProjectionResult(messages=[msg], final_text=text) + + def _project_user_message(self, item: dict) -> ProjectionResult: + # codex's userMessage content is a list of UserInput variants. For + # projection purposes we flatten any text fragments and ignore + # non-text parts (images, etc.) — Hermes' messages store text only. + text_parts: list[str] = [] + for fragment in item.get("content") or []: + if isinstance(fragment, dict): + if fragment.get("type") == "text": + text_parts.append(fragment.get("text") or "") + elif "text" in fragment: + text_parts.append(str(fragment["text"])) + return ProjectionResult( + messages=[{"role": "user", "content": "\n".join(text_parts)}] + ) + + def _project_command(self, item: dict, item_id: str) -> ProjectionResult: + call_id = _deterministic_call_id("exec", item_id) + args = { + "command": item.get("command") or "", + "cwd": item.get("cwd") or "", + } + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": "exec_command", + "arguments": _format_tool_args(args), + }, + } + ], + } + if self._pending_reasoning: + assistant_msg["reasoning"] = "\n".join(self._pending_reasoning) + self._pending_reasoning = [] + output = item.get("aggregatedOutput") or "" + exit_code = item.get("exitCode") + if exit_code is not None and exit_code != 0: + output = f"[exit {exit_code}]\n{output}" + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": output, + } + return ProjectionResult( + messages=[assistant_msg, tool_msg], is_tool_iteration=True + ) + + def _project_file_change(self, item: dict, item_id: str) -> ProjectionResult: + call_id = _deterministic_call_id("apply_patch", item_id) + # Reduce the codex changes array to a digest the agent loop will + # find readable. We record per-file change kinds (Add/Update/Delete) + # without inlining full file contents — those can be huge. + changes_summary = [] + for change in item.get("changes") or []: + kind = (change.get("kind") or {}).get("type") or "update" + path = change.get("path") or "" + changes_summary.append({"kind": kind, "path": path}) + args = {"changes": changes_summary} + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": "apply_patch", + "arguments": _format_tool_args(args), + }, + } + ], + } + if self._pending_reasoning: + assistant_msg["reasoning"] = "\n".join(self._pending_reasoning) + self._pending_reasoning = [] + status = item.get("status") or "unknown" + n = len(changes_summary) + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": f"apply_patch status={status}, {n} change(s)", + } + return ProjectionResult( + messages=[assistant_msg, tool_msg], is_tool_iteration=True + ) + + def _project_mcp_tool_call(self, item: dict, item_id: str) -> ProjectionResult: + server = item.get("server") or "mcp" + tool = item.get("tool") or "unknown" + call_id = _deterministic_call_id(f"mcp_{server}_{tool}", item_id) + args = item.get("arguments") or {} + if not isinstance(args, dict): + args = {"arguments": args} + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": f"mcp.{server}.{tool}", + "arguments": _format_tool_args(args), + }, + } + ], + } + if self._pending_reasoning: + assistant_msg["reasoning"] = "\n".join(self._pending_reasoning) + self._pending_reasoning = [] + result = item.get("result") + error = item.get("error") + if error: + content = f"[error] {json.dumps(error, ensure_ascii=False)[:1000]}" + elif result is not None: + content = json.dumps(result, ensure_ascii=False)[:4000] + else: + content = "" + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": content, + } + return ProjectionResult( + messages=[assistant_msg, tool_msg], is_tool_iteration=True + ) + + def _project_dynamic_tool_call( + self, item: dict, item_id: str + ) -> ProjectionResult: + tool = item.get("tool") or "unknown" + call_id = _deterministic_call_id(f"dyn_{tool}", item_id) + args = item.get("arguments") or {} + if not isinstance(args, dict): + args = {"arguments": args} + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool, + "arguments": _format_tool_args(args), + }, + } + ], + } + if self._pending_reasoning: + assistant_msg["reasoning"] = "\n".join(self._pending_reasoning) + self._pending_reasoning = [] + content_items = item.get("contentItems") or [] + if isinstance(content_items, list) and content_items: + content = json.dumps(content_items, ensure_ascii=False)[:4000] + else: + success = item.get("success") + content = f"success={success}" + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": content, + } + return ProjectionResult( + messages=[assistant_msg, tool_msg], is_tool_iteration=True + ) + + def _project_opaque(self, item: dict, item_type: str) -> ProjectionResult: + # Record the existence of the item without inventing tool_calls. + # Memory review will see this and may or may not save anything. + try: + payload = json.dumps(item, ensure_ascii=False)[:1500] + except (TypeError, ValueError): + payload = repr(item)[:1500] + return ProjectionResult( + messages=[ + { + "role": "assistant", + "content": f"[codex {item_type}] {payload}", + } + ] + ) diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py new file mode 100644 index 000000000000..f7f8ae24887f --- /dev/null +++ b/agent/transports/hermes_tools_mcp_server.py @@ -0,0 +1,225 @@ +"""Hermes-tools-as-MCP server for the codex_app_server runtime. + +When the user runs `openai/*` turns through the codex app-server, codex +owns the loop and builds its own tool list. By default, that means +Hermes' richer tool surface — web search, browser automation, +delegate_task subagents, vision analysis, persistent memory, skills, +cross-session search, image generation, TTS — is unreachable. + +This module exposes a curated subset of those Hermes tools to the +spawned codex subprocess via stdio MCP. Codex registers it as a normal +MCP server (per `~/.codex/config.toml [mcp_servers.hermes-tools]`) and +the user gets full Hermes capability inside a Codex turn. + +Scope (what we expose): + - web_search, web_extract — Firecrawl, no codex equivalent + - browser_navigate / _click / _type / — Camofox/Browserbase automation + _snapshot / _screenshot / _scroll / _back / _press / _vision + - delegate_task — Hermes subagents + - vision_analyze — image inspection by vision model + - image_generate — image generation + - memory — Hermes' persistent memory store + - skill_view, skills_list — Hermes' skill library + - session_search — cross-session search + - text_to_speech — TTS + +What we DO NOT expose (codex has equivalents): + - terminal / shell — codex's own shell tool + - read_file / write_file / patch — codex's apply_patch + shell + - search_files / process — codex's shell + - clarify, todo — codex's own UX + +Run with: python -m agent.transports.hermes_tools_mcp_server +Spawned by: CodexAppServerSession.ensure_started() when the runtime is + active and config opts in. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# Tools we expose. Each name MUST match a registered Hermes tool that +# `model_tools.handle_function_call()` can dispatch. +# +# What we deliberately DO NOT expose: +# - terminal / shell / read_file / write_file / patch / search_files / +# process — codex's built-ins cover these and approval routes through +# codex's own UI. +# - delegate_task / memory / session_search / todo — these are +# `_AGENT_LOOP_TOOLS` in Hermes (model_tools.py:493). They require +# the running AIAgent context to dispatch (mid-loop state), so a +# stateless MCP callback can't drive them. Hermes' default runtime +# keeps these working; the codex_app_server runtime cannot. +EXPOSED_TOOLS: tuple[str, ...] = ( + "web_search", + "web_extract", + "browser_navigate", + "browser_click", + "browser_type", + "browser_press", + "browser_snapshot", + "browser_scroll", + "browser_back", + "browser_get_images", + "browser_console", + "browser_vision", + "vision_analyze", + "image_generate", + "skill_view", + "skills_list", + "text_to_speech", + # Kanban worker handoff tools — gated on HERMES_KANBAN_TASK env var + # (set by the kanban dispatcher when spawning a worker). Without these + # in the callback, a worker spawned with openai_runtime=codex_app_server + # could do the work but couldn't report completion back to the kernel, + # making it hang until timeout. Stateless dispatch — they just read + # the env var and write to ~/.hermes/kanban.db. + "kanban_complete", + "kanban_block", + "kanban_comment", + "kanban_heartbeat", + "kanban_show", + "kanban_list", + # NOTE: kanban_create / kanban_unblock / kanban_link are orchestrator- + # only — the kanban tool gates them on HERMES_KANBAN_TASK being unset. + # They're exposed here for orchestrator agents running on the codex + # runtime that need to dispatch new tasks. + "kanban_create", + "kanban_unblock", + "kanban_link", +) + + +def _build_server() -> Any: + """Create the FastMCP server with Hermes tools attached. Lazy imports + so the module can be imported without the mcp package installed + (we degrade to a clear error only when actually run).""" + try: + from mcp.server.fastmcp import FastMCP + except ImportError as exc: # pragma: no cover - install hint + raise ImportError( + f"hermes-tools MCP server requires the 'mcp' package: {exc}" + ) from exc + + # Discover Hermes tools so dispatch works. + from model_tools import ( + get_tool_definitions, + handle_function_call, + ) + + mcp = FastMCP( + "hermes-tools", + instructions=( + "Hermes Agent's tool surface, exposed for use inside a Codex " + "session. Use these for capabilities Codex's built-in toolset " + "doesn't cover: web search/extract, browser automation, " + "subagent delegation, vision, image generation, persistent " + "memory, skills, and cross-session search." + ), + ) + + # Pull authoritative Hermes tool schemas for the ones we expose, so + # MCP clients see the same parameter docs Hermes gives the model. + all_defs = { + td["function"]["name"]: td["function"] + for td in (get_tool_definitions(quiet_mode=True) or []) + if isinstance(td, dict) and td.get("type") == "function" + } + + exposed_count = 0 + + for name in EXPOSED_TOOLS: + spec = all_defs.get(name) + if spec is None: + logger.debug( + "skipping %s — not registered in this Hermes process", name + ) + continue + + description = spec.get("description") or f"Hermes {name} tool" + params_schema = spec.get("parameters") or {"type": "object", "properties": {}} + + # FastMCP wants a Python callable. Build a closure that takes the + # arguments dict, dispatches via handle_function_call, and returns + # the result string. We use add_tool() for full control over the + # input schema (FastMCP's @tool() decorator inspects type hints, + # which we can't get from a JSON schema at runtime). + def _make_handler(tool_name: str): + def _dispatch(**kwargs: Any) -> str: + try: + return handle_function_call(tool_name, kwargs or {}) + except Exception as exc: + logger.exception("tool %s raised", tool_name) + return json.dumps({"error": str(exc), "tool": tool_name}) + _dispatch.__name__ = tool_name + _dispatch.__doc__ = description + return _dispatch + + try: + mcp.add_tool( + _make_handler(name), + name=name, + description=description, + # FastMCP accepts JSON schema directly via the + # input_schema parameter on newer versions; older + # versions use parameters_schema. Try both for compat. + ) + except TypeError: + # Older mcp SDK signature — fall back to decorator-style. + handler = _make_handler(name) + handler = mcp.tool(name=name, description=description)(handler) + + exposed_count += 1 + + logger.info( + "hermes-tools MCP server registered %d/%d tools", + exposed_count, + len(EXPOSED_TOOLS), + ) + return mcp + + +def main(argv: Optional[list[str]] = None) -> int: + """Entry point for `python -m agent.transports.hermes_tools_mcp_server`.""" + argv = argv or sys.argv[1:] + verbose = "--verbose" in argv or "-v" in argv + + log_level = logging.INFO if verbose else logging.WARNING + logging.basicConfig( + level=log_level, + stream=sys.stderr, # MCP uses stdio for protocol — logs MUST go to stderr + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + # Quiet mode: keep Hermes' own banners off stdout (which is the MCP wire). + os.environ.setdefault("HERMES_QUIET", "1") + os.environ.setdefault("HERMES_REDACT_SECRETS", "true") + + try: + server = _build_server() + except ImportError as exc: + sys.stderr.write(f"hermes-tools MCP server cannot start: {exc}\n") + return 2 + + # FastMCP runs with stdio transport by default when launched as a + # subprocess. + try: + server.run() + except KeyboardInterrupt: + return 0 + except Exception as exc: + logger.exception("hermes-tools MCP server crashed") + sys.stderr.write(f"hermes-tools MCP server error: {exc}\n") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent/video_gen_provider.py b/agent/video_gen_provider.py new file mode 100644 index 000000000000..af8bf9faf785 --- /dev/null +++ b/agent/video_gen_provider.py @@ -0,0 +1,299 @@ +""" +Video Generation Provider ABC +============================= + +Defines the pluggable-backend interface for video generation. Providers register +instances via ``PluginContext.register_video_gen_provider()``; the active one +(selected via ``video_gen.provider`` in ``config.yaml``) services every +``video_generate`` tool call. + +Providers live in ``/plugins/video_gen//`` (built-in, auto-loaded +as ``kind: backend``) or ``~/.hermes/plugins/video_gen//`` (user, opt-in +via ``plugins.enabled``). + +Mirrors the ``image_gen`` provider design (``agent/image_gen_provider.py``) so +the two surfaces stay learnable together. + +Unified surface +--------------- +One tool — ``video_generate`` — covers **text-to-video** and **image-to-video**. +The router is the presence of ``image_url``: if it's set, the provider routes +to its image-to-video endpoint; if it's omitted, the provider routes to +text-to-video. Users pick one **model family** (e.g. Pixverse v6, Veo 3.1, +Kling O3 Standard); the provider handles which underlying FAL/xAI endpoint +to hit. + +Video edit and video extend are intentionally NOT exposed in this surface — +the inconsistency across backends is too large for one unified tool. If +those use cases warrant attention later they can ship as separate tools. + +Response shape +-------------- +All providers return a dict built by :func:`success_response` / +:func:`error_response`. Keys: + + success bool + video str | None URL or absolute file path + model str provider-specific model identifier + prompt str echoed prompt + modality str "text" | "image" (which mode was used) + aspect_ratio str provider-native (e.g. "16:9") or "" + duration int seconds (0 if not applicable) + provider str provider name (for diagnostics) + error str only when success=False + error_type str only when success=False +""" + +from __future__ import annotations + +import abc +import base64 +import datetime +import logging +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# Common aspect ratios across providers (Veo / Kling / xAI / Pixverse). The +# tool schema advertises this set as an enum hint, but providers may accept +# a narrower or wider set — they are responsible for clamping. +COMMON_ASPECT_RATIOS: Tuple[str, ...] = ("16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3") +DEFAULT_ASPECT_RATIO = "16:9" + +COMMON_RESOLUTIONS: Tuple[str, ...] = ("480p", "540p", "720p", "1080p") +DEFAULT_RESOLUTION = "720p" + + +# --------------------------------------------------------------------------- +# ABC +# --------------------------------------------------------------------------- + + +class VideoGenProvider(abc.ABC): + """Abstract base class for a video generation backend. + + Subclasses must implement :meth:`generate`. Everything else has sane + defaults — override only what your provider needs. + """ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used in ``video_gen.provider`` config. + + Lowercase, no spaces. Examples: ``xai``, ``fal``, ``google``. + """ + + @property + def display_name(self) -> str: + """Human-readable label shown in ``hermes tools``. Defaults to ``name.title()``.""" + return self.name.title() + + def is_available(self) -> bool: + """Return True when this provider can service calls. + + Typically checks for a required API key and optional-dependency + import. Default: True. + """ + return True + + def list_models(self) -> List[Dict[str, Any]]: + """Return catalog entries for ``hermes tools`` model picker. + + Each entry represents a **model family** that supports text-to-video + and/or image-to-video routing internally:: + + { + "id": "veo-3.1", # required + "display": "Veo 3.1", # optional; defaults to id + "speed": "~60s", # optional + "strengths": "...", # optional + "price": "$0.20/s", # optional + "modalities": ["text", "image"], # optional, advisory + } + + Default: empty list (provider has no user-selectable models). + """ + return [] + + def get_setup_schema(self) -> Dict[str, Any]: + """Return provider metadata for the ``hermes tools`` picker.""" + return { + "name": self.display_name, + "badge": "", + "tag": "", + "env_vars": [], + } + + def default_model(self) -> Optional[str]: + """Return the default model id, or None if not applicable.""" + models = self.list_models() + if models: + return models[0].get("id") + return None + + def capabilities(self) -> Dict[str, Any]: + """Return what this provider supports. + + Returned dict (all keys optional):: + + { + "modalities": ["text", "image"], # which inputs the backend accepts + "aspect_ratios": ["16:9", "9:16", ...], + "resolutions": ["720p", "1080p"], + "max_duration": 15, # seconds + "min_duration": 1, + "supports_audio": True, + "supports_negative_prompt": True, + "max_reference_images": 7, + } + + Used by the tool layer for soft validation and by ``hermes tools`` + for the picker. Default: text-only. + """ + return { + "modalities": ["text"], + "aspect_ratios": list(COMMON_ASPECT_RATIOS), + "resolutions": list(COMMON_RESOLUTIONS), + "max_duration": 10, + "min_duration": 1, + "supports_audio": False, + "supports_negative_prompt": False, + "max_reference_images": 0, + } + + @abc.abstractmethod + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + resolution: str = DEFAULT_RESOLUTION, + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """Generate a video from a prompt (text-to-video) or animate an image + (image-to-video). + + Routing: if ``image_url`` is provided, the provider should route to + its image-to-video endpoint; otherwise text-to-video. The plugin + is responsible for picking the right underlying endpoint within + the user's chosen model family. + + Implementations should return the dict from :func:`success_response` + or :func:`error_response`. ``kwargs`` may contain forward-compat + parameters future versions of the schema will expose — + implementations MUST ignore unknown keys (no TypeError). + """ + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _videos_cache_dir() -> Path: + """Return ``$HERMES_HOME/cache/videos/``, creating parents as needed.""" + from hermes_constants import get_hermes_home + + path = get_hermes_home() / "cache" / "videos" + path.mkdir(parents=True, exist_ok=True) + return path + + +def save_b64_video( + b64_data: str, + *, + prefix: str = "video", + extension: str = "mp4", +) -> Path: + """Decode base64 video data and write under ``$HERMES_HOME/cache/videos/``. + + Returns the absolute :class:`Path` to the saved file. + + Filename format: ``__.``. + """ + raw = base64.b64decode(b64_data) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:8] + path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}" + path.write_bytes(raw) + return path + + +def save_bytes_video( + raw: bytes, + *, + prefix: str = "video", + extension: str = "mp4", +) -> Path: + """Write raw video bytes (e.g. an HTTP download body) to the cache.""" + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:8] + path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}" + path.write_bytes(raw) + return path + + +def success_response( + *, + video: str, + model: str, + prompt: str, + modality: str = "text", + aspect_ratio: str = "", + duration: int = 0, + provider: str, + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a uniform success response dict. + + ``video`` may be an HTTP URL or an absolute filesystem path. + ``modality`` is ``"text"`` (text-to-video) or ``"image"`` (image-to-video) — + indicates which endpoint was actually hit, useful for diagnostics. + """ + payload: Dict[str, Any] = { + "success": True, + "video": video, + "model": model, + "prompt": prompt, + "modality": modality, + "aspect_ratio": aspect_ratio, + "duration": int(duration) if duration else 0, + "provider": provider, + } + if extra: + for k, v in extra.items(): + payload.setdefault(k, v) + return payload + + +def error_response( + *, + error: str, + error_type: str = "provider_error", + provider: str = "", + model: str = "", + prompt: str = "", + aspect_ratio: str = "", +) -> Dict[str, Any]: + """Build a uniform error response dict.""" + return { + "success": False, + "video": None, + "error": error, + "error_type": error_type, + "model": model, + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "provider": provider, + } diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py new file mode 100644 index 000000000000..ad936e29d42b --- /dev/null +++ b/agent/video_gen_registry.py @@ -0,0 +1,117 @@ +""" +Video Generation Provider Registry +================================== + +Central map of registered providers. Populated by plugins at import-time via +``PluginContext.register_video_gen_provider()``; consumed by the +``video_generate`` tool to dispatch each call to the active backend. + +Active selection +---------------- +The active provider is chosen by ``video_gen.provider`` in ``config.yaml``. +If unset, :func:`get_active_provider` applies fallback logic: + +1. If exactly one provider is registered, use it. +2. Otherwise return ``None`` (the tool surfaces a helpful error pointing + the user at ``hermes tools``). + +Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the +same. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.video_gen_provider import VideoGenProvider + +logger = logging.getLogger(__name__) + + +_providers: Dict[str, VideoGenProvider] = {} +_lock = threading.Lock() + + +def register_provider(provider: VideoGenProvider) -> None: + """Register a video generation provider. + + Re-registration (same ``name``) overwrites the previous entry and logs + a debug message — this makes hot-reload scenarios (tests, dev loops) + behave predictably. + """ + if not isinstance(provider, VideoGenProvider): + raise TypeError( + f"register_provider() expects a VideoGenProvider instance, " + f"got {type(provider).__name__}" + ) + name = provider.name + if not isinstance(name, str) or not name.strip(): + raise ValueError("Video gen provider .name must be a non-empty string") + with _lock: + existing = _providers.get(name) + _providers[name] = provider + if existing is not None: + logger.debug("Video gen provider '%s' re-registered (was %r)", name, type(existing).__name__) + else: + logger.debug("Registered video gen provider '%s' (%s)", name, type(provider).__name__) + + +def list_providers() -> List[VideoGenProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + items = list(_providers.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider(name: str) -> Optional[VideoGenProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + with _lock: + return _providers.get(name.strip()) + + +def get_active_provider() -> Optional[VideoGenProvider]: + """Resolve the currently-active provider. + + Reads ``video_gen.provider`` from config.yaml; falls back per the + module docstring. + """ + configured: Optional[str] = None + try: + from hermes_cli.config import load_config + + cfg = load_config() + section = cfg.get("video_gen") if isinstance(cfg, dict) else None + if isinstance(section, dict): + raw = section.get("provider") + if isinstance(raw, str) and raw.strip(): + configured = raw.strip() + except Exception as exc: + logger.debug("Could not read video_gen.provider from config: %s", exc) + + with _lock: + snapshot = dict(_providers) + + if configured: + provider = snapshot.get(configured) + if provider is not None: + return provider + logger.debug( + "video_gen.provider='%s' configured but not registered; falling back", + configured, + ) + + # Fallback: single-provider case + if len(snapshot) == 1: + return next(iter(snapshot.values())) + + return None + + +def _reset_for_tests() -> None: + """Clear the registry. **Test-only.**""" + with _lock: + _providers.clear() diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py new file mode 100644 index 000000000000..7223bbf2cfea --- /dev/null +++ b/agent/web_search_provider.py @@ -0,0 +1,221 @@ +""" +Web Search Provider ABC +======================= + +Defines the pluggable-backend interface for web search and content extraction. +Providers register instances via ``PluginContext.register_web_search_provider()``; +the active one (selected via ``web.search_backend`` / ``web.extract_backend`` / +``web.backend`` in ``config.yaml``) services every ``web_search`` / +``web_extract`` tool call. + +Providers live in ``/plugins/web//`` (built-in, auto-loaded as +``kind: backend``) or ``~/.hermes/plugins/web//`` (user, opt-in via +``plugins.enabled``). + +This ABC is the SINGLE plugin-facing surface for web providers — every +provider in the tree (brave-free, ddgs, searxng, exa, parallel, tavily, +firecrawl) implements it. The legacy in-tree ``tools.web_providers.base`` +ABCs were deleted in PR #25182 along with the per-vendor inline helpers +in ``tools/web_tools.py``; the response-shape contract documented below +is preserved bit-for-bit so the tool wrapper does not have to translate. + +Response shape (preserved from the legacy contract): + +Search results:: + + { + "success": True, + "data": { + "web": [ + {"title": str, "url": str, "description": str, "position": int}, + ... + ] + } + } + +Extract results:: + + { + "success": True, + "data": [ + {"url": str, "title": str, "content": str, + "raw_content": str, "metadata": dict}, + ... + ] + } + +On failure (either capability):: + + {"success": False, "error": str} +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict, List + + +# --------------------------------------------------------------------------- +# ABC +# --------------------------------------------------------------------------- + + +class WebSearchProvider(abc.ABC): + """Abstract base class for a web search/extract/crawl backend. + + Subclasses must implement :meth:`is_available` and at least one of + :meth:`search` / :meth:`extract` / :meth:`crawl`. The + :meth:`supports_search` / :meth:`supports_extract` / :meth:`supports_crawl` + capability flags let the registry route each tool call to the right + provider, and let multi-capability providers (Firecrawl, Tavily, Exa, + …) advertise multiple capabilities from a single class. + """ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used in ``web.search_backend`` / + ``web.extract_backend`` / ``web.backend`` config keys. + + Lowercase, no spaces; hyphens permitted to preserve existing + user-visible names. Examples: ``brave-free``, ``ddgs``, + ``searxng``, ``firecrawl``. + """ + + @property + def display_name(self) -> str: + """Human-readable label shown in ``hermes tools``. Defaults to ``name``.""" + return self.name + + @abc.abstractmethod + def is_available(self) -> bool: + """Return True when this provider can service calls. + + Typically a cheap check (env var present, optional Python dep + importable, instance URL set). Must NOT make network calls — this + runs at tool-registration time and on every ``hermes tools`` paint. + """ + + def supports_search(self) -> bool: + """Return True if this provider implements :meth:`search`.""" + return True + + def supports_extract(self) -> bool: + """Return True if this provider implements :meth:`extract`. + + Both sync and async :meth:`extract` implementations are valid — the + dispatcher detects coroutine functions via + :func:`inspect.iscoroutinefunction` and awaits as needed. Sync + implementations that perform blocking I/O (HTTP, SDK calls) should + ideally wrap in :func:`asyncio.to_thread` at the call site; small + providers can keep their sync shape and let the dispatcher handle + threading. + """ + return False + + def supports_crawl(self) -> bool: + """Return True if this provider implements :meth:`crawl`. + + Crawl differs from extract in that the agent provides a *seed URL* + and the provider walks linked pages on its own — useful for + documentation sites where the agent doesn't know all relevant + URLs upfront. Tavily is the only built-in backend that natively + crawls today; Firecrawl provides a similar capability that we + don't currently surface as a tool. + + Providers that don't crawl should leave this as False; the + dispatcher in :func:`tools.web_tools.web_crawl_tool` will fall + back to its auxiliary-model summarization path. + """ + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a web search. + + Override when :meth:`supports_search` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_search` before calling. + """ + raise NotImplementedError( + f"{self.name} does not support search (override supports_search)" + ) + + def extract(self, urls: List[str], **kwargs: Any) -> Any: + """Extract content from one or more URLs. + + Override when :meth:`supports_extract` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_extract` before calling. + + Return shape: a list of result dicts matching what the legacy + :func:`tools.web_tools.web_extract_tool` post-processing pipeline + expects:: + + [ + { + "url": str, + "title": str, + "content": str, + "raw_content": str, + "metadata": dict, # optional + "error": str, # optional, only on per-URL failure + }, + ... + ] + + Implementations MAY be ``async def`` — the dispatcher detects + coroutines via :func:`inspect.iscoroutinefunction` and awaits. + + ``kwargs`` may carry forward-compat fields (``format``, ``include_raw``, + ``max_chars``) — implementations should ignore unknown keys. + """ + raise NotImplementedError( + f"{self.name} does not support extract (override supports_extract)" + ) + + def crawl(self, url: str, **kwargs: Any) -> Any: + """Crawl a seed URL and return results. + + Override when :meth:`supports_crawl` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_crawl` before calling. + + Return shape: ``{"results": [{"url": str, "title": str, + "content": str, ...}, ...]}`` matching what + :func:`tools.web_tools.web_crawl_tool` post-processing expects. + + Implementations MAY be ``async def``. + + ``kwargs`` may carry forward-compat fields (e.g. ``max_depth``, + ``include_domains``) — implementations should ignore unknown keys. + """ + raise NotImplementedError( + f"{self.name} does not support crawl (override supports_crawl)" + ) + + def get_setup_schema(self) -> Dict[str, Any]: + """Return provider metadata for the ``hermes tools`` picker. + + Used by ``hermes_cli/tools_config.py`` to inject this provider as a + row in the Web Search / Web Extract picker. Shape:: + + { + "name": "Brave Search (Free)", + "badge": "free", + "tag": "No paid tier needed — uses Brave's free API.", + "env_vars": [ + {"key": "BRAVE_SEARCH_API_KEY", + "prompt": "Brave Search API key", + "url": "https://brave.com/search/api/"}, + ], + } + + Default: minimal entry derived from ``display_name``. Override to + expose API key prompts, badges, and instance URL fields. + """ + return { + "name": self.display_name, + "badge": "", + "tag": "", + "env_vars": [], + } diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py new file mode 100644 index 000000000000..c61c16cadb2a --- /dev/null +++ b/agent/web_search_registry.py @@ -0,0 +1,262 @@ +""" +Web Search Provider Registry +============================ + +Central map of registered web providers. Populated by plugins at import-time +via :meth:`PluginContext.register_web_search_provider`; consumed by the +``web_search`` and ``web_extract`` tool wrappers in :mod:`tools.web_tools` to +dispatch each call to the active backend. + +Active selection +---------------- +The active provider is chosen by configuration with this precedence: + +1. ``web.search_backend`` / ``web.extract_backend`` / ``web.crawl_backend`` + (per-capability override). +2. ``web.backend`` (shared fallback). +3. If exactly one capability-eligible provider is registered AND available, + use it. +4. Legacy preference order — ``firecrawl`` → ``parallel`` → ``tavily`` → + ``exa`` → ``searxng`` → ``brave-free`` → ``ddgs`` — filtered by + availability. Matches the historic ``tools.web_tools._get_backend()`` + candidate order so installs that never set a config key keep landing + on the same provider they did before the plugin migration. +5. Otherwise ``None`` — the tool surfaces a helpful error pointing at + ``hermes tools``. + +The capability filter (``supports_search`` / ``supports_extract`` / +``supports_crawl``) is applied at every step so a search-only provider +(``brave-free``) configured as ``web.extract_backend`` correctly falls +through to an extract-capable backend. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +_providers: Dict[str, WebSearchProvider] = {} +_lock = threading.Lock() + + +def register_provider(provider: WebSearchProvider) -> None: + """Register a web search/extract provider. + + Re-registration (same ``name``) overwrites the previous entry and logs + a debug message — makes hot-reload scenarios (tests, dev loops) behave + predictably. + """ + if not isinstance(provider, WebSearchProvider): + raise TypeError( + f"register_provider() expects a WebSearchProvider instance, " + f"got {type(provider).__name__}" + ) + name = provider.name + if not isinstance(name, str) or not name.strip(): + raise ValueError("Web provider .name must be a non-empty string") + with _lock: + existing = _providers.get(name) + _providers[name] = provider + if existing is not None: + logger.debug( + "Web provider '%s' re-registered (was %r)", + name, type(existing).__name__, + ) + else: + logger.debug( + "Registered web provider '%s' (%s)", + name, type(provider).__name__, + ) + + +def list_providers() -> List[WebSearchProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + items = list(_providers.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider(name: str) -> Optional[WebSearchProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + with _lock: + return _providers.get(name.strip()) + + +# --------------------------------------------------------------------------- +# Active-provider resolution +# --------------------------------------------------------------------------- + + +def _read_config_key(*path: str) -> Optional[str]: + """Resolve a dotted config key from ``config.yaml``. Returns None on miss.""" + try: + from hermes_cli.config import load_config + + cfg = load_config() + cur = cfg + for segment in path: + if not isinstance(cur, dict): + return None + cur = cur.get(segment) + if isinstance(cur, str) and cur.strip(): + return cur.strip() + except Exception as exc: + logger.debug("Could not read config %s: %s", ".".join(path), exc) + return None + + +# Legacy preference order — preserves behaviour for users who set no +# ``web.backend`` / ``web._backend`` config key at all. Matches +# the historic candidate order in :func:`tools.web_tools._get_backend` +# (paid providers first so existing paid setups don't get downgraded to +# a free tier on upgrade). Filtered by ``is_available()`` at walk time so +# we don't surface a provider the user has no credentials for. +_LEGACY_PREFERENCE = ( + "firecrawl", + "parallel", + "tavily", + "exa", + "searxng", + "brave-free", + "ddgs", +) + + +def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: + """Resolve the active provider for a capability ("search" | "extract" | "crawl"). + + Resolution rules (in order): + + 1. **Explicit config wins, ignoring availability.** If + ``web.{capability}_backend`` or ``web.backend`` names a registered + provider that supports *capability*, return it even if its + :meth:`is_available` returns False — the dispatcher will surface a + precise "X_API_KEY is not set" error to the user instead of silently + routing somewhere else. Matches legacy + :func:`tools.web_tools._get_backend` behavior for configured names. + + 2. **Single-provider shortcut.** When only one registered provider + supports *capability* AND ``is_available()`` reports True, return it. + + 3. **Legacy preference walk, filtered by availability.** Walk the + :data:`_LEGACY_PREFERENCE` order (firecrawl → parallel → tavily → + exa → searxng → brave-free → ddgs) looking for a provider whose + ``supports_()`` is True AND whose ``is_available()`` is + True. Matches the historic ``tools.web_tools._get_backend()`` + candidate order so users with credentials but no explicit config + key keep landing on the same provider as pre-migration. This is + the path that fires when no config key is set — pick the + highest-priority backend the user actually has credentials for. + + Returns None when no provider is configured AND no available provider + matches the legacy preference; the dispatcher then returns a "set up a + provider" error to the user. + """ + with _lock: + snapshot = dict(_providers) + + def _capable(p: WebSearchProvider) -> bool: + if capability == "search": + return bool(p.supports_search()) + if capability == "extract": + return bool(p.supports_extract()) + if capability == "crawl": + return bool(p.supports_crawl()) + return False + + def _is_available_safe(p: WebSearchProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("provider %s.is_available() raised %s", p.name, exc) + return False + + # 1. Explicit config wins — return regardless of is_available() so the + # user gets a precise downstream error message rather than a silent + # backend switch. Matches _get_backend() in web_tools.py. + if configured: + provider = snapshot.get(configured) + if provider is not None and _capable(provider): + return provider + if provider is None: + logger.debug( + "web backend '%s' configured but not registered; falling back", + configured, + ) + else: + logger.debug( + "web backend '%s' configured but does not support '%s'; falling back", + configured, capability, + ) + + # 2. + 3. Fallback path — filter by availability so we don't surface + # a provider the user has no credentials for. Without this filter, + # a registered-but-unconfigured provider could end up "active" on + # a fresh install with no API keys at all. + eligible = [ + p for p in snapshot.values() + if _capable(p) and _is_available_safe(p) + ] + if len(eligible) == 1: + return eligible[0] + + for legacy in _LEGACY_PREFERENCE: + provider = snapshot.get(legacy) + if ( + provider is not None + and _capable(provider) + and _is_available_safe(provider) + ): + return provider + + return None + + +def get_active_search_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web search provider. + + Reads ``web.search_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + """ + explicit = _read_config_key("web", "search_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="search") + + +def get_active_extract_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web extract provider. + + Reads ``web.extract_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + """ + explicit = _read_config_key("web", "extract_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="extract") + + +def get_active_crawl_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web crawl provider. + + Reads ``web.crawl_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + + Crawl is a niche capability — among built-in providers only Tavily and + Firecrawl implement it. Callers should expect ``None`` and fall back to + a different strategy (e.g. summarize-via-LLM) when neither is + configured. + """ + explicit = _read_config_key("web", "crawl_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="crawl") + + +def _reset_for_tests() -> None: + """Clear the registry. **Test-only.**""" + with _lock: + _providers.clear() diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 6daceba04a9b..3f98b8868ecd 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -364,6 +364,18 @@ compression: # compression of older turns. protect_last_n: 20 + # Number of non-system messages to protect at the head of the transcript, in + # ADDITION to the system prompt (which is always implicitly protected). + # Head messages are NEVER summarized — they survive every compression + # indefinitely. This gives stable early context for short/medium sessions, + # but in long-running sessions that rely on rolling compaction the pinned + # opening turns may not match how you want the session framed over time. + # Set to 0 to preserve ONLY the system prompt (plus the rolling summary + # and recent tail) — the cleanest configuration for long-running sessions. + # Default 3 preserves the system prompt plus the first three non-system + # head messages, matching the pre-feature behaviour. + protect_first_n: 3 + # To pin a specific model/provider for compression summaries, use the # auxiliary section below (auxiliary.compression.provider / model). @@ -669,6 +681,16 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# +# Discord-specific settings (config.yaml top-level, not under platforms:): +# +# discord: +# require_mention: true # Require @mention in server channels (default: true) +# auto_thread: true # Auto-create thread on @mention (default: true) +# free_response_channels: "" # Channel IDs where no mention is needed +# reactions: true # Show processing reactions (default: true) +# history_backfill: true # Recover missed channel messages on mention (default: true) +# history_backfill_limit: 50 # Max messages to scan backwards (default: 50) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) diff --git a/cli.py b/cli.py index da2f32954ba5..728309733b6f 100644 --- a/cli.py +++ b/cli.py @@ -1415,9 +1415,6 @@ def _render_final_assistant_content(text: str, mode: str = "render"): _OUTPUT_HISTORY_SUPPRESSED = False _OUTPUT_HISTORY_MAX_LINES = 200 _OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES) -_ANSI_CONTROL_RE = re.compile( - r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))" -) def _coerce_output_history_limit(value) -> int: @@ -1459,10 +1456,10 @@ def _record_output_history_entry(entry) -> None: def _record_output_history(text: str) -> None: if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED: return - clean = _ANSI_CONTROL_RE.sub("", str(text)).replace("\r", "").rstrip("\n") - if not clean: + normalized = str(text).replace("\r", "").rstrip("\n") + if not normalized: return - for line in clean.splitlines(): + for line in normalized.splitlines(): _record_output_history_entry(line) @@ -1473,6 +1470,7 @@ def _replay_output_history() -> None: return _OUTPUT_HISTORY_REPLAYING = True try: + rendered_lines = [] for entry in tuple(_OUTPUT_HISTORY): if callable(entry): try: @@ -1483,8 +1481,15 @@ def _replay_output_history() -> None: lines = lines.splitlines() else: lines = [entry] - for line in lines: - _pt_print(_PT_ANSI(str(line))) + rendered_lines.extend(str(line) for line in lines) + if rendered_lines: + # Replay after resize can contain hundreds of history lines. A + # per-line prompt_toolkit print forces one synchronous terminal I/O + # and redraw cycle per line, which users perceive as a waterfall of + # old output. Keep the existing history contents unchanged, but + # emit the replay as one ANSI payload so resize recovery does a + # single prompt_toolkit print/redraw. + _pt_print(_PT_ANSI("\n".join(rendered_lines))) except Exception: pass finally: @@ -2639,6 +2644,12 @@ def __init__( # Status bar visibility (toggled via /statusbar) self._status_bar_visible = True + # When True, the input separator rules and the dynamic status bar are + # hidden until the next user input. Set by _recover_after_resize() so a + # SIGWINCH cannot stamp a freshly-drawn status bar on top of one that + # the terminal just reflowed into scrollback — the cause of duplicated + # bars / "blank line flooding" reports (#19280, #22976). + self._status_bar_suppressed_after_resize = False self._resize_recovery_lock = threading.Lock() self._resize_recovery_timer = None self._resize_recovery_pending = False @@ -2703,9 +2714,36 @@ def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) pass def _recover_after_resize(self, app, original_on_resize) -> None: - """Recover a resized classic CLI without desynchronizing cursor state.""" - self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True) - _replay_output_history() + """Recover a resized classic CLI without desynchronizing cursor state. + + Unlike _force_full_redraw, we do NOT clear the physical screen or + scrollback here. The startup banner and tool summary are printed + before prompt_toolkit owns the live chrome, so they live in normal + terminal scrollback. Erasing the screen on SIGWINCH removes that + startup UI and ``_replay_output_history`` cannot reconstruct it + (the banner was never added to ``_OUTPUT_HISTORY``). + + Instead we just reset prompt_toolkit's renderer cache so the next + incremental redraw starts from a clean slate, then let + ``original_on_resize`` recalculate layout for the new size. + + We also flag ``_status_bar_suppressed_after_resize`` so the dynamic + status bar and input separator rules stay hidden until the next user + input. On column shrink the terminal reflows already-rendered status + bar rows into scrollback before prompt_toolkit can erase them; drawing + a fresh full-width bar immediately makes the old and new versions + look duplicated (#19280, #22976). Clearing the suppression on the + next prompt restores the bar cleanly. + """ + self._status_bar_suppressed_after_resize = True + try: + app.renderer.reset(leave_alternate_screen=False) + except Exception: + pass + try: + app.invalidate() + except Exception: + pass original_on_resize() def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: @@ -2940,10 +2978,34 @@ def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool: width = self._get_tui_terminal_width() return width < 64 + @staticmethod + def _scrollback_box_width(width: Optional[int] = None) -> int: + """Return a resize-safe width for printed scrollback box rules. + + Lines already printed to terminal scrollback are reflowed by the + terminal emulator when the column count shrinks. A full-width response + border drawn at, say, 200 columns will wrap into two or three rows of + dashes after the user resizes to 80 columns, looking like duplicated + separator lines (the family of bugs tracked by #18449, #19280, #22976). + + Keep decorative scrollback boxes intentionally narrower than the + viewport so a moderate resize never triggers reflow. The live TUI + footer (status bar, input rule) still uses the full width — only + content that is *stamped into scrollback* needs this clamp. + """ + if width is None: + try: + width = shutil.get_terminal_size((80, 24)).columns + except Exception: + width = 80 + return max(32, min(int(width or 80), 56)) + def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: """Return the visible height for the top/bottom input separator rules.""" if position not in {"top", "bottom"}: raise ValueError(f"Unknown input rule position: {position}") + if getattr(self, "_status_bar_suppressed_after_resize", False): + return 0 if position == "top": return 1 return 0 if self._use_minimal_tui_chrome(width=width) else 1 @@ -3453,7 +3515,7 @@ def _stream_reasoning_delta(self, text: str) -> None: # Open reasoning box on first reasoning token if not getattr(self, "_reasoning_box_opened", False): self._reasoning_box_opened = True - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() r_label = " Reasoning " r_fill = w - 2 - len(r_label) _cprint(f"\n{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}") @@ -3477,7 +3539,7 @@ def _close_reasoning_box(self) -> None: if buf: _cprint(f"{_DIM}{buf}{_RST}") self._reasoning_buf = "" - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}") self._reasoning_box_opened = False @@ -3668,7 +3730,7 @@ def _emit_stream_text(self, text: str) -> None: self._stream_text_ansi = "" if self.show_timestamps: label = f"{label} {datetime.now().strftime('%H:%M')}" - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() fill = w - 2 - HermesCLI._status_bar_display_width(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") @@ -3769,7 +3831,7 @@ def _flush_stream(self) -> None: # Close the response box if self._stream_box_opened: - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") def _reset_stream_state(self) -> None: @@ -5899,6 +5961,38 @@ def _handle_resume_command(self, cmd_original: str) -> None: else: _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + def _handle_sessions_command(self, cmd_original: str) -> None: + """Handle /sessions [list|] — browse or resume previous sessions. + + Without arguments, prints the same recent-sessions table that /resume + shows when called without a target, and tells the user how to resume. + With an explicit subcommand or target, delegates to the resume flow so + ``/sessions `` and ``/resume `` behave identically. + + The TUI ships an interactive picker overlay for this command; the + classic CLI prints an inline list because there is no equivalent + overlay primitive here. Without this handler the canonical name + ``sessions`` falls through ``process_command``'s elif chain and + prints ``Unknown command: sessions`` even though the command is + registered in the central COMMAND_REGISTRY. + """ + parts = cmd_original.split(None, 1) + arg = parts[1].strip() if len(parts) > 1 else "" + sub = arg.lower() + + # Bare /sessions or /sessions list — show recent sessions inline. + if not arg or sub in {"list", "ls", "browse"}: + if not self._session_db: + from hermes_state import format_session_db_unavailable + _cprint(f" {format_session_db_unavailable()}") + return + if not self._show_recent_sessions(reason="sessions"): + _cprint(" (._.) No previous sessions yet.") + return + + # /sessions behaves the same as /resume . + self._handle_resume_command(f"/resume {arg}") + def _handle_branch_command(self, cmd_original: str) -> None: """Handle /branch [name] — fork the current session into a new independent copy. @@ -6596,7 +6690,7 @@ def _handle_model_switch(self, cmd_original: str): /model --provider — switch provider + model /model --provider — switch to provider, auto-detect model """ - from hermes_cli.model_switch import switch_model, parse_model_flags, list_authenticated_providers + from hermes_cli.model_switch import switch_model, parse_model_flags from hermes_cli.providers import get_label # Parse args from the original command @@ -6606,16 +6700,25 @@ def _handle_model_switch(self, cmd_original: str): # Parse --provider and --global flags model_input, explicit_provider, persist_global = parse_model_flags(raw_args) - # Load providers for switch_model (picker path needs them below) - user_provs = None - custom_provs = None + # Single inventory context — replaces the inline config-slice the + # dashboard / TUI used to duplicate. Overlay live session state + # via with_overrides (truthy-only) so empty self.* attrs don't + # clobber disk config. + from hermes_cli.inventory import build_models_payload, load_picker_context + try: - from hermes_cli.config import get_compatible_custom_providers, load_config - cfg = load_config() - user_provs = cfg.get("providers") - custom_provs = get_compatible_custom_providers(cfg) + ctx = load_picker_context().with_overrides( + current_provider=self.provider or "", + current_model=self.model or "", + current_base_url=self.base_url or "", + ) except Exception: - pass + ctx = None + + # switch_model() + _open_model_picker still need the raw provider + # dicts; ConfigContext is the canonical source for both. + user_provs = ctx.user_providers if ctx is not None else None + custom_provs = ctx.custom_providers if ctx is not None else None # No args at all: open prompt_toolkit-native picker modal if not model_input and not explicit_provider: @@ -6623,14 +6726,9 @@ def _handle_model_switch(self, cmd_original: str): provider_display = get_label(self.provider) if self.provider else "unknown" try: - providers = list_authenticated_providers( - current_provider=self.provider or "", - current_base_url=self.base_url or "", - current_model=self.model or "", - user_providers=user_provs, - custom_providers=custom_provs, - max_models=50, - ) + if ctx is None: + raise RuntimeError("inventory context unavailable") + providers = build_models_payload(ctx, max_models=50)["providers"] except Exception: providers = [] @@ -6756,6 +6854,46 @@ def _handle_model_switch(self, cmd_original: str): else: _cprint(" (session only — add --global to persist)") + def _handle_codex_runtime(self, cmd_original: str) -> None: + """Handle /codex-runtime — toggle the codex app-server runtime opt-in. + + Usage: + /codex-runtime — show current state + /codex-runtime auto — Hermes default (chat_completions) + /codex-runtime codex_app_server — hand turns to codex subprocess + /codex-runtime on / off — synonyms for the above + """ + from hermes_cli import codex_runtime_switch as crs + + parts = cmd_original.split(None, 1) + raw_args = parts[1].strip() if len(parts) > 1 else "" + new_value, errors = crs.parse_args(raw_args) + if errors: + for err in errors: + _cprint(f"❌ {err}") + return + + # Load + persist via the existing config helpers + try: + from hermes_cli.config import load_config, save_config + except Exception as exc: + _cprint(f"❌ could not load config: {exc}") + return + cfg = load_config() + + result = crs.apply( + cfg, + new_value, + persist_callback=(save_config if new_value is not None else None), + ) + + prefix = "✓" if result.success else "✗" + for line in result.message.splitlines(): + _cprint(f" {prefix} {line}" if line.startswith("openai_runtime") + else f" {line}") + if result.success and result.requires_new_session: + _cprint(" Tip: `/reset` starts a new session immediately.") + def _should_handle_model_command_inline(self, text: str, has_images: bool = False) -> bool: """Return True when /model should be handled immediately on the UI thread.""" if not text or has_images or not _looks_like_slash_command(text): @@ -7434,8 +7572,12 @@ def process_command(self, command: str) -> bool: self.new_session(title=title) elif canonical == "resume": self._handle_resume_command(cmd_original) + elif canonical == "sessions": + self._handle_sessions_command(cmd_original) elif canonical == "model": self._handle_model_switch(cmd_original) + elif canonical == "codex-runtime": + self._handle_codex_runtime(cmd_original) elif canonical == "gquota": self._handle_gquota_command(cmd_original) @@ -7583,6 +7725,8 @@ 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": @@ -7600,6 +7744,8 @@ def process_command(self, command: str) -> bool: exec_cmd = qcmd.get("command", "") if exec_cmd: try: + # shell=True is intentional: quick_commands are user-defined + # shell snippets from config.yaml — not agent/LLM controlled. result = subprocess.run( exec_cmd, shell=True, capture_output=True, text=True, timeout=30 @@ -7817,6 +7963,7 @@ def _bg_thinking(text: str) -> None: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) else: _cprint(" (No response generated)") @@ -8179,6 +8326,81 @@ 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 current subgoals + /subgoal append a criterion + /subgoal remove drop subgoal n (1-based) + /subgoal clear wipe all subgoals + + Subgoals are extra criteria the user adds mid-loop. They get + appended to both the judge prompt (verdict must consider them) + and the continuation prompt (agent sees them) on the next turn + boundary. No special kick — the running turn finishes, the next + judge call includes them. + """ + parts = (cmd or "").strip().split(None, 2) + 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 → list current subgoals. + if not arg: + _cprint(f" {mgr.status_line()}") + _cprint(f" {mgr.render_subgoals()}") + return + + tokens = arg.split(None, 1) + verb = tokens[0].lower() + rest = tokens[1].strip() if len(tokens) > 1 else "" + + 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 subgoal {idx}: {removed}") + return + + if verb == "clear": + try: + prev = mgr.clear_subgoals() + except RuntimeError as exc: + _cprint(f" /subgoal clear: {exc}") + return + if prev: + _cprint(f" ✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}.") + else: + _cprint(f" {_DIM}No subgoals to clear.{_RST}") + return + + # Otherwise — append the whole arg as a new subgoal. + try: + text = mgr.add_subgoal(arg) + except (ValueError, RuntimeError) as exc: + _cprint(f" /subgoal: {exc}") + return + idx = len(mgr.state.subgoals) if mgr.state else 0 + _cprint(f" ✓ Added subgoal {idx}: {text}") + def _maybe_continue_goal_after_turn(self) -> None: """Hook run after every CLI turn. Judges + maybe re-queues. @@ -8205,10 +8427,36 @@ def _maybe_continue_goal_after_turn(self) -> None: # If a real user message is already queued, don't inject a # continuation prompt on top — let the user's turn go first. + # Slash commands don't count as "real user messages" for this + # check: they're inspection/mutation (e.g. /subgoal added mid- + # run) and the process_loop dispatches them via process_command, + # not via chat(). If we treat a queued /subgoal as preempting, + # the goal loop silently stalls — we'd return here, then the + # slash command consumes its queue slot via process_command() + # which never re-fires the goal hook. Peek at all queued entries + # and only defer when there's a non-slash payload. try: - if getattr(self, "_pending_input", None) is not None \ - and not self._pending_input.empty(): - return + pending = getattr(self, "_pending_input", None) + if pending is not None and not pending.empty(): + has_real_message = False + try: + # Queue.queue is the underlying deque — direct peek + # without disturbing FIFO order. + for entry in list(pending.queue): + # Bundled payloads are (text, images) tuples; + # unpack for inspection. + if isinstance(entry, tuple) and entry: + entry = entry[0] + if isinstance(entry, str) and _looks_like_slash_command(entry): + continue + has_real_message = True + break + except Exception: + # Fallback: if we can't introspect the queue, behave + # like the old check and defer to be safe. + has_real_message = True + if has_real_message: + return except Exception: pass @@ -9198,7 +9446,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: Updates the TUI spinner widget so the user can see what the agent is doing during tool execution (fills the gap between thinking - spinner and next response). Also plays audio cue in voice mode. + spinner and next response). On tool.started, records a monotonic timestamp so get_spinner_text() can show a live elapsed timer (the TUI poll loop already invalidates @@ -9277,20 +9525,6 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: ) self._invalidate() - if not self._voice_mode: - return - if not function_name or function_name.startswith("_"): - return - try: - from tools.voice_mode import play_beep - threading.Thread( - target=play_beep, - kwargs={"frequency": 1200, "duration": 0.06, "count": 1}, - daemon=True, - ).start() - except Exception: - pass - def _on_tool_start(self, tool_call_id: str, function_name: str, function_args: dict): """Capture local before-state for write-capable tools.""" try: @@ -9895,7 +10129,7 @@ def _approval_callback(self, command: str, description: str, import time as _time with self._approval_lock: - timeout = 60 + timeout = int(CLI_CONFIG.get("approvals", {}).get("timeout", 60)) response_queue = queue.Queue() self._approval_state = { @@ -10389,7 +10623,7 @@ def display_callback(sentence: str): nonlocal _streaming_box_opened if not _streaming_box_opened: _streaming_box_opened = True - w = self.console.width + w = self._scrollback_box_width(getattr(self.console, "width", 80)) label = " ⚕ Hermes " if self.show_timestamps: label = f"{label}{datetime.now().strftime('%H:%M')} " @@ -10674,7 +10908,7 @@ def run_agent(): if self.show_reasoning and result and not _reasoning_already_shown: reasoning = result.get("last_reasoning") if reasoning: - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() r_label = " Reasoning " r_fill = w - 2 - len(r_label) r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}" @@ -10705,7 +10939,7 @@ def run_agent(): already_streamed = self._stream_started and self._stream_box_opened and not is_error_response if use_streaming_tts and _streaming_box_opened and not is_error_response: # Text was already printed sentence-by-sentence; just close the box - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") elif already_streamed: # Response was already streamed token-by-token with box framing; @@ -10721,6 +10955,7 @@ def run_agent(): style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) @@ -12754,7 +12989,10 @@ def _get_voice_status(): # guard against any future width mismatch. wrap_lines=False, ), - filter=Condition(lambda: cli_ref._status_bar_visible), + filter=Condition( + lambda: cli_ref._status_bar_visible + and not getattr(cli_ref, "_status_bar_suppressed_after_resize", False) + ), ) # Allow wrapper CLIs to register extra keybindings. @@ -12923,6 +13161,10 @@ def process_loop(): if not user_input: continue + # The user has typed and submitted something, so any + # post-resize transient suppression should end here. + self._status_bar_suppressed_after_resize = False + # Unpack image payload: (text, [Path, ...]) or plain str submit_images = [] if isinstance(user_input, tuple): diff --git a/gateway/config.py b/gateway/config.py index 11bc8b75a0b4..7180f1ddb84a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -74,6 +74,24 @@ def _normalize_notice_delivery(value: Any, default: str = "public") -> str: return default +def _ensure_platform_extra_dict(platforms_data: dict, name: str) -> tuple[dict, dict]: + """Get-or-create ``platforms_data[name]`` and its nested ``extra`` dict. + + Both slots are coerced to ``{}`` if a non-dict value is encountered, so + callers can safely write keys without type-checking. Returns + ``(plat_data, extra)`` for in-place mutation. + """ + plat_data = platforms_data.setdefault(name, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[name] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + return plat_data, extra + + # Module-level cache for bundled platform plugin names (lives outside the # enum so it doesn't become an accidental enum member). _Platform__bundled_plugin_names: Optional[set] = None @@ -717,6 +735,10 @@ def load_gateway_config() -> GatewayConfig: gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"] streaming_cfg = yaml_cfg.get("streaming") + if not isinstance(streaming_cfg, dict): + # Fall back to nested gateway.streaming written by + # ``hermes config set gateway.streaming.*`` + streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -755,7 +777,27 @@ def load_gateway_config() -> GatewayConfig: merged["extra"] = merged_extra platforms_data[plat_name] = merged gw_data["platforms"] = platforms_data - for plat in Platform: + # Iterate built-in platforms plus any registered plugin platforms + # so plugin authors get the same shared-key bridging (#24836). + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry as _pr + except Exception as e: + logger.debug("plugin discovery skipped: %s", e) + _pr = None + + _shared_loop_targets: list = list(Platform) + if _pr is not None: + for _entry in _pr.plugin_entries(): + try: + _plat = Platform(_entry.name) + except (ValueError, KeyError): + continue + if _plat not in _shared_loop_targets: + _shared_loop_targets.append(_plat) + + for plat in _shared_loop_targets: if plat == Platform.LOCAL: continue platform_cfg = yaml_cfg.get(plat.value) @@ -810,20 +852,38 @@ def load_gateway_config() -> GatewayConfig: enabled_was_explicit = "enabled" in platform_cfg if not bridged and not enabled_was_explicit: continue - plat_data = platforms_data.setdefault(plat.value, {}) - if not isinstance(plat_data, dict): - plat_data = {} - platforms_data[plat.value] = plat_data + plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value) if enabled_was_explicit: plat_data["enabled"] = platform_cfg["enabled"] - extra = plat_data.setdefault("extra", {}) - if not isinstance(extra, dict): - extra = {} - plat_data["extra"] = extra if plat == Platform.SLACK and enabled_was_explicit: extra["_enabled_explicit"] = True extra.update(bridged) + # Plugin-owned YAML→env config bridges (#24836). See + # ``PlatformEntry.apply_yaml_config_fn`` for the hook contract. + # Order: shared-key loop (above) → this dispatch → legacy hardcoded + # blocks (below; no-op when a hook already set their env var) → + # ``_apply_env_overrides()`` after ``GatewayConfig.from_dict``. + if _pr is not None: + for entry in _pr.all_entries(): + if entry.apply_yaml_config_fn is None: + continue + platform_cfg = yaml_cfg.get(entry.name) + if not isinstance(platform_cfg, dict): + continue + try: + seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg) + except Exception as e: + logger.debug( + "apply_yaml_config_fn for %s raised: %s", + entry.name, e, + ) + continue + if not isinstance(seeded, dict) or not seeded: + continue + _, extra = _ensure_platform_extra_dict(platforms_data, entry.name) + extra.update(seeded) + # Slack settings → env vars (env vars take precedence) slack_cfg = yaml_cfg.get("slack", {}) if isinstance(slack_cfg, dict): @@ -852,6 +912,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(discord_cfg, dict): if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() + if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): + os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): @@ -879,6 +941,14 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ntc, list): ntc = ",".join(str(v) for v in ntc) os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + # history_backfill: recover missed channel messages for shared sessions + # when require_mention is active. Fetches messages between bot turns + # and prepends them to the user message for context. + if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): + os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + hbl = discord_cfg.get("history_backfill_limit") + if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): + os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) # allow_mentions: granular control over what the bot can ping. # Safe defaults (no @everyone/roles) are applied in the adapter; # these YAML keys only override when set and let users opt back diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 96bfe1ccadf3..97f0c0e1d747 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -119,6 +119,22 @@ class PlatformEntry: # Signature: () -> Optional[dict[str, Any]] env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None + # ── YAML→env config bridge ── + # Optional: translate this platform's ``config.yaml`` keys into env vars + # and/or seed ``PlatformConfig.extra`` directly. Lets a plugin own its + # YAML config translation instead of forcing core ``gateway/config.py`` + # to know every platform's schema. + # + # Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict] + # Called from ``load_gateway_config()`` after the generic shared-key loop + # and before ``_apply_env_overrides``. Mutating ``os.environ`` is allowed + # (use ``not os.getenv(...)`` guards to preserve env > YAML precedence); + # any returned dict is merged into ``PlatformConfig.extra``. Exceptions + # are caught and logged at debug level. + # See website/docs/developer-guide/adding-platform-adapters.md for the + # full contract and a worked example. + apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None + # Optional: home-channel env var name for cron/notification delivery # (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this # platform as a valid ``deliver=`` target and reads the env var to diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index ffe67e046b17..c373b9fa0b90 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -21,6 +21,14 @@ status display, gateway setup, and more. constructed. Without this, env-only setups don't surface in `hermes gateway status` or `get_connected_platforms()` until the SDK instantiates. +- `apply_yaml_config_fn: (yaml_cfg, platform_cfg) -> Optional[dict]` — + translate this platform's `config.yaml` keys into env vars and/or seed + `PlatformConfig.extra` directly. Lets a plugin own its YAML schema + instead of growing core `gateway/config.py` boilerplate per platform. + Mutating `os.environ` is allowed (use `not os.getenv(...)` guards to + preserve env > YAML precedence); the returned dict is merged into + `PlatformConfig.extra`. Called during `load_gateway_config()` after + the generic shared-key loop and before `_apply_env_overrides()`. - `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var. When set, `deliver=` cron jobs route to this var without editing `cron/scheduler.py`'s hardcoded sets. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 0bf7b9a2ad95..d03bc282ed34 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -955,6 +955,12 @@ class MessageEvent: # Per-channel ephemeral system prompt (e.g. Discord channel_prompts). # Applied at API call time and never persisted to transcript history. channel_prompt: Optional[str] = None + + # Channel context recovered by history backfill (e.g. messages between + # bot turns that were missed due to require_mention). Kept separate + # from ``text`` so the sender-prefix logic in run.py can operate on the + # trigger message alone, then prepend this context afterward. + channel_context: Optional[str] = None # Internal flag — set for synthetic events (e.g. background process # completion notifications) that must bypass user authorization checks. @@ -1774,8 +1780,12 @@ async def send_clarify( The default implementation falls back to a numbered text list, which works on every platform — the user replies with a number ("2") or with the literal choice text, and the gateway intercepts - and resolves. Adapters with native button UIs (Telegram, Discord) - SHOULD override this for a richer UX. + and resolves. For the text fallback path, the default calls + ``mark_awaiting_text()`` so that the gateway text-intercept + (:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the + user's reply instead of timing out. + Adapters with native button UIs (Telegram, Discord) SHOULD + override this for a richer UX. """ if choices: lines = [f"❓ {question}", ""] @@ -1784,6 +1794,10 @@ async def send_clarify( lines.append("") lines.append("Reply with the number, the option text, or your own answer.") text = "\n".join(lines) + # Text fallback: enable text-capture so the gateway intercept + # picks up the user's typed reply (e.g. "2" or choice text). + from tools.clarify_gateway import mark_awaiting_text + mark_awaiting_text(clarify_id) else: text = f"❓ {question}" return await self.send( diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1817ece173db..a3904630fa96 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -589,6 +589,10 @@ def __init__(self, config: PlatformConfig): # chunk only, default), "all" (reply-reference on every chunk). self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' self._slash_commands: bool = self.config.extra.get("slash_commands", True) + # In-memory cache of the bot's last message ID per channel, used by + # history backfill to skip the full scan on hot paths. Falls back to + # scanning channel.history() on cache miss (cold start / restart). + self._last_self_message_id: Dict[str, str] = {} async def connect(self) -> bool: """Connect to Discord and start receiving events.""" @@ -1459,6 +1463,12 @@ async def send( raise message_ids.append(str(msg.id)) + # Track the last message we sent in this channel for history + # backfill — avoids a full channel.history() scan on hot paths. + if message_ids: + _target_id = thread_id or chat_id + self._last_self_message_id[_target_id] = message_ids[-1] + return SendResult( success=True, message_id=message_ids[0] if message_ids else None, @@ -3577,6 +3587,153 @@ def _discord_free_response_channels(self) -> set: return {part.strip() for part in s.split(",") if part.strip()} return set() + def _discord_thread_require_mention(self) -> bool: + """Return whether thread participation requires @mention to follow up. + + When ``False`` (default), once the bot has participated in a thread it + keeps responding to every message in that thread without needing to be + mentioned again — useful for one-on-one conversations. + + When ``True``, the @mention requirement is enforced inside threads as + well. Set this when multiple bots share a thread and you want each + one to only fire on explicit @mention, avoiding bot-to-bot loops or + unwanted cross-replies. + """ + configured = self.config.extra.get("thread_require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + + def _discord_history_backfill(self) -> bool: + """Return whether history backfill is enabled for shared sessions.""" + configured = self.config.extra.get("history_backfill") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes") + + def _discord_history_backfill_limit(self) -> int: + """Return the max number of messages to scan backwards for context. + + In practice the scan usually stops much earlier — at the bot's own + last message in the channel (the natural partition point). This + limit is a safety cap for cold starts and long gaps where no prior + bot message exists in recent history. + """ + configured = self.config.extra.get("history_backfill_limit") + if configured is not None: + try: + return int(configured) + except (ValueError, TypeError): + pass + raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50") + try: + return int(raw) + except (ValueError, TypeError): + return 50 + + async def _fetch_channel_context( + self, + channel: Any, + before: "DiscordMessage", + ) -> str: + """Fetch recent channel messages for conversational context. + + Scans backwards from *before* and collects messages until it hits + a message sent by this bot (the natural partition point between + bot turns) or reaches ``history_backfill_limit``. + + Returns a formatted block like:: + + [Recent channel messages] + [Alice] some message + [Bob [bot]] another message + + Returns an empty string if no context is available. + """ + limit = self._discord_history_backfill_limit() + if limit <= 0: + return "" + + # Determine which bot messages to include in context + allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + include_other_bots = allow_bots_raw != "none" + + # Use the in-memory cache to narrow the fetch window on hot paths. + # If we know our last message ID in this channel, pass it as `after` + # to avoid scanning the full limit. Falls back to scanning on cache + # miss (cold start / restart). + # Guard: only use the cache when it's chronologically before the + # trigger — Discord snowflake IDs are monotonically increasing, so + # a simple int comparison suffices. + channel_id = str(getattr(channel, "id", "")) + _cached_id = self._last_self_message_id.get(channel_id) + _after_obj = None + try: + if _cached_id and int(_cached_id) < int(before.id): + _after_obj = discord.Object(id=int(_cached_id)) + except (ValueError, TypeError): + pass # Malformed cache entry — fall back to cold-start scan + + try: + collected = [] + # IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x + # silently flips the default to True when `after=` is supplied, + # which would select the *earliest* N messages after our last + # response instead of the *latest* N before the trigger. In + # high-traffic windows that returns stale tool traces and drops + # the actual final answer. See the regression test + # `test_fetch_channel_context_cache_uses_latest_window_when_after_set`. + async for msg in channel.history( + limit=limit, + before=before, + after=_after_obj, + oldest_first=False, + ): + # Stop at our own message — this is the partition point. + # Everything before this is already in the session transcript. + # (Redundant when _after_obj is set, but needed for cold start.) + if msg.author == self._client.user: + break + + # Skip system messages (pins, joins, thread renames, etc.) + if msg.type not in (discord.MessageType.default, discord.MessageType.reply): + continue + + # Respect DISCORD_ALLOW_BOTS for other bots. + # For history context, "mentions" is treated as "all" — we are + # deciding what context to show, not whether to respond. + if getattr(msg.author, "bot", False) and not include_other_bots: + continue + + content = getattr(msg, "clean_content", msg.content) or "" + if not content and msg.attachments: + content = "(attachment)" + if not content: + continue + + name = msg.author.display_name + if getattr(msg.author, "bot", False): + name = f"{name} [bot]" + collected.append(f"[{name}] {content}") + + if not collected: + return "" + + # channel.history returns newest-first (oldest_first=False); reverse for chronological order + collected.reverse() + return "[Recent channel messages]\n" + "\n".join(collected) + + except discord.Forbidden: + logger.debug("[%s] Missing permissions to fetch channel history", self.name) + return "" + except Exception as e: + logger.warning("[%s] Failed to fetch channel history: %s", self.name, e) + return "" + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -3877,6 +4034,84 @@ async def send_slash_confirm( except Exception as e: return SendResult(success=False, error=str(e)) + async def send_clarify( + self, + chat_id: str, + question: str, + choices: Optional[list], + clarify_id: str, + session_key: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Render a clarify prompt with one Discord button per choice. + + Multi-choice mode (``choices`` non-empty): renders a button per option + plus a final "✏️ Other (type answer)" button. Picking "Other" flips + the clarify entry into text-capture mode so the next user message in + the session becomes the response. Numeric clicks resolve immediately + via ``resolve_gateway_clarify(clarify_id, choice_text)``. + + Open-ended mode (``choices`` empty/None): renders the question as + plain embed text — no buttons. The gateway's text-intercept captures + the next message in this session and resolves the clarify. + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + + try: + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = self._client.get_channel(int(target_id)) + if not channel: + channel = await self._client.fetch_channel(int(target_id)) + + # Discord embed description limit is 4096; trim conservatively. + max_desc = 4088 + body = str(question or "").strip() + if len(body) > max_desc: + body = body[: max_desc - 3] + "..." + + embed = discord.Embed( + title="❓ Hermes needs your input", + description=body, + color=discord.Color.orange(), + ) + + clean_choices = [ + str(c).strip() for c in (choices or []) if c is not None and str(c).strip() + ] + # Discord allows up to 5 buttons per row, 5 rows per view = 25. + # We reserve one slot for the "Other" button, so cap at 24 choices. + clean_choices = clean_choices[:24] + + if clean_choices: + embed.add_field( + name="Choices", + value="Pick one below, or click ✏️ Other to type a custom answer.", + inline=False, + ) + view = ClarifyChoiceView( + choices=clean_choices, + clarify_id=clarify_id, + allowed_user_ids=self._allowed_user_ids, + allowed_role_ids=self._allowed_role_ids, + ) + else: + embed.add_field( + name="Reply", + value="Reply in this channel with your answer.", + inline=False, + ) + view = None + + msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed) + return SendResult(success=True, message_id=str(msg.id)) + except Exception as e: + logger.warning("[%s] send_clarify failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + async def send_update_prompt( self, chat_id: str, prompt: str, default: str = "", session_key: str = "", @@ -4167,6 +4402,17 @@ async def _handle_message(self, message: DiscordMessage) -> None: raw_content = message.content.strip() normalized_content = raw_content mention_prefix = False + + snapshot_attachments = [] + if hasattr(message, "message_snapshots") and message.message_snapshots: + snapshot_text_parts = [] + for snap in message.message_snapshots: + if getattr(snap, "content", None): + snapshot_text_parts.append(snap.content.strip()) + snapshot_attachments.extend(getattr(snap, "attachments", []) or []) + if snapshot_text_parts and not raw_content: + raw_content = "\n".join(snapshot_text_parts) + normalized_content = raw_content if self._client.user and self._client.user in message.mentions: mention_prefix = True normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip() @@ -4209,8 +4455,15 @@ async def _handle_message(self, message: DiscordMessage) -> None: ) # Skip the mention check if the message is in a thread where - # the bot has previously participated (auto-created or replied in). - in_bot_thread = is_thread and thread_id in self._threads + # the bot has previously participated (auto-created or replied in) + # — UNLESS thread_require_mention is enabled, in which case threads + # are gated the same as channels. Useful when multiple bots share + # a thread. + in_bot_thread = ( + is_thread + and thread_id in self._threads + and not self._discord_thread_require_mention() + ) if require_mention and not is_free_channel and not in_bot_thread: if self._client.user not in message.mentions and not mention_prefix: @@ -4223,7 +4476,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: if not is_thread and not isinstance(message.channel, discord.DMChannel): 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) + skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel 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: @@ -4235,13 +4488,15 @@ async def _handle_message(self, message: DiscordMessage) -> None: auto_threaded_channel = thread self._threads.mark(thread_id) + all_attachments = list(message.attachments) + snapshot_attachments + # Determine message type msg_type = MessageType.TEXT if normalized_content.startswith("/"): msg_type = MessageType.COMMAND - elif message.attachments: + elif all_attachments: # Check attachment types - for att in message.attachments: + for att in all_attachments: if att.content_type: if att.content_type.startswith("image/"): msg_type = MessageType.PHOTO @@ -4300,7 +4555,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: media_urls = [] media_types = [] pending_text_injection: Optional[str] = None - for att in message.attachments: + for att in all_attachments: content_type = att.content_type or "unknown" if content_type.startswith("image/"): try: @@ -4387,9 +4642,50 @@ async def _handle_message(self, message: DiscordMessage) -> None: if pending_text_injection: event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection + # ── History backfill ───────────────────────────────────────── + # When require_mention is active, the bot only processes messages + # that @mention it. Messages in the channel between bot turns are + # invisible to the session transcript. To recover that context, + # fetch recent channel history and prepend it to the user message. + # + # The fetch window is: everything after the bot's last message in + # the channel up to (but not including) the current trigger. On + # cold start (no prior bot message found), fetch the last N messages + # and stop at the first self-message encountered. + # + # Threads naturally scope to thread-only history (channel.history() + # on a thread returns only that thread's messages). DMs are skipped + # because every DM message triggers the bot — there's no mention gap + # to fill; the session transcript already has everything. + # + # Per-user sessions also benefit: Alice's session is missing the + # other-channel-participants' context, and her own messages from + # before she mentioned the bot. Backfill fills that gap. + # + # Messages that arrive while the bot is processing (between trigger + # and response) are not captured — this is an accepted simplification + # to keep the partition rule clean. + _channel_context = None + _is_dm = isinstance(message.channel, discord.DMChannel) + if not _is_dm: + _needed_mention = ( + require_mention + and not is_free_channel + and not in_bot_thread + ) + _backfill_enabled = self._discord_history_backfill() + if _needed_mention and _backfill_enabled: + _backfill_text = await self._fetch_channel_context( + message.channel, before=message, + ) + if _backfill_text: + _channel_context = _backfill_text + # Defense-in-depth: prevent empty user messages from entering session - # (can happen when user sends @mention-only with no other text) - if not event_text or not event_text.strip(): + # (can happen when user sends @mention-only with no other text). + # When channel_context is present, a bare mention means "catch me up" + # — the context IS the message, so skip the placeholder. + if (not event_text or not event_text.strip()) and not _channel_context: event_text = "(The user sent a message with no text content)" _chan = message.channel @@ -4418,6 +4714,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: timestamp=message.created_at, auto_skill=_skills, channel_prompt=_channel_prompt, + channel_context=_channel_context, ) # Track thread participation so the bot won't require @mention for @@ -5099,3 +5396,188 @@ async def _on_cancel(self, interaction: discord.Interaction): async def on_timeout(self): self.resolved = True self.clear_items() + + + class ClarifyChoiceView(discord.ui.View): + """Interactive button view for the clarify tool's multiple-choice prompts. + + Renders one button per choice (max 24) plus a final ``✏️ Other`` button. + Picking a numeric choice resolves the gateway clarify entry immediately; + picking ``Other`` flips the entry into text-capture mode so the next + user message in the session becomes the response (the gateway's + text-intercept handles the resolution). + + Auth gating mirrors ``ExecApprovalView`` — only users/roles in the + Discord adapter's allowlist may answer. Single-use: after the first + valid click all buttons disable and the embed updates to show who + answered and what they chose. + """ + + def __init__( + self, + choices: List[str], + clarify_id: str, + allowed_user_ids: set, + allowed_role_ids: Optional[set] = None, + ): + super().__init__(timeout=300) # 5-minute timeout + self.choices = list(choices)[:24] + self.clarify_id = clarify_id + self.allowed_user_ids = allowed_user_ids + self.allowed_role_ids = allowed_role_ids or set() + self.resolved = False + + for index, choice in enumerate(self.choices): + # Discord button labels are capped at 80 chars. + label_body = choice if len(choice) <= 75 else choice[:72] + "..." + button = discord.ui.Button( + label=f"{index + 1}. {label_body}", + style=discord.ButtonStyle.primary, + custom_id=f"clarify:{clarify_id}:{index}", + ) + button.callback = self._make_choice_callback(index, choice) + self.add_item(button) + + other_btn = discord.ui.Button( + label="✏️ Other (type answer)", + style=discord.ButtonStyle.secondary, + custom_id=f"clarify:{clarify_id}:other", + ) + other_btn.callback = self._on_other + self.add_item(other_btn) + + def _check_auth(self, interaction: "discord.Interaction") -> bool: + return _component_check_auth( + interaction, self.allowed_user_ids, self.allowed_role_ids, + ) + + def _make_choice_callback(self, index: int, choice: str): + async def _callback(interaction: "discord.Interaction"): + await self._resolve_choice(interaction, index, choice) + return _callback + + async def _resolve_choice( + self, + interaction: "discord.Interaction", + index: int, + choice: str, + ) -> None: + """Resolve the clarify with a chosen option.""" + if self.resolved: + await interaction.response.send_message( + "This prompt has already been answered~", ephemeral=True, + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized to answer this prompt~", ephemeral=True, + ) + return + + self.resolved = True + for child in self.children: + child.disabled = True + + embed = interaction.message.embeds[0] if ( + interaction.message and interaction.message.embeds + ) else None + if embed: + user = getattr(interaction, "user", None) + display_name = getattr(user, "display_name", "user") + embed.color = discord.Color.green() + embed.set_footer(text=f"Answered by {display_name}: {choice}") + + try: + await interaction.response.edit_message(embed=embed, view=self) + except Exception: + logger.debug( + "Discord clarify edit_message failed for %s", + self.clarify_id, + exc_info=True, + ) + try: + await interaction.response.defer() + except Exception: + pass + + # Resolve via the gateway clarify primitive — same mechanism as + # Telegram. Look up the canonical choice text from the entry so + # we round-trip the original value, not a button-label variant. + resolved_text: Optional[str] = None + try: + from tools.clarify_gateway import _entries as _clarify_entries # type: ignore + entry = _clarify_entries.get(self.clarify_id) + if entry and entry.choices and 0 <= index < len(entry.choices): + resolved_text = entry.choices[index] + except Exception: + resolved_text = None + if resolved_text is None: + resolved_text = choice + + try: + from tools.clarify_gateway import resolve_gateway_clarify + resolved = resolve_gateway_clarify(self.clarify_id, resolved_text) + logger.info( + "Discord clarify button resolved (id=%s, choice=%r, user=%s, ok=%s)", + self.clarify_id, resolved_text, + getattr(getattr(interaction, "user", None), "display_name", "?"), + resolved, + ) + except Exception as exc: + logger.error( + "Discord clarify resolve_gateway_clarify failed (id=%s): %s", + self.clarify_id, exc, + ) + + async def _on_other(self, interaction: "discord.Interaction") -> None: + """Flip the clarify entry into text-capture mode.""" + if self.resolved: + await interaction.response.send_message( + "This prompt has already been answered~", ephemeral=True, + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized to answer this prompt~", ephemeral=True, + ) + return + + # Don't pop the entry — the gateway's text-intercept needs it + # until the user actually types. Just mark it as awaiting text + # and disable the buttons so the user can't double-click. + try: + from tools.clarify_gateway import mark_awaiting_text + mark_awaiting_text(self.clarify_id) + except Exception as exc: + logger.warning( + "Discord clarify mark_awaiting_text failed (id=%s): %s", + self.clarify_id, exc, + ) + + self.resolved = True + for child in self.children: + child.disabled = True + + embed = interaction.message.embeds[0] if ( + interaction.message and interaction.message.embeds + ) else None + if embed: + user = getattr(interaction, "user", None) + display_name = getattr(user, "display_name", "user") + embed.color = discord.Color.blue() + embed.set_footer( + text=f"Awaiting typed response from {display_name}…", + ) + + try: + await interaction.response.edit_message(embed=embed, view=self) + except Exception: + try: + await interaction.response.defer() + except Exception: + pass + + async def on_timeout(self): + self.resolved = True + for child in self.children: + child.disabled = True diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index e7be062e84c1..8d60046d35d0 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -1300,12 +1300,12 @@ def _apply_runtime_ws_overrides() -> None: except Exception: logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True) - async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: + def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs: kwargs["ping_interval"] = adapter._ws_ping_interval if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs: kwargs["ping_timeout"] = adapter._ws_ping_timeout - return await original_connect(*args, **kwargs) + return original_connect(*args, **kwargs) def _configure_with_overrides(conf: Any) -> Any: if original_configure is None: @@ -1346,22 +1346,62 @@ def check_feishu_requirements() -> bool: """Check if Feishu/Lark dependencies are available. Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")`` - on first call if not present. + on first call if not present. Rebinds all module-level globals on success. """ - global FEISHU_AVAILABLE if FEISHU_AVAILABLE: return True - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.feishu", prompt=False) - except Exception: - return False - try: - import lark_oapi # noqa: F401 - except ImportError: - return False - FEISHU_AVAILABLE = True - return True + + def _import(): + import lark_oapi as lark + from lark_oapi.api.application.v6 import GetApplicationRequest + from lark_oapi.api.im.v1 import ( + CreateFileRequest, CreateFileRequestBody, + CreateImageRequest, CreateImageRequestBody, + CreateMessageRequest, CreateMessageRequestBody, + GetChatRequest, GetMessageRequest, GetMessageResourceRequest, + P2ImMessageMessageReadV1, + ReplyMessageRequest, ReplyMessageRequestBody, + UpdateMessageRequest, UpdateMessageRequestBody, + ) + from lark_oapi.core import AccessTokenType, HttpMethod + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + from lark_oapi.core.model import BaseRequest + from lark_oapi.event.callback.model.p2_card_action_trigger import ( + CallBackCard, P2CardActionTriggerResponse, + ) + from lark_oapi.event.dispatcher_handler import EventDispatcherHandler + from lark_oapi.ws import Client as FeishuWSClient + return { + "lark": lark, + "GetApplicationRequest": GetApplicationRequest, + "CreateFileRequest": CreateFileRequest, + "CreateFileRequestBody": CreateFileRequestBody, + "CreateImageRequest": CreateImageRequest, + "CreateImageRequestBody": CreateImageRequestBody, + "CreateMessageRequest": CreateMessageRequest, + "CreateMessageRequestBody": CreateMessageRequestBody, + "GetChatRequest": GetChatRequest, + "GetMessageRequest": GetMessageRequest, + "GetMessageResourceRequest": GetMessageResourceRequest, + "P2ImMessageMessageReadV1": P2ImMessageMessageReadV1, + "ReplyMessageRequest": ReplyMessageRequest, + "ReplyMessageRequestBody": ReplyMessageRequestBody, + "UpdateMessageRequest": UpdateMessageRequest, + "UpdateMessageRequestBody": UpdateMessageRequestBody, + "AccessTokenType": AccessTokenType, + "HttpMethod": HttpMethod, + "FEISHU_DOMAIN": FEISHU_DOMAIN, + "LARK_DOMAIN": LARK_DOMAIN, + "BaseRequest": BaseRequest, + "CallBackCard": CallBackCard, + "P2CardActionTriggerResponse": P2CardActionTriggerResponse, + "EventDispatcherHandler": EventDispatcherHandler, + "FeishuWSClient": FeishuWSClient, + "FEISHU_AVAILABLE": True, + } + + from tools.lazy_deps import ensure_and_bind + return ensure_and_bind("platform.feishu", _import, globals(), prompt=False) class FeishuAdapter(BasePlatformAdapter): diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 12075e678370..95dc73201c56 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -227,7 +227,7 @@ def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used. Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")`` - on first call if not present. + on first call if not present. Rebinds all module-level type globals on success. """ token = os.getenv("MATRIX_ACCESS_TOKEN", "") password = os.getenv("MATRIX_PASSWORD", "") @@ -242,11 +242,27 @@ def check_matrix_requirements() -> bool: try: import mautrix # noqa: F401 except ImportError: - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.matrix", prompt=False) - import mautrix # noqa: F401, F811 - except Exception: + def _import(): + from mautrix.types import ( + ContentURI, EventID, EventType, PaginationDirection, + PresenceState, RoomCreatePreset, RoomID, SyncToken, + TrustState, UserID, + ) + return { + "ContentURI": ContentURI, + "EventID": EventID, + "EventType": EventType, + "PaginationDirection": PaginationDirection, + "PresenceState": PresenceState, + "RoomCreatePreset": RoomCreatePreset, + "RoomID": RoomID, + "SyncToken": SyncToken, + "TrustState": TrustState, + "UserID": UserID, + } + + from tools.lazy_deps import ensure_and_bind + if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False): logger.warning( "Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'" ) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index b7a306f9b693..086f5e073f5f 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -176,6 +176,28 @@ def _fail_pending(self, reason: str) -> None: fut.set_exception(RuntimeError(reason)) self._pending_responses.clear() + def _mark_transport_disconnected(self) -> None: + """Mark QQ WS down without stopping the reconnect loop. + + BasePlatformAdapter uses _running for both process lifecycle and + connection status. QQBot needs to keep the listener task alive across + transient transport drops so it can continue reconnect attempts after a + short-lived gateway or network failure. + """ + if self.has_fatal_error: + return + self._write_runtime_status_safe( + "disconnected", + platform_state="disconnected", + error_code=None, + error_message=None, + ) + + @property + def is_connected(self) -> bool: + """Return True only when the QQ WebSocket transport is usable.""" + return bool(self._running and self._ws and not self._ws.closed) + def __init__(self, config: PlatformConfig): super().__init__(config, Platform.QQBOT) @@ -509,7 +531,7 @@ async def _listen_loop(self) -> None: else: quick_disconnect_count = 0 - self._mark_disconnected() + self._mark_transport_disconnected() self._fail_pending("Connection closed") # Stop reconnecting for fatal codes @@ -531,6 +553,7 @@ async def _listen_loop(self) -> None: RATE_LIMIT_DELAY, ) if backoff_idx >= MAX_RECONNECT_ATTEMPTS: + self._mark_disconnected() return await asyncio.sleep(RATE_LIMIT_DELAY) if await self._reconnect(backoff_idx): @@ -584,17 +607,19 @@ async def _listen_loop(self) -> None: backoff_idx += 1 if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag) + self._mark_disconnected() return except Exception as exc: if not self._running: return logger.warning("[%s] WebSocket error: %s", self._log_tag, exc) - self._mark_disconnected() + self._mark_transport_disconnected() self._fail_pending("Connection interrupted") if backoff_idx >= MAX_RECONNECT_ATTEMPTS: logger.error("[%s] Max reconnect attempts reached", self._log_tag) + self._mark_disconnected() return if await self._reconnect(backoff_idx): diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 432b01d80bfc..ca34ab4acac4 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -76,27 +76,26 @@ def check_slack_requirements() -> bool: """Check if Slack dependencies are available. Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")`` - on first call if not present. + on first call if not present. Rebinds all module-level globals on success. """ - global SLACK_AVAILABLE, AsyncApp, AsyncSocketModeHandler, AsyncWebClient if SLACK_AVAILABLE: return True - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.slack", prompt=False) - except Exception: - return False - try: - from slack_bolt.async_app import AsyncApp as _App - from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler as _Handler - from slack_sdk.web.async_client import AsyncWebClient as _Client - except ImportError: - return False - AsyncApp = _App - AsyncSocketModeHandler = _Handler - AsyncWebClient = _Client - SLACK_AVAILABLE = True - return True + + def _import(): + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient + import aiohttp + return { + "AsyncApp": AsyncApp, + "AsyncSocketModeHandler": AsyncSocketModeHandler, + "AsyncWebClient": AsyncWebClient, + "aiohttp": aiohttp, + "SLACK_AVAILABLE": True, + } + + from tools.lazy_deps import ensure_and_bind + return ensure_and_bind("platform.slack", _import, globals(), prompt=False) def _extract_text_from_slack_blocks(blocks: list) -> str: @@ -1799,6 +1798,26 @@ async def _handle_slack_message(self, event: dict) -> None: return original_text = event.get("text", "") + + # Slack blocks native slash commands inside threads ("/queue is not + # supported in threads. Sorry!"). As a workaround, recognise a + # leading ``!`` as an alternate command prefix and rewrite it to + # ``/`` so the rest of the pipeline (MessageType.COMMAND tagging, + # gateway dispatcher) handles it like a normal slash command. Only + # rewrite when the first token resolves to a known gateway command + # so casual messages like "!nice work" pass through unchanged. + if original_text.startswith("!"): + try: + from hermes_cli.commands import is_gateway_known_command + first_token = original_text[1:].split(maxsplit=1)[0] + # Strip "@suffix" the same way get_command() does, so + # forms like ``!stop@hermes`` still resolve. + cmd_name = first_token.split("@", 1)[0].lower() + if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name): + original_text = "/" + original_text[1:] + except Exception: # pragma: no cover - defensive + pass + text = original_text # Extract quoted/forwarded content from Slack blocks. diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index db25b87497dd..4c56937e5cb2 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -332,6 +332,13 @@ class TelegramAdapter(BasePlatformAdapter): MEDIA_GROUP_WAIT_SECONDS = 0.8 _GENERAL_TOPIC_THREAD_ID = "1" + # Telegram's edit_message applies MarkdownV2 formatting only on the + # finalize=True path. Without this flag, stream_consumer._send_or_edit + # short-circuits when the raw text is unchanged between the last streamed + # edit and the final edit, skipping the plain-text → MarkdownV2 conversion. + # Fixes #25710. + REQUIRES_EDIT_FINALIZE: bool = True + # Adaptive text-batch ingress: short messages need a tighter delay so the # first token reaches the agent fast. Numbers tuned for "feels instant": # ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024 @@ -2070,7 +2077,7 @@ async def send_update_prompt( return SendResult(success=False, error="Not connected") try: default_hint = f" (default: {default})" if default else "" - text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}" + text = self.format_message(f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}") keyboard = InlineKeyboardMarkup([ [ InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"), @@ -2082,7 +2089,7 @@ async def send_update_prompt( msg = await self._send_message_with_thread_fallback( chat_id=int(chat_id), text=text, - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, reply_to_message_id=reply_to_id, **self._thread_kwargs_for_send( @@ -2334,11 +2341,13 @@ def get_label(slug): keyboard = InlineKeyboardMarkup(rows) provider_label = get_label(current_provider) - text = ( - f"⚙ *Model Configuration*\n\n" - f"Current model: `{current_model or 'unknown'}`\n" - f"Provider: {provider_label}\n\n" - f"Select a provider:" + text = self.format_message( + ( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ) ) thread_id = metadata.get("thread_id") if metadata else None @@ -2346,7 +2355,7 @@ def get_label(slug): msg = await self._send_message_with_thread_fallback( chat_id=int(chat_id), text=text, - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, reply_to_message_id=reply_to_id, **self._thread_kwargs_for_send( @@ -2456,12 +2465,14 @@ def get_label(slug): extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else "" await query.edit_message_text( - text=( - f"⚙ *Model Configuration*\n\n" - f"Provider: *{pname}*{page_info}\n" - f"Select a model:{extra}" + text=self.format_message( + ( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*{page_info}\n" + f"Select a model:{extra}" + ) ), - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, ) await query.answer() @@ -2490,12 +2501,14 @@ def get_label(slug): extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else "" await query.edit_message_text( - text=( - f"⚙ *Model Configuration*\n\n" - f"Provider: *{pname}*{page_info}\n" - f"Select a model:{extra}" + text=self.format_message( + ( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*{page_info}\n" + f"Select a model:{extra}" + ) ), - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, ) await query.answer() @@ -2530,8 +2543,8 @@ def get_label(slug): # Edit message to show confirmation, remove buttons try: await query.edit_message_text( - text=result_text, - parse_mode=ParseMode.MARKDOWN, + text=self.format_message(result_text), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: @@ -2571,13 +2584,15 @@ def get_label(slug): provider_label = state["current_provider"] await query.edit_message_text( - text=( - f"⚙ *Model Configuration*\n\n" - f"Current model: `{state['current_model'] or 'unknown'}`\n" - f"Provider: {provider_label}\n\n" - f"Select a provider:" + text=self.format_message( + ( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{state['current_model'] or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ) ), - parse_mode=ParseMode.MARKDOWN, + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard, ) await query.answer() @@ -2660,8 +2675,8 @@ async def _handle_callback_query( # Edit message to show decision, remove buttons try: await query.edit_message_text( - text=f"{label} by {user_display}", - parse_mode=ParseMode.MARKDOWN, + text=self.format_message(f"{label} by {user_display}"), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: @@ -2714,8 +2729,8 @@ async def _handle_callback_query( try: await query.edit_message_text( - text=f"{label} by {user_display}", - parse_mode=ParseMode.MARKDOWN, + text=self.format_message(f"{label} by {user_display}"), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: @@ -2740,8 +2755,8 @@ async def _handle_callback_query( prompt_message_id = getattr(query.message, "message_id", None) send_kwargs: Dict[str, Any] = { "chat_id": int(query.message.chat_id), - "text": result_text, - "parse_mode": ParseMode.MARKDOWN, + "text": self.format_message(result_text), + "parse_mode": ParseMode.MARKDOWN_V2, **self._link_preview_kwargs(), } chat_type_value = getattr(chat_type, "value", chat_type) @@ -2901,8 +2916,8 @@ async def _handle_callback_query( label = "Yes" if answer == "y" else "No" try: await query.edit_message_text( - text=f"⚕ Update prompt answered: *{label}*", - parse_mode=ParseMode.MARKDOWN, + text=self.format_message(f"⚕ Update prompt answered: *{label}*"), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 29b78d75d01e..5239df3b5aef 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -322,6 +322,26 @@ def _coerce_allow_list(raw) -> set[str]: return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} + @staticmethod + def _is_broadcast_chat(chat_id: str) -> bool: + """True for WhatsApp pseudo-chats that aren't real conversations. + + Covers Status updates (Stories) and Channel/Newsletter broadcasts. + These show up as inbound messages on Baileys but the agent should + never reply — answering a Story update spams the contact's status + feed, and Channel posts aren't addressable in the first place. + """ + if not chat_id: + return False + cid = chat_id.strip().lower() + if cid == "status@broadcast": + return True + # @broadcast suffix covers status@broadcast plus any future + # broadcast-list variants. @newsletter is the Channel JID suffix. + if cid.endswith("@broadcast") or cid.endswith("@newsletter"): + return True + return False + def _is_dm_allowed(self, sender_id: str) -> bool: """Check whether a DM from the given sender should be processed.""" if self._dm_policy == "disabled": @@ -432,9 +452,16 @@ def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str: return cleaned.strip() or text def _should_process_message(self, data: Dict[str, Any]) -> bool: + chat_id_raw = str(data.get("chatId") or "") + # WhatsApp uses pseudo-chats for Status updates (Stories) and + # Channel/Newsletter broadcasts. These are not real conversations + # and the agent should never reply to them — even in self-chat mode + # where the bridge may surface them as "fromMe" events. + if self._is_broadcast_chat(chat_id_raw): + return False is_group = data.get("isGroup", False) if is_group: - chat_id = str(data.get("chatId") or "") + chat_id = chat_id_raw if not self._is_group_allowed(chat_id): return False else: diff --git a/gateway/run.py b/gateway/run.py index 46c508e4bde0..d986917ebabc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1139,6 +1139,38 @@ def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool: return True +def _preserve_queued_followup_history_offset( + current_result: dict, + followup_result: dict, +) -> dict: + """Carry the outer history offset through queued follow-up drains. + + ``_process_message_background()`` persists transcript rows only once, after the + entire in-band queued-follow-up chain returns. Each recursive ``_run_agent()`` + call advances ``history_offset`` to the history it received, so without + correction the outermost persistence step sees only the *last* queued turn as + "new" and silently drops earlier turns from the same drain chain. + + Preserve the earliest (outermost) history offset so the final transcript slice + still includes every queued turn that ran during the chain. + """ + if not isinstance(followup_result, dict): + return followup_result + if not isinstance(current_result, dict): + return followup_result + + current_offset = current_result.get("history_offset") + followup_offset = followup_result.get("history_offset") + if not isinstance(current_offset, int): + return followup_result + if isinstance(followup_offset, int) and followup_offset <= current_offset: + return followup_result + + merged = dict(followup_result) + merged["history_offset"] = current_offset + return merged + + class GatewayRunner: """ Main gateway controller. @@ -6096,6 +6128,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if _cmd_def_inner and _cmd_def_inner.name == "model": return "Agent is running — wait or /stop first, then switch models." + # /codex-runtime must not be used while the agent is running. + # Switching mid-turn would split a turn across two transports. + if _cmd_def_inner and _cmd_def_inner.name == "codex-runtime": + return ("Agent is running — wait or /stop first, then " + "change runtime.") + # /approve and /deny must bypass the running-agent interrupt path. # The agent thread is blocked on a threading.Event inside # tools/approval.py — sending an interrupt won't unblock it. @@ -6135,6 +6173,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: 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 goal's + # subgoals list, which the judge reads at the next turn + # boundary. 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. @@ -6430,6 +6474,9 @@ async def _do_reset(): if canonical == "model": return await self._handle_model_command(event) + if canonical == "codex-runtime": + return await self._handle_codex_runtime_command(event) + if canonical == "personality": return await self._handle_personality_command(event) @@ -6513,6 +6560,9 @@ 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) @@ -6759,6 +6809,12 @@ async def _prepare_inbound_message_text( if _is_shared_multi_user and source.user_name: message_text = f"[{source.user_name}] {message_text}" + # Prepend channel context from history backfill (if any). This + # happens after sender-prefix so the prefix only applies to the + # trigger message, not the backfill block. + if getattr(event, "channel_context", None): + message_text = f"{event.channel_context}\n\n[New message]\n{message_text}" + if event.media_urls: image_paths = [] audio_paths = [] @@ -9210,6 +9266,51 @@ async def _on_model_selected( return "\n".join(lines) + async def _handle_codex_runtime_command(self, event: MessageEvent) -> str: + """Handle /codex-runtime command in the gateway. + + Same surface as the CLI handler in cli.py: + /codex-runtime — show current state + /codex-runtime auto — Hermes default runtime + /codex-runtime codex_app_server — codex subprocess runtime + /codex-runtime on / off — synonyms + + On change, the cached agent for this session is evicted so the next + message creates a fresh AIAgent with the new api_mode wired in + (avoids prompt-cache invalidation mid-session).""" + from hermes_cli import codex_runtime_switch as crs + + raw_args = event.get_command_args().strip() if event else "" + new_value, errors = crs.parse_args(raw_args) + if errors: + return "❌ " + "\n❌ ".join(errors) + + # Load + persist via the same helpers used for /model and /yolo + try: + from hermes_cli.config import load_config, save_config + except Exception as exc: + return f"❌ Could not load config: {exc}" + cfg = load_config() + + result = crs.apply( + cfg, + new_value, + persist_callback=(save_config if new_value is not None else None), + ) + + # On a real change, evict the cached agent so the new runtime takes + # effect on the next message rather than waiting for cache TTL. + if result.success and new_value is not None and result.requires_new_session: + try: + session_key = self._session_key_for_source(event.source) + self._evict_cached_agent(session_key) + except Exception: + logger.debug("could not evict cached agent after codex-runtime change", + exc_info=True) + + prefix = "✓" if result.success else "✗" + return f"{prefix} {result.message}" + async def _handle_personality_command(self, event: MessageEvent) -> str: """Handle /personality command - list or set a personality.""" from hermes_constants import display_hermes_home @@ -9438,6 +9539,57 @@ 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 (mirror of CLI handler). + + Subgoals are extra criteria appended to the active goal mid-loop. + They modify state read at the next turn boundary, so this is safe + to invoke while the agent is running. + """ + 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 ." + + # No args → list current subgoals. + if not args: + return f"{mgr.status_line()}\n{mgr.render_subgoals()}" + + tokens = args.split(None, 1) + verb = tokens[0].lower() + rest = tokens[1].strip() if len(tokens) > 1 else "" + + 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 subgoal {idx}: {removed}" + + if verb == "clear": + try: + prev = mgr.clear_subgoals() + except RuntimeError as exc: + return f"/subgoal clear: {exc}" + if prev: + return f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." + return "No subgoals to clear." + + try: + text = mgr.add_subgoal(args) + except (ValueError, RuntimeError) as exc: + return f"/subgoal: {exc}" + idx = len(mgr.state.subgoals) if mgr.state else 0 + return f"✓ Added subgoal {idx}: {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) @@ -10209,6 +10361,10 @@ async def _handle_background_command(self, event: MessageEvent) -> str: event_message_id = self._reply_anchor_for_event(event) + # Forward image/audio attachments so the background agent can see them. + media_urls = list(event.media_urls) if event.media_urls else [] + media_types = list(event.media_types) if event.media_types else [] + # Fire-and-forget the background task _task = asyncio.create_task( self._run_background_task( @@ -10216,6 +10372,8 @@ async def _handle_background_command(self, event: MessageEvent) -> str: source, task_id, event_message_id=event_message_id, + media_urls=media_urls, + media_types=media_types, ) ) self._background_tasks.add(_task) @@ -10230,10 +10388,15 @@ async def _run_background_task( source: "SessionSource", task_id: str, event_message_id: Optional[str] = None, + media_urls: Optional[List[str]] = None, + media_types: Optional[List[str]] = None, ) -> None: """Execute a background agent task and deliver the result to the chat.""" from run_agent import AIAgent + media_urls = media_urls or [] + media_types = media_types or [] + adapter = self.adapters.get(source.platform) if not adapter: logger.warning("No adapter for platform %s in background task %s", source.platform, task_id) @@ -10269,6 +10432,23 @@ async def _run_background_task( self._service_tier = self._load_service_tier() turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) + # Enrich the prompt with image descriptions so the background + # agent can see user-attached images (same as the main flow). + enriched_prompt = prompt + if media_urls: + image_paths = [] + for i, path in enumerate(media_urls): + mtype = media_types[i] if i < len(media_types) else "" + if mtype.startswith("image/"): + image_paths.append(path) + if image_paths: + try: + enriched_prompt = await self._enrich_message_with_vision( + prompt, image_paths, + ) + except Exception as e: + logger.warning("Background task vision enrichment failed: %s", e) + def run_sync(): agent = AIAgent( model=turn_route["model"], @@ -10300,7 +10480,7 @@ def run_sync(): ) try: return agent.run_conversation( - user_message=prompt, + user_message=enriched_prompt, task_id=task_id, ) finally: @@ -15957,6 +16137,7 @@ async def _notify_long_running(): _already_streamed = bool( (_sc and getattr(_sc, "final_response_sent", False)) or _previewed + or (_sc and getattr(_sc, "final_content_delivered", False)) ) first_response = result.get("final_response", "") if first_response and not _already_streamed: @@ -16042,7 +16223,7 @@ async def _notify_long_running(): except Exception: pass - return await self._run_agent( + followup_result = await self._run_agent( message=next_message, context_prompt=context_prompt, history=updated_history, @@ -16054,6 +16235,7 @@ async def _notify_long_running(): event_message_id=next_message_id, channel_prompt=next_channel_prompt, ) + return _preserve_queued_followup_history_offset(result, followup_result) finally: # Stop progress sender, interrupt monitor, and notification task if progress_task: @@ -16117,12 +16299,16 @@ async def _notify_long_running(): # response_previewed means the interim_assistant_callback already # sent the final text via the adapter (non-streaming path). _previewed = bool(response.get("response_previewed")) - if not _is_empty_sentinel and (_streamed or _previewed): + _content_delivered = bool( + _sc and getattr(_sc, "final_content_delivered", False) + ) + if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered): logger.info( - "Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s).", + "Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s content_delivered=%s).", session_key or "?", _streamed, _previewed, + _content_delivered, ) response["already_sent"] = True diff --git a/gateway/status.py b/gateway/status.py index 3c6198560256..516ea8f385e6 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -128,6 +128,7 @@ def _read_process_cmdline(pid: int) -> Optional[str]: On Linux, reads /proc//cmdline directly. On macOS and other platforms without /proc, falls back to ``ps -p -o command=``. + On Windows (no /proc, no ps), uses psutil. """ cmdline_path = Path(f"/proc/{pid}/cmdline") try: @@ -150,6 +151,16 @@ def _read_process_cmdline(pid: int) -> Optional[str]: except (OSError, subprocess.TimeoutExpired): pass + # Windows fallback: psutil (already used by _pid_exists) + try: + import psutil # type: ignore + proc = psutil.Process(pid) + cmdline_parts = proc.cmdline() + if cmdline_parts: + return " ".join(cmdline_parts) + except Exception: + pass + return None @@ -178,7 +189,8 @@ def _record_looks_like_gateway(record: dict[str, Any]) -> bool: if not isinstance(argv, list) or not argv: return False - cmdline = " ".join(str(part) for part in argv) + # Normalize Windows backslashes so patterns match cross-platform. + cmdline = " ".join(str(part) for part in argv).replace("\\", "/") patterns = ( "hermes_cli.main gateway", "hermes_cli/main.py gateway", diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 558a86bd2958..3c761d528ab2 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -150,6 +150,10 @@ def __init__( self._flood_strikes = 0 # Consecutive flood-control edit failures self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._final_response_sent = False + # Set when the final response content was sent to the user via + # streaming, even if the final edit (cursor removal etc.) + # subsequently failed. + self._final_content_delivered = False # Cache adapter lifecycle capability: only platforms that need an # explicit finalize call (e.g. DingTalk AI Cards) force us to make # a redundant final edit. Everyone else keeps the fast path. @@ -187,6 +191,12 @@ def final_response_sent(self) -> bool: """True when the stream consumer delivered the final assistant reply.""" return self._final_response_sent + @property + def final_content_delivered(self) -> bool: + """True when the final response content reached the user, even if + the subsequent cosmetic edit (cursor removal) failed.""" + return self._final_content_delivered + def on_segment_break(self) -> None: """Finalize the current stream segment and start a fresh message.""" self._queue.put(_NEW_SEGMENT) @@ -455,6 +465,8 @@ async def run(self) -> None: # tool-progress edits or fallback-mode promotion (#10748) # — that doesn't mean the final answer reached the user. self._final_response_sent = chunks_delivered + if chunks_delivered: + self._final_content_delivered = True return if got_segment_break: self._message_id = None @@ -505,6 +517,11 @@ async def run(self) -> None: self._last_edit_time = time.monotonic() if got_done: + # Record that the final content reached the user even + # if the cosmetic final edit below fails. + if current_update_visible and self._accumulated: + self._final_content_delivered = True + # Final edit without cursor. If progressive editing failed # mid-stream, send a single continuation/fallback message # here instead of letting the base gateway path send the diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 88acd1cd4385..2dcf6a03b457 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -35,7 +35,7 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlencode, urlparse import httpx @@ -3870,6 +3870,39 @@ def _entry_sort_key(entry: Any) -> tuple[float, float, int]: return _empty_nous_auth_status() +# ── Process-level memo for get_nous_auth_status() ── +# get_nous_auth_status() validates state by calling resolve_nous_runtime_credentials(), +# which does a synchronous OAuth refresh POST to portal.nousresearch.com. That can take +# ~350ms even on the failure path, and read-only UI surfaces (`hermes tools`, status panels, +# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms" +# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning +# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json +# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call. +_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds +_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None + + +def _auth_file_mtime() -> Optional[float]: + try: + return _auth_file_path().stat().st_mtime + except FileNotFoundError: + return None + except Exception: + return None + + +def invalidate_nous_auth_status_cache() -> None: + """Clear the get_nous_auth_status() process-level memo. + + Call this from any code path that mutates Nous auth state without going + through resolve_nous_runtime_credentials() (e.g. tests). Login/logout + flows touch auth.json, so the mtime check below invalidates them + automatically — explicit invalidation is the belt-and-braces option. + """ + global _nous_auth_status_cache + _nous_auth_status_cache = None + + def get_nous_auth_status() -> Dict[str, Any]: """Status snapshot for Nous auth. @@ -3878,7 +3911,32 @@ def get_nous_auth_status() -> Dict[str, Any]: by resolving runtime credentials so revoked refresh sessions do not show up as a healthy login. If provider state is absent, fall back to the credential pool for the just-logged-in / not-yet-promoted case. + + The returned snapshot is memoised for ~15s keyed on the auth.json mtime, + so menu/status surfaces that ask repeatedly don't trigger one refresh POST + per call. Login/logout flows write to auth.json and therefore invalidate + the cache automatically; tests can also call + ``invalidate_nous_auth_status_cache()`` explicitly. """ + global _nous_auth_status_cache + now = time.monotonic() + mtime = _auth_file_mtime() + cached = _nous_auth_status_cache + if cached is not None: + cached_at, cached_mtime, cached_status = cached + if ( + cached_mtime == mtime + and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL + ): + return dict(cached_status) + + status = _compute_nous_auth_status() + _nous_auth_status_cache = (now, mtime, dict(status)) + return status + + +def _compute_nous_auth_status() -> Dict[str, Any]: + """Uncached implementation of get_nous_auth_status(). See that function.""" state = get_provider_auth_state("nous") if state: base_status = { diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1cfb0d51f760..c4ec348ef489 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -581,6 +581,19 @@ def build_welcome_banner(console: Console, model: str, cwd: str, if mcp_connected: summary_parts.append(f"{mcp_connected} MCP servers") summary_parts.append("/help for commands") + # Indicate when the codex_app_server runtime is active so users + # understand why tool counts may not match what's actually reachable + # (codex builds its own tool list inside the spawned subprocess). + try: + from hermes_cli.codex_runtime_switch import get_current_runtime + from hermes_cli.config import load_config as _load_cfg + if get_current_runtime(_load_cfg()) == "codex_app_server": + right_lines.append( + f"[bold {accent}]Runtime:[/] [{text}]codex app-server[/] " + f"[dim {dim}](terminal/file ops/MCP run inside codex)[/]" + ) + except Exception: + pass # Show active profile name when not 'default' try: from hermes_cli.profiles import get_active_profile_name diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index facc8f3c50ad..a6b6da7c06aa 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -22,6 +22,7 @@ from hermes_constants import is_wsl as _is_wsl logger = logging.getLogger(__name__) +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" def save_clipboard_image(dest: Path) -> bool: @@ -378,10 +379,13 @@ def _wayland_save(dest: Path) -> bool: dest.unlink(missing_ok=True) return False - # BMP needs conversion to PNG (common in WSLg where only BMP - # is bridged from Windows clipboard via RDP). - if mime == "image/bmp": - return _convert_to_png(dest) + # save_clipboard_image() promises a PNG output path. Wayland can offer + # JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before + # returning success. + if mime != "image/png": + if not _convert_to_png(dest) or not _is_png_file(dest): + dest.unlink(missing_ok=True) + return False return True @@ -433,6 +437,15 @@ def _convert_to_png(path: Path) -> bool: return path.exists() and path.stat().st_size > 0 +def _is_png_file(path: Path) -> bool: + """Return True when *path* starts with the PNG file signature.""" + try: + with path.open("rb") as f: + return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE + except OSError: + return False + + # ── X11 (xclip) ───────────────────────────────────────────────────────── def _xclip_has_image() -> bool: diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py new file mode 100644 index 000000000000..dd7faa097943 --- /dev/null +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -0,0 +1,614 @@ +"""Migrate Hermes' MCP server config and Codex's installed curated plugins +to the format Codex expects in ~/.codex/config.toml. + +When the user enables the codex_app_server runtime, the codex subprocess +runs its own MCP client and its own plugin runtime (Linear, Atlassian, +Asana, plus per-account ChatGPT apps via app/list). For both of those to +be useful, the user's choices need to be visible to codex too. This +module: + + 1. Reads Hermes' YAML and writes equivalent [mcp_servers.] + entries to ~/.codex/config.toml. + 2. Queries codex's `plugin/list` for the openai-curated marketplace + and writes [plugins."@"] entries for any plugin + the user has installed=true on their codex CLI. (This is what + OpenClaw calls "migrate native codex plugins" — the YouTube-video- + worthy bit Pash highlighted: Canva, GitHub, Calendar, Gmail + pre-configured.) + 3. Writes a [permissions] default profile so users on this runtime + don't get an approval prompt on every write attempt. + +What translates (MCP servers): + Hermes mcp_servers..command/args/env → codex stdio transport + Hermes mcp_servers..url/headers → codex streamable_http transport + Hermes mcp_servers..timeout → codex tool_timeout_sec + Hermes mcp_servers..connect_timeout → codex startup_timeout_sec + +What does NOT translate (warned + skipped): + Hermes-specific keys (sampling, etc.) — codex's MCP client has no + equivalent. Listed in the per-server skipped[] field of the report. + +What's NOT migrated (intentional): + AGENTS.md — codex respects this file natively in its cwd. Hermes' own + AGENTS.md (project-level) is already in the worktree, so codex picks + it up without translation. No code needed. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# Marker comments wrapping the managed section so re-runs can detect +# what's ours and what's user-edited. Both must appear or strip is a no-op. +MIGRATION_MARKER = ( + "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section" +) +MIGRATION_END_MARKER = ( + "# end hermes-agent managed section" +) + + +@dataclass +class MigrationReport: + """Outcome of a migration pass.""" + + target_path: Optional[Path] = None + migrated: list[str] = field(default_factory=list) + skipped_keys_per_server: dict[str, list[str]] = field(default_factory=dict) + migrated_plugins: list[str] = field(default_factory=list) + plugin_query_error: Optional[str] = None + wrote_permissions_default: Optional[str] = None + errors: list[str] = field(default_factory=list) + written: bool = False + dry_run: bool = False + + def summary(self) -> str: + lines = [] + if self.dry_run: + lines.append(f"(dry run) Would write {self.target_path}") + elif self.written: + lines.append(f"Wrote {self.target_path}") + if self.migrated: + lines.append(f"Migrated {len(self.migrated)} MCP server(s):") + for name in self.migrated: + skipped = self.skipped_keys_per_server.get(name, []) + note = ( + f" (skipped: {', '.join(skipped)})" if skipped else "" + ) + lines.append(f" - {name}{note}") + else: + lines.append("No MCP servers found in Hermes config.") + if self.migrated_plugins: + lines.append( + f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):" + ) + for name in self.migrated_plugins: + lines.append(f" - {name}") + elif self.plugin_query_error: + lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}") + if self.wrote_permissions_default: + lines.append( + f"Wrote default_permissions = " + f"{self.wrote_permissions_default!r}" + ) + for err in self.errors: + lines.append(f"⚠ {err}") + return "\n".join(lines) + + +# Hermes keys that codex's MCP schema doesn't support — dropped during +# migration with a warning. Anything not on the keep list AND not the +# transport keys is added to skipped. +_KNOWN_HERMES_KEYS = { + # transport — stdio + "command", "args", "env", "cwd", + # transport — http + "url", "headers", "transport", + # timeouts + "timeout", "connect_timeout", + # general + "enabled", "description", +} + +# Subset that have a direct codex equivalent. +_KEYS_DROPPED_WITH_WARNING = { + # Hermes' sampling subsection — codex MCP has no equivalent + "sampling", +} + + +def _translate_one_server( + name: str, hermes_cfg: dict +) -> tuple[Optional[dict], list[str]]: + """Translate one Hermes MCP server config to the codex inline-table dict + representation. Returns (codex_entry, skipped_keys). + + codex_entry is a dict ready for TOML serialization, or None when the + server can't be translated (e.g. neither command nor url present).""" + if not isinstance(hermes_cfg, dict): + return None, [] + + skipped: list[str] = [] + out: dict[str, Any] = {} + + has_command = bool(hermes_cfg.get("command")) + has_url = bool(hermes_cfg.get("url")) + + if has_command and has_url: + skipped.append("url (both command and url set; preferring stdio)") + has_url = False + + if has_command: + # Stdio transport + out["command"] = str(hermes_cfg["command"]) + args = hermes_cfg.get("args") or [] + if args: + out["args"] = [str(a) for a in args] + env = hermes_cfg.get("env") or {} + if env: + # Codex expects string values + out["env"] = {str(k): str(v) for k, v in env.items()} + cwd = hermes_cfg.get("cwd") + if cwd: + out["cwd"] = str(cwd) + elif has_url: + # streamable_http transport (codex covers both http and SSE here) + out["url"] = str(hermes_cfg["url"]) + headers = hermes_cfg.get("headers") or {} + if headers: + out["http_headers"] = {str(k): str(v) for k, v in headers.items()} + # Hermes' transport: sse hint is informational; codex auto-negotiates + if hermes_cfg.get("transport") == "sse": + skipped.append("transport=sse (codex auto-negotiates)") + else: + return None, ["no command or url field"] + + # Timeouts + if "timeout" in hermes_cfg: + try: + out["tool_timeout_sec"] = float(hermes_cfg["timeout"]) + except (TypeError, ValueError): + skipped.append("timeout (not numeric)") + if "connect_timeout" in hermes_cfg: + try: + out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"]) + except (TypeError, ValueError): + skipped.append("connect_timeout (not numeric)") + + # Enabled flag (codex defaults to true so we only emit when explicitly false) + if hermes_cfg.get("enabled") is False: + out["enabled"] = False + + # Detect keys we explicitly drop with warning + for key in hermes_cfg: + if key in _KEYS_DROPPED_WITH_WARNING: + skipped.append(f"{key} (no codex equivalent)") + elif key not in _KNOWN_HERMES_KEYS: + skipped.append(f"{key} (unknown Hermes key)") + + return out, skipped + + +def _format_toml_value(value: Any) -> str: + """Minimal TOML value formatter for the value types we emit. + + We only emit strings, numbers, booleans, and tables of those — no nested + arrays of tables. This covers everything codex's MCP schema accepts.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + if isinstance(value, str): + # Escape per TOML basic-string rules. Order matters: backslash + # first so the other escapes don't get re-escaped. + # Control characters (newline, tab, etc.) must use \-escapes + # because TOML basic strings don't allow literal control chars + # — passing them through would produce invalid TOML that codex + # would refuse to load. Paths usually don't contain control + # chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could + # in pathological cases. + escaped = ( + value + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\b", "\\b") + .replace("\t", "\\t") + .replace("\n", "\\n") + .replace("\f", "\\f") + .replace("\r", "\\r") + ) + return f'"{escaped}"' + if isinstance(value, list): + items = ", ".join(_format_toml_value(v) for v in value) + return f"[{items}]" + if isinstance(value, dict): + items = ", ".join( + f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items() + ) + return "{ " + items + " }" if items else "{}" + raise ValueError(f"Unsupported TOML value type: {type(value).__name__}") + + +def _quote_key(key: str) -> str: + """Return key bare-or-quoted depending on whether it's a valid bare key.""" + if all(c.isalnum() or c in "-_" for c in key) and key: + return key + escaped = key.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + +def render_codex_toml_section( + servers: dict[str, dict], + plugins: Optional[list[dict]] = None, + default_permission_profile: Optional[str] = None, +) -> str: + """Render the managed [mcp_servers.] / [plugins.] / [permissions] + block for ~/.codex/config.toml. + + Args: + servers: dict of MCP server name → translated codex inline-table + plugins: optional list of {name, marketplace, enabled} for native + Codex plugins to enable. (E.g. the Linear / Atlassian / Asana + curated plugins, or per-account ChatGPT apps.) + default_permission_profile: when set, write `[permissions] default` + so the user doesn't get an approval prompt on every write + attempt. Common values: "workspace-write", "read-only", + "full-access". + """ + out = [MIGRATION_MARKER] + if not servers and not plugins and not default_permission_profile: + out.append("# (no MCP servers, plugins, or permissions configured by Hermes)") + out.append(MIGRATION_END_MARKER) + return "\n".join(out) + "\n" + + if default_permission_profile: + # Codex's config schema: `default_permissions` is a top-level + # string referencing a profile name. Built-in profile names start + # with ":" (":workspace-write", ":read-only", ":full-access"). The + # [permissions] table is for *user-defined* named profiles with + # structured fields — not what we want. + normalized = ( + default_permission_profile + if default_permission_profile.startswith(":") + else f":{default_permission_profile}" + ) + out.append("") + out.append(f"default_permissions = {_format_toml_value(normalized)}") + + if servers: + for name in sorted(servers.keys()): + cfg = servers[name] + out.append("") + out.append(f"[mcp_servers.{_quote_key(name)}]") + for k, v in cfg.items(): + out.append(f"{_quote_key(k)} = {_format_toml_value(v)}") + + if plugins: + for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"): + name = plugin.get("name") or "" + marketplace = plugin.get("marketplace") or "openai-curated" + enabled = bool(plugin.get("enabled", True)) + qualified = f"{name}@{marketplace}" + out.append("") + out.append(f'[plugins.{_quote_key(qualified)}]') + out.append(f"enabled = {_format_toml_value(enabled)}") + + out.append("") + out.append(MIGRATION_END_MARKER) + return "\n".join(out) + "\n" + + +def _strip_existing_managed_block(toml_text: str) -> str: + """Remove any prior managed section so re-runs idempotently replace it. + + The managed section is everything between MIGRATION_MARKER (start) and + MIGRATION_END_MARKER (end), inclusive of both markers. User-edited + sections above or below are preserved verbatim. + + Backward compatibility: if the start marker is found but no end marker + follows, we fall back to the heuristic that swallows lines until we + hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/ + a `default_permissions =` key. This matches what older versions of + this code wrote so re-runs don't break configs from prior Hermes + versions.""" + lines = toml_text.splitlines(keepends=True) + out: list[str] = [] + in_managed = False + saw_end_marker = False + for line in lines: + line_stripped_nl = line.rstrip("\n") + if line_stripped_nl == MIGRATION_MARKER: + in_managed = True + saw_end_marker = False + continue + if in_managed: + if line_stripped_nl == MIGRATION_END_MARKER: + in_managed = False + saw_end_marker = True + continue + stripped = line.lstrip() + if not saw_end_marker and stripped.startswith("[") and not ( + stripped.startswith("[mcp_servers") + or stripped.startswith("[plugins") + or stripped.startswith("[permissions]") + or stripped.startswith("[permissions.") + ): + # Old-format managed block without end marker: bail back + # to user content as soon as we see a non-managed section. + in_managed = False + out.append(line) + continue + # Otherwise swallow the line. + continue + out.append(line) + return "".join(out) + + +def _query_codex_plugins( + codex_home: Optional[Path] = None, + timeout: float = 8.0, +) -> tuple[list[dict], Optional[str]]: + """Query codex's `plugin/list` for installed curated plugins. + + Spawns `codex app-server` briefly, sends initialize + plugin/list, + extracts plugins where installed=true. Returns (plugins, error). + Plugins is a list of {name, marketplace, enabled} dicts ready for + render_codex_toml_section(). + + On any failure (codex not installed, RPC error, timeout) returns + ([], error_message). Migration treats this as non-fatal — MCP + servers and permissions still write through. + """ + try: + from agent.transports.codex_app_server import CodexAppServerClient + except Exception as exc: + return [], f"transport unavailable: {exc}" + + try: + with CodexAppServerClient( + codex_home=str(codex_home) if codex_home else None + ) as client: + client.initialize(client_name="hermes-migration") + resp = client.request("plugin/list", {}, timeout=timeout) + except Exception as exc: + return [], f"plugin/list query failed: {exc}" + + out: list[dict] = [] + seen: set[tuple[str, str]] = set() + marketplaces = resp.get("marketplaces") or [] + if not isinstance(marketplaces, list): + return [], "plugin/list response missing 'marketplaces'" + for marketplace in marketplaces: + if not isinstance(marketplace, dict): + continue + market_name = str(marketplace.get("name") or "openai-curated") + plugins = marketplace.get("plugins") or [] + if not isinstance(plugins, list): + continue + for plugin in plugins: + if not isinstance(plugin, dict): + continue + installed = bool(plugin.get("installed", False)) + if not installed: + continue + # Skip plugins codex itself reports as unavailable (broken + # install, missing OAuth, removed from marketplace, etc.). + # Cf. openclaw/openclaw#80815 — OpenClaw learned to gate + # migration on app readiness to avoid writing config that + # would fail at activation time. Our migration writes to + # codex's config.toml directly, so a broken plugin would + # surface as a codex error on first use. Skipping it here + # keeps the migrated config clean and the user's first + # codex turn from failing. + availability = str(plugin.get("availability") or "").upper() + if availability and availability != "AVAILABLE": + logger.debug( + "skipping plugin %s: availability=%s", + plugin.get("name"), availability, + ) + continue + name = str(plugin.get("name") or "") + if not name: + continue + key = (name, market_name) + if key in seen: + continue + seen.add(key) + # Carry forward whatever 'enabled' codex reports — defaults to + # true for installed plugins. This is the same shape OpenClaw + # writes when migrating native codex plugins. + out.append({ + "name": name, + "marketplace": market_name, + "enabled": bool(plugin.get("enabled", True)), + }) + return out, None + + +def _build_hermes_tools_mcp_entry() -> dict: + """Build the codex stdio-transport entry that launches Hermes' own + tool surface as an MCP server. Codex's subprocess will call back into + this for browser/web/delegate_task/vision/memory/skills tools. + + The command runs the worktree's Python via the current sys.executable + so a hermes installed under /opt/, /usr/local/, or a venv all work. + HERMES_HOME and PYTHONPATH are passed through so the spawned process + sees the same config + module layout the user is running.""" + import sys + + env: dict[str, str] = {} + # HERMES_HOME passes through if set so the MCP subprocess sees the + # same config / auth / sessions DB as the parent CLI. + hermes_home = os.environ.get("HERMES_HOME") + if hermes_home: + env["HERMES_HOME"] = hermes_home + # PYTHONPATH passes through so a worktree-launched hermes finds the + # branch's modules instead of the installed package. + pythonpath = os.environ.get("PYTHONPATH") + if pythonpath: + env["PYTHONPATH"] = pythonpath + # Quiet mode + redaction defaults so the MCP wire stays clean. + env["HERMES_QUIET"] = "1" + env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true") + + out: dict[str, Any] = { + "command": sys.executable, + "args": ["-m", "agent.transports.hermes_tools_mcp_server"], + } + if env: + out["env"] = env + # Generous timeouts — browser_navigate or delegate_task can take a + # while; we don't want codex's MCP client to give up too early. + out["startup_timeout_sec"] = 30.0 + out["tool_timeout_sec"] = 600.0 + return out + + +def migrate( + hermes_config: dict, + *, + codex_home: Optional[Path] = None, + dry_run: bool = False, + discover_plugins: bool = True, + default_permission_profile: Optional[str] = ":workspace", + expose_hermes_tools: bool = True, +) -> MigrationReport: + """Translate Hermes mcp_servers config + Codex curated plugins into + ~/.codex/config.toml. + + Args: + hermes_config: full ~/.hermes/config.yaml dict + codex_home: override CODEX_HOME (defaults to ~/.codex) + dry_run: skip the actual write; report what would happen + discover_plugins: when True (default), query `plugin/list` against + the live codex CLI to migrate any installed curated plugins + into [plugins."@"] entries. Set False to + skip the subprocess spawn (for tests or restricted environments). + default_permission_profile: when set (default ":workspace"), write + top-level `default_permissions = ""` so users on this + runtime don't get an approval prompt on every write attempt. + Built-in codex profile names are ":workspace", ":read-only", + ":danger-no-sandbox" (note the leading ":"). Also accepts a + user-defined profile name (no leading ":") that the user has + configured in their own [permissions.] table. Set None + to leave permissions unset and let codex use its compiled-in + default (which is read-only). + expose_hermes_tools: when True (default), register Hermes' own + tool surface (web_search, browser_*, delegate_task, vision, + memory, skills, etc.) as an MCP server in ~/.codex/config.toml + so the codex subprocess can call back into Hermes for tools + codex doesn't have built in. Set False to opt out. + """ + report = MigrationReport(dry_run=dry_run) + codex_home = codex_home or Path.home() / ".codex" + target = codex_home / "config.toml" + report.target_path = target + + hermes_servers = (hermes_config or {}).get("mcp_servers") or {} + if not isinstance(hermes_servers, dict): + report.errors.append( + "mcp_servers in Hermes config is not a dict; cannot migrate." + ) + return report + + translated: dict[str, dict] = {} + for name, cfg in hermes_servers.items(): + out, skipped = _translate_one_server(str(name), cfg or {}) + if out is None: + report.errors.append( + f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}" + ) + continue + translated[str(name)] = out + if skipped: + report.skipped_keys_per_server[str(name)] = skipped + report.migrated.append(str(name)) + + # Discover installed Codex curated plugins. Best-effort — never blocks + # the migration if codex is unreachable or the RPC fails. + plugins: list[dict] = [] + if discover_plugins and not dry_run: + plugins, plugin_err = _query_codex_plugins(codex_home=codex_home) + if plugin_err: + report.plugin_query_error = plugin_err + for p in plugins: + report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}") + + # Track whether we wrote a default permission profile so the report + # surfaces it to the user. + if default_permission_profile: + report.wrote_permissions_default = default_permission_profile + + # Inject Hermes' own tool surface as an MCP server so the spawned + # codex subprocess can call back into Hermes for the tools codex + # doesn't ship with — web_search, browser_*, delegate_task, vision, + # memory, skills, session_search, image_generate, text_to_speech. + # The server itself is agent/transports/hermes_tools_mcp_server.py + # and is launched on demand by codex (stdio MCP). + if expose_hermes_tools: + translated["hermes-tools"] = _build_hermes_tools_mcp_entry() + if "hermes-tools" not in report.migrated: + report.migrated.append("hermes-tools") + + # Build the new managed block + managed_block = render_codex_toml_section( + translated, plugins=plugins, + default_permission_profile=default_permission_profile, + ) + + # Read existing codex config if any, strip the prior managed block, + # append the new one. + if target.exists(): + try: + existing = target.read_text(encoding="utf-8") + except Exception as exc: + report.errors.append(f"could not read {target}: {exc}") + return report + without_managed = _strip_existing_managed_block(existing) + # Ensure exactly one blank line between user content and managed block + if without_managed and not without_managed.endswith("\n"): + without_managed += "\n" + new_text = ( + without_managed.rstrip("\n") + "\n\n" + managed_block + if without_managed.strip() + else managed_block + ) + else: + new_text = managed_block + + if dry_run: + return report + + try: + codex_home.mkdir(parents=True, exist_ok=True) + # Atomic write: write to a temp file in the same directory then + # rename. Same-directory rename is atomic on POSIX and ReplaceFile + # on Windows. Avoids leaving a half-written config.toml that + # codex would refuse to load if we crash mid-write. + import tempfile + tmp_fd, tmp_path_str = tempfile.mkstemp( + prefix=".config.toml.", dir=str(codex_home) + ) + tmp_path = Path(tmp_path_str) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh: + fh.write(new_text) + tmp_path.replace(target) + except Exception: + # Clean up the temp file if the rename didn't happen. + try: + if tmp_path.exists(): + tmp_path.unlink() + except Exception: + pass + raise + report.written = True + except Exception as exc: + report.errors.append(f"could not write {target}: {exc}") + return report diff --git a/hermes_cli/codex_runtime_switch.py b/hermes_cli/codex_runtime_switch.py new file mode 100644 index 000000000000..b3adda12b545 --- /dev/null +++ b/hermes_cli/codex_runtime_switch.py @@ -0,0 +1,266 @@ +"""Shared logic for the /codex-runtime slash command. + +Toggles `model.openai_runtime` between "auto" (= chat_completions, Hermes' +default) and "codex_app_server" (= hand turns to a codex subprocess). + +Both CLI (cli.py) and gateway (gateway/run.py) call into this module so the +behavior stays identical across surfaces. + +The actual runtime resolution happens in hermes_cli.runtime_provider's +_maybe_apply_codex_app_server_runtime() helper, which reads the persisted +config value. This module just persists the value and reports the change. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +VALID_RUNTIMES = ("auto", "codex_app_server") + + +@dataclass +class CodexRuntimeStatus: + """Result of a /codex-runtime invocation. Callers render this however + suits their surface (CLI uses Rich panels, gateway sends a text message).""" + + success: bool + new_value: Optional[str] = None + old_value: Optional[str] = None + message: str = "" + requires_new_session: bool = False + codex_binary_ok: bool = True + codex_version: Optional[str] = None + + +def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]: + """Parse the slash-command argument string. Returns (value, errors). + + No args → return current state (value=None) + 'auto' / 'codex_app_server' / 'on' / 'off' → return that value + anything else → error + """ + raw = (arg_string or "").strip().lower() + if not raw: + return None, [] + # Accept human-friendly synonyms + if raw in ("on", "codex", "enable"): + return "codex_app_server", [] + if raw in ("off", "default", "disable", "hermes"): + return "auto", [] + if raw in VALID_RUNTIMES: + return raw, [] + return None, [ + f"Unknown runtime {raw!r}. Use one of: auto, codex_app_server, on, off" + ] + + +def get_current_runtime(config: dict) -> str: + """Read the current `model.openai_runtime` value from a config dict. + Returns 'auto' for unset / empty / unrecognized values.""" + if not isinstance(config, dict): + return "auto" + model_cfg = config.get("model") or {} + if not isinstance(model_cfg, dict): + return "auto" + value = str(model_cfg.get("openai_runtime") or "").strip().lower() + if value in VALID_RUNTIMES: + return value + return "auto" + + +def set_runtime(config: dict, new_value: str) -> str: + """Mutate the config dict in place to persist the new runtime value. + Returns the previous value for callers that want to report a delta.""" + if new_value not in VALID_RUNTIMES: + raise ValueError( + f"invalid runtime {new_value!r}; must be one of {VALID_RUNTIMES}" + ) + old = get_current_runtime(config) + if not isinstance(config.get("model"), dict): + config["model"] = {} + config["model"]["openai_runtime"] = new_value + return old + + +def check_codex_binary_ok() -> tuple[bool, Optional[str]]: + """Best-effort verification that codex CLI is installed at acceptable + version. Returns (ok, version_or_message).""" + try: + from agent.transports.codex_app_server import check_codex_binary + + return check_codex_binary() + except Exception as exc: # pragma: no cover + return False, f"codex check failed: {exc}" + + +def apply( + config: dict, + new_value: Optional[str], + *, + persist_callback=None, +) -> CodexRuntimeStatus: + """Top-level entry point used by both CLI and gateway handlers. + + Args: + config: in-memory config dict (will be mutated when new_value is set) + new_value: desired runtime; None means "show current state only" + persist_callback: optional callable taking the mutated config dict + and persisting it to disk. Skipped when None (used by tests). + + Returns: CodexRuntimeStatus describing the outcome. + """ + current = get_current_runtime(config) + + # Cache the codex binary check for this apply() call. Subprocess spawn + # is cheap (~50ms for `codex --version`), but we'd otherwise call it up + # to 3 times in the enable path (read-only/state, gate, success message). + # None = not yet checked; (bool, str) = result. + _binary_check: Optional[tuple[bool, Optional[str]]] = None + + def _check_binary_cached() -> tuple[bool, Optional[str]]: + nonlocal _binary_check + if _binary_check is None: + _binary_check = check_codex_binary_ok() + return _binary_check + + # Read-only call: just report state + if new_value is None: + ok, ver = _check_binary_cached() + msg = ( + f"openai_runtime: {current}\n" + f"codex CLI: {'OK ' + ver if ok else 'not available — ' + (ver or 'install with `npm i -g @openai/codex`')}" + ) + return CodexRuntimeStatus( + success=True, + new_value=current, + old_value=current, + message=msg, + codex_binary_ok=ok, + codex_version=ver if ok else None, + ) + + # No change requested + if new_value == current: + return CodexRuntimeStatus( + success=True, + new_value=current, + old_value=current, + message=f"openai_runtime already set to {current}", + ) + + # If switching ON, verify codex CLI is installed before persisting — + # an opt-in toggle that silently fails on the first turn is the + # worst possible UX. Block here with a clear install hint. + if new_value == "codex_app_server": + ok, ver_or_msg = _check_binary_cached() + if not ok: + return CodexRuntimeStatus( + success=False, + new_value=None, + old_value=current, + message=( + "Cannot enable codex_app_server runtime: " + f"{ver_or_msg or 'codex CLI not available'}\n" + "Install with: npm i -g @openai/codex" + ), + codex_binary_ok=False, + codex_version=None, + ) + + set_runtime(config, new_value) + if persist_callback is not None: + try: + persist_callback(config) + except Exception as exc: + logger.exception("failed to persist openai_runtime change") + return CodexRuntimeStatus( + success=False, + new_value=new_value, + old_value=current, + message=f"updated config in memory but persist failed: {exc}", + ) + + msg_lines = [ + f"openai_runtime: {current} → {new_value}", + ] + if new_value == "codex_app_server": + ok, ver = _check_binary_cached() + if ok: + msg_lines.append(f"codex CLI: {ver}") + # Auto-migrate Hermes' MCP servers + Codex's installed curated + # plugins into ~/.codex/config.toml so the spawned codex subprocess + # sees the same tool surface AND can call back into Hermes for + # browser/web/delegate_task/vision/memory tools (#7 fix). + # Failures are non-fatal — the runtime change still proceeds. + try: + from hermes_cli.codex_runtime_plugin_migration import migrate + mig_report = migrate(config) + # Tools/MCP servers (excluding the hermes-tools callback, + # which is internal plumbing — surface separately). + user_servers = [ + s for s in mig_report.migrated if s != "hermes-tools" + ] + if user_servers: + msg_lines.append( + f"Migrated {len(user_servers)} MCP server(s): " + f"{', '.join(user_servers)}" + ) + # Native Codex plugin migration (Linear, GitHub, etc.) + if mig_report.migrated_plugins: + msg_lines.append( + f"Migrated {len(mig_report.migrated_plugins)} native " + f"Codex plugin(s): {', '.join(mig_report.migrated_plugins)}" + ) + elif mig_report.plugin_query_error: + msg_lines.append( + f"Codex plugin discovery skipped: " + f"{mig_report.plugin_query_error}" + ) + # Permissions + Hermes tool callback are always-on production + # bits the user benefits from knowing about. + if mig_report.wrote_permissions_default: + msg_lines.append( + f"Default sandbox: {mig_report.wrote_permissions_default} " + f"(no approval prompt on every write)" + ) + if "hermes-tools" in mig_report.migrated: + msg_lines.append( + "Hermes tool callback registered: codex can now use " + "web_search, web_extract, browser_*, vision_analyze, " + "image_generate, skill_view, skills_list, text_to_speech, " + "kanban_* (worker + orchestrator) via MCP." + ) + msg_lines.append( + " (delegate_task, memory, session_search, todo run " + "only on the default Hermes runtime — they need the " + "agent loop context.)" + ) + msg_lines.append(f" (config: {mig_report.target_path})") + for err in mig_report.errors: + msg_lines.append(f"⚠ MCP migration: {err}") + except Exception as exc: + msg_lines.append(f"⚠ MCP migration skipped: {exc}") + msg_lines.append( + "OpenAI/Codex turns now run through `codex app-server` " + "(terminal/file ops/patching inside Codex; " + "Hermes tools available via MCP callback)." + ) + msg_lines.append( + "Effective on next session — current cached agent keeps " + "the prior runtime to preserve prompt cache." + ) + else: + msg_lines.append("OpenAI/Codex turns will use the default Hermes runtime.") + msg_lines.append("Effective on next session.") + return CodexRuntimeStatus( + success=True, + new_value=new_value, + old_value=current, + message="\n".join(msg_lines), + requires_new_session=True, + ) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 56a62c85a0a4..b3556d3932df 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -104,6 +104,8 @@ class CommandDef: args_hint=""), CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session", args_hint="[text | pause | resume | clear | status]"), + CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session", + args_hint="[text | remove N | clear]"), CommandDef("status", "Show session info", "Session"), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), @@ -120,6 +122,8 @@ class CommandDef: cli_only=True), CommandDef("model", "Switch model for this session", "Configuration", aliases=("provider",), args_hint="[model] [--provider name] [--global]"), + CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models", + "Configuration", args_hint="[auto|codex_app_server]"), CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info", cli_only=True), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4c2596594ec0..c3a8152f4a7d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -731,6 +731,12 @@ def _ensure_hermes_home_managed(home: Path): "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count + "protect_first_n": 3, # non-system head messages always preserved + # verbatim, in ADDITION to the system prompt + # (which is always implicitly protected). Set to + # 0 for long-running rolling-compaction sessions + # where you want nothing pinned except the + # system prompt + rolling summary + recent tail. }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). @@ -971,6 +977,21 @@ def _ensure_hermes_home_managed(home: Path): # Web dashboard settings "dashboard": { "theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose" + # Hide the token/cost analytics surfaces (Analytics page, token bars and + # cost figures on the Models page) by default. The numbers shown there + # are a local debug estimate: they only count successful main-agent + # responses with a usable ``response.usage``, and silently exclude every + # auxiliary call (context compression, title generation, vision, + # session search, web extract, smart approval, MCP routing, plugin LLM + # access) plus provider-side retries, fallback attempts, and any call + # whose usage block didn't come back. Cache writes are also missing + # from the API response. On models with heavy auxiliary traffic + # (Kimi K2.6, MiniMax M2.7) the local total can be 10x-100x lower than + # the provider bill, which is worse than hiding the numbers entirely + # because they look precise enough to compare against the provider. + # Set this to True to re-enable the surfaces with the understanding + # that the numbers are a local lower-bound estimate, not billing. + "show_token_analytics": False, }, # Privacy settings @@ -1229,6 +1250,9 @@ def _ensure_hermes_home_managed(home: Path): "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) + "thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads) + "history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out) + "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES @@ -2107,10 +2131,10 @@ def _ensure_hermes_home_managed(home: Path): "category": "tool", }, "FAL_KEY": { - "description": "FAL API key for image generation", + "description": "FAL API key for image and video generation", "prompt": "FAL API key", "url": "https://fal.ai/", - "tools": ["image_generate"], + "tools": ["image_generate", "video_generate"], "password": True, "category": "tool", }, @@ -4319,10 +4343,34 @@ def load_env() -> Dict[str, str]: concatenated KEY=VALUE pairs on a single line) are handled gracefully instead of producing mangled values such as duplicated bot tokens. See #8908. + + The parsed dict is memoised keyed on the .env file mtime, because + ``get_env_value()`` is called dozens-to-hundreds of times per + interactive menu render (`hermes tools`, `hermes setup`, status + panels). Sanitisation is O(lines × known-keys), so re-parsing the + same file on every call was burning ~300ms of CPU per `hermes tools` + menu paint on top of the OAuth-refresh slowness. The mtime check + invalidates the cache when the user edits .env mid-process. """ + global _env_cache env_path = get_env_path() - env_vars = {} - + + try: + mtime = env_path.stat().st_mtime + size = env_path.stat().st_size + cache_key = (str(env_path), mtime, size) + except FileNotFoundError: + cache_key = (str(env_path), None, None) + except Exception: + cache_key = None + + if cache_key is not None and _env_cache is not None: + cached_key, cached_vars = _env_cache + if cached_key == cache_key: + return dict(cached_vars) + + env_vars: Dict[str, str] = {} + if env_path.exists(): # On Windows, open() defaults to the system locale (cp1252) which can # fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM @@ -4338,10 +4386,33 @@ def load_env() -> Dict[str, str]: if line and not line.startswith('#') and '=' in line: key, _, value = line.partition('=') env_vars[key.strip()] = value.strip().strip('"\'') - + + if cache_key is not None: + _env_cache = (cache_key, dict(env_vars)) + return env_vars +# Module-level memo for load_env(), keyed on (path, mtime, size). +# Editing .env bumps mtime → next load_env() rebuilds. invalidate_env_cache() +# is the explicit knob for writers that update .env via this module +# (set_env_value, save_env, etc.) without relying on filesystem mtime +# resolution. +_env_cache: Optional[Tuple[Tuple[str, Optional[float], Optional[int]], Dict[str, str]]] = None + + +def invalidate_env_cache() -> None: + """Clear the load_env() process-level memo. + + Writers that mutate .env (set_env_value, save_env, etc.) call this + to guarantee the next load_env() sees their change even on + filesystems with coarse mtime resolution. Reads invalidate naturally + via the mtime/size check. + """ + global _env_cache + _env_cache = None + + def _sanitize_env_lines(lines: list) -> list: """Fix corrupted .env lines before reading or writing. @@ -4444,6 +4515,7 @@ def sanitize_env_file() -> int: pass raise _secure_file(env_path) + invalidate_env_cache() return fixes @@ -4555,6 +4627,7 @@ def save_env_value(key: str, value: str): _secure_file(env_path) os.environ[key] = value + invalidate_env_cache() def remove_env_value(key: str) -> bool: @@ -4610,6 +4683,7 @@ def remove_env_value(key: str) -> bool: _secure_file(env_path) os.environ.pop(key, None) + invalidate_env_cache() return found @@ -4796,6 +4870,7 @@ def show_config(): print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved") print(f" Protect last: {compression.get('protect_last_n', 20)} messages") + print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages") _aux_comp = config.get('auxiliary', {}).get('compression', {}) _sm = _aux_comp.get('model', '') or '(auto)' print(f" Model: {_sm}") diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 6a8a2ae971fa..1542b9a7a382 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -33,8 +33,8 @@ import logging import re import time -from dataclasses import dataclass, asdict -from typing import Any, Dict, Optional, Tuple +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) @@ -65,6 +65,21 @@ "If you are blocked and need input from the user, say so clearly and stop." ) +# Used when the user has added one or more /subgoal criteria. Surfaced +# to the agent verbatim so it sees what to target on the next turn, +# and surfaced to the judge so the verdict considers them too. +CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = ( + "[Continuing toward your standing goal]\n" + "Goal: {goal}\n\n" + "Additional criteria the user added mid-loop:\n" + "{subgoals_block}\n\n" + "Continue working toward the goal AND all additional criteria. Take " + "the next concrete step. If you believe the goal and every " + "additional criterion are complete, state so explicitly and stop. " + "If you are blocked and need input from the user, say so clearly " + "and stop." +) + JUDGE_SYSTEM_PROMPT = ( "You are a strict judge evaluating whether an autonomous agent has " @@ -88,6 +103,23 @@ "Is the goal satisfied?" ) +# Used when the user has added /subgoal criteria. The judge must +# evaluate ALL of them being met, not just the original goal. +JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE = ( + "Goal:\n{goal}\n\n" + "Additional criteria the user added mid-loop (all must also be " + "satisfied for the goal to be DONE):\n{subgoals_block}\n\n" + "Agent's most recent response:\n{response}\n\n" + "Decision: For each numbered criterion above, find concrete " + "evidence in the agent's response that the criterion is " + "satisfied. Do not accept generic phrases like 'all requirements " + "met' or 'implying it was done' — require specific evidence (a " + "file contents excerpt, an output line, a command result). If " + "ANY criterion lacks specific evidence in the response, the goal " + "is NOT done — return CONTINUE.\n\n" + "Is the goal AND every additional criterion satisfied?" +) + # ────────────────────────────────────────────────────────────────────── # Dataclass @@ -108,6 +140,12 @@ 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 + # User-added criteria appended mid-loop via the /subgoal command. + # When non-empty the judge prompt and continuation prompt both + # include them so the agent works toward them and the judge factors + # them into the verdict. Backwards-compatible: defaults to empty so + # old state_meta rows load unchanged. + subgoals: List[str] = field(default_factory=list) def to_json(self) -> str: return json.dumps(asdict(self), ensure_ascii=False) @@ -115,6 +153,10 @@ def to_json(self) -> str: @classmethod def from_json(cls, raw: str) -> "GoalState": data = json.loads(raw) + raw_subgoals = data.get("subgoals") or [] + subgoals: List[str] = [] + if isinstance(raw_subgoals, list): + subgoals = [str(s).strip() for s in raw_subgoals if str(s).strip()] return cls( goal=data.get("goal", ""), status=data.get("status", "active"), @@ -126,8 +168,18 @@ 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), + subgoals=subgoals, ) + # --- subgoals helpers ------------------------------------------------- + + def render_subgoals_block(self) -> str: + """Render the subgoals as a numbered ``- N. text`` block. Empty + when no subgoals exist.""" + if not self.subgoals: + return "" + return "\n".join(f"- {i}. {text}" for i, text in enumerate(self.subgoals, start=1)) + # ────────────────────────────────────────────────────────────────────── # Persistence (SessionDB state_meta) @@ -284,6 +336,7 @@ def judge_goal( last_response: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT, + subgoals: Optional[List[str]] = None, ) -> Tuple[str, str, bool]: """Ask the auxiliary model whether the goal is satisfied. @@ -296,6 +349,11 @@ def judge_goal( auto-pause after N consecutive parse failures (see ``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``). + ``subgoals`` is an optional list of user-added criteria (from + ``/subgoal``) that the judge must also factor into its DONE/CONTINUE + decision. When non-empty the prompt switches to the with-subgoals + template; otherwise behavior is identical to the original judge. + 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. @@ -321,10 +379,22 @@ def judge_goal( 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), - ) + # Build the prompt — pick the with-subgoals variant when applicable. + clean_subgoals = [s.strip() for s in (subgoals or []) if s and s.strip()] + if clean_subgoals: + subgoals_block = "\n".join( + f"- {i}. {text}" for i, text in enumerate(clean_subgoals, start=1) + ) + prompt = JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE.format( + goal=_truncate(goal, 2000), + subgoals_block=_truncate(subgoals_block, 2000), + response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS), + ) + else: + prompt = JUDGE_USER_PROMPT_TEMPLATE.format( + goal=_truncate(goal, 2000), + response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS), + ) try: resp = client.chat.completions.create( @@ -397,14 +467,15 @@ def status_line(self) -> str: 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" + sub = f", {len(s.subgoals)} subgoal{'s' if len(s.subgoals) != 1 else ''}" if s.subgoals else "" if s.status == "active": - return f"⊙ Goal (active, {turns}): {s.goal}" + return f"⊙ Goal (active, {turns}{sub}): {s.goal}" if s.status == "paused": extra = f" — {s.paused_reason}" if s.paused_reason else "" - return f"⏸ Goal (paused, {turns}{extra}): {s.goal}" + return f"⏸ Goal (paused, {turns}{sub}{extra}): {s.goal}" if s.status == "done": - return f"✓ Goal done ({turns}): {s.goal}" - return f"Goal ({s.status}, {turns}): {s.goal}" + return f"✓ Goal done ({turns}{sub}): {s.goal}" + return f"Goal ({s.status}, {turns}{sub}): {s.goal}" # --- mutation ----------------------------------------------------- @@ -457,6 +528,53 @@ 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) -> str: + """Append a user-added criterion to the active goal. Requires + ``has_goal()``; raises ``RuntimeError`` otherwise. + + Returns the cleaned text so the caller can show it back to the user. + """ + if self._state is None or not self.has_goal(): + raise RuntimeError("no active goal") + text = (text or "").strip() + if not text: + raise ValueError("subgoal text is empty") + self._state.subgoals.append(text) + save_goal(self.session_id, self._state) + return text + + def remove_subgoal(self, index_1based: int) -> str: + """Remove a subgoal by 1-based index. Returns the removed text.""" + if self._state is None or not self.has_goal(): + raise RuntimeError("no active goal") + idx = int(index_1based) - 1 + if idx < 0 or idx >= len(self._state.subgoals): + raise IndexError( + f"index out of range (1..{len(self._state.subgoals)})" + ) + removed = self._state.subgoals.pop(idx) + save_goal(self.session_id, self._state) + return removed + + def clear_subgoals(self) -> int: + """Wipe all subgoals. Returns the previous count.""" + if self._state is None or not self.has_goal(): + raise RuntimeError("no active goal") + prev = len(self._state.subgoals) + self._state.subgoals = [] + save_goal(self.session_id, self._state) + return prev + + def render_subgoals(self) -> str: + """Public helper for the /subgoal slash command.""" + if self._state is None: + return "(no active goal)" + if not self._state.subgoals: + return "(no subgoals — use /subgoal to add criteria)" + return self._state.render_subgoals_block() + # --- the main entry point called after every turn ----------------- def evaluate_after_turn( @@ -494,7 +612,9 @@ def evaluate_after_turn( state.turns_used += 1 state.last_turn_at = time.time() - verdict, reason, parse_failed = judge_goal(state.goal, last_response) + verdict, reason, parse_failed = judge_goal( + state.goal, last_response, subgoals=state.subgoals or None + ) state.last_verdict = verdict state.last_reason = reason @@ -579,6 +699,11 @@ def evaluate_after_turn( def next_continuation_prompt(self) -> Optional[str]: if not self._state or self._state.status != "active": return None + if self._state.subgoals: + return CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE.format( + goal=self._state.goal, + subgoals_block=self._state.render_subgoals_block(), + ) return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal) @@ -586,6 +711,9 @@ def next_continuation_prompt(self) -> Optional[str]: "GoalState", "GoalManager", "CONTINUATION_PROMPT_TEMPLATE", + "CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE", + "JUDGE_USER_PROMPT_TEMPLATE", + "JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE", "DEFAULT_MAX_TURNS", "load_goal", "save_goal", diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py new file mode 100644 index 000000000000..5cf32d1c847c --- /dev/null +++ b/hermes_cli/inventory.py @@ -0,0 +1,240 @@ +"""Provider/model inventory context — shared substrate for the dashboard +``/api/model/options``, the TUI ``model.options``/``model.save_key`` +JSON-RPC handlers, and the interactive picker. + +Before this module the three call-sites each duplicated: + +1. The 17-LOC config-slice that pulls ``model.{default,name,provider,base_url}``, + ``providers:``, and ``custom_providers:`` out of ``load_config()``; +2. The call into ``list_authenticated_providers`` with the resulting kwargs; +3. (TUI only) a 45-LOC post-pass that merges authenticated rows with + unconfigured ``CANONICAL_PROVIDERS`` rows and emits ``authenticated``/ + ``auth_type``/``key_env``/``warning`` hints for the picker UI. + +Consolidating those three steps into one entry point eliminates two bugs +the duplicates were hiding: + +- The dashboard read ``cfg.get("custom_providers")`` directly, missing the + v12+ keyed ``providers:`` form (which the TUI handled via + ``get_compatible_custom_providers``). +- The TUI's canonical-merge keyed on ``is_user_defined`` to decide + ordering. Section 3 of ``list_authenticated_providers`` sets + ``is_user_defined=True`` even for canonical slugs that appear in the + ``providers:`` config dict, which silently demoted them to the tail of + the picker. ``_reorder_canonical`` keys on slug membership instead. + +Substrate facts (verified May 2026): +- ``list_authenticated_providers`` already populates each row's + ``models`` from the curated catalog (same source as the picker). Do + NOT call ``provider_model_ids()`` per row to "freshen" — that bypasses + curation and pulls in non-agentic models (Nous /models returns ~400 + IDs including TTS, embeddings, rerankers, image/video generators). +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Optional + + +# ─── Public types ─────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ConfigContext: + """Snapshot of the model + provider config every inventory caller + needs. Built once via ``load_picker_context()``; the TUI overlays + live agent state via ``with_overrides()`` before passing through. + """ + + current_provider: str + current_model: str + current_base_url: str + user_providers: dict + custom_providers: list + + def with_overrides( + self, + *, + current_provider: Optional[str] = None, + current_model: Optional[str] = None, + current_base_url: Optional[str] = None, + ) -> "ConfigContext": + """Return a copy with truthy overrides applied. + + Truthy-only because the TUI reads agent attributes that may be + empty strings before an agent is spawned — empties must NOT + clobber the disk-config values. + """ + kw: dict = {} + if current_provider: + kw["current_provider"] = current_provider + if current_model: + kw["current_model"] = current_model + if current_base_url: + kw["current_base_url"] = current_base_url + return replace(self, **kw) if kw else self + + +def load_picker_context() -> ConfigContext: + """Load the disk-config snapshot every consumer needs. + + Replaces the inline 17-LOC config-slice that ``web_server.py`` and + ``tui_gateway/server.py`` (×2 sites) used to do. + """ + from hermes_cli.config import get_compatible_custom_providers, load_config + + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_model = model_cfg.get("default", model_cfg.get("name", "")) or "" + current_provider = model_cfg.get("provider", "") or "" + current_base_url = model_cfg.get("base_url", "") or "" + else: + # config.model can be a bare string in older configs. + current_model = str(model_cfg) if model_cfg else "" + current_provider = "" + current_base_url = "" + raw = cfg.get("providers") + return ConfigContext( + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + user_providers=raw if isinstance(raw, dict) else {}, + custom_providers=get_compatible_custom_providers(cfg), + ) + + +# ─── Public: payload builder ──────────────────────────────────────────── + + +def build_models_payload( + ctx: ConfigContext, + *, + include_unconfigured: bool = False, + picker_hints: bool = False, + canonical_order: bool = False, + max_models: int = 50, +) -> dict: + """Build the ``{providers, model, provider}`` shape every consumer + needs from a single substrate call. + + Flags: + - ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that + ``list_authenticated_providers`` didn't emit (TUI uses this to show + the full provider universe in the picker). + - ``picker_hints``: add ``authenticated``/``auth_type``/``key_env``/ + ``warning`` per row (TUI ``ModelPickerDialog`` shape). + - ``canonical_order``: reorder canonical-slug rows to + ``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go + last (TUI display order). + """ + from hermes_cli.model_switch import list_authenticated_providers + + rows = list_authenticated_providers( + current_provider=ctx.current_provider, + current_base_url=ctx.current_base_url, + current_model=ctx.current_model, + user_providers=ctx.user_providers, + custom_providers=ctx.custom_providers, + max_models=max_models, + ) + + if include_unconfigured: + rows = list(rows) + _append_unconfigured_rows(rows, ctx) + if picker_hints: + _apply_picker_hints(rows) + if canonical_order: + rows = _reorder_canonical(rows) + + return { + "providers": rows, + "model": ctx.current_model, + "provider": ctx.current_provider, + } + + +# ─── Internal: row post-processing ────────────────────────────────────── + + +def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: + """Build skeleton rows for canonical providers missing from ``rows``.""" + from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS + + seen = {r["slug"].lower() for r in rows} + cur = (ctx.current_provider or "").lower() + extras: list[dict] = [] + for entry in CANONICAL_PROVIDERS: + if entry.slug.lower() in seen: + continue + extras.append( + { + "slug": entry.slug, + "name": _PROVIDER_LABELS.get(entry.slug, entry.label), + "is_current": entry.slug.lower() == cur, + "is_user_defined": False, + "models": [], + "total_models": 0, + "source": "canonical", + } + ) + return extras + + +def _apply_picker_hints(rows: list[dict]) -> None: + """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. + + Mutates ``rows`` in-place. Rows already from + ``list_authenticated_providers`` are marked ``authenticated=True``; + the unconfigured skeleton rows from ``_append_unconfigured_rows`` get + the picker's setup-hint shape. + """ + from hermes_cli.auth import PROVIDER_REGISTRY + + for row in rows: + if "authenticated" in row: + continue + # Distinguish authenticated rows (returned by + # list_authenticated_providers) from skeleton rows (from + # _append_unconfigured_rows). The skeleton rows have empty + # `models` AND source="canonical"; authenticated rows have + # populated `models` OR a non-canonical source. + is_skeleton = row.get("source") == "canonical" and not row.get("models") + row["authenticated"] = not is_skeleton + if not is_skeleton or row.get("is_user_defined"): + continue + cfg = PROVIDER_REGISTRY.get(row["slug"]) + auth_type = cfg.auth_type if cfg else "api_key" + key_env = ( + cfg.api_key_env_vars[0] + if (cfg and cfg.api_key_env_vars) + else "" + ) + row["auth_type"] = auth_type + row["key_env"] = key_env + row["warning"] = ( + f"paste {key_env} to activate" + if auth_type == "api_key" and key_env + else f"run `hermes model` to configure ({auth_type})" + ) + + +def _reorder_canonical(rows: list[dict]) -> list[dict]: + """Canonical slugs in ``CANONICAL_PROVIDERS`` declaration order; + truly-custom rows last. + + Keys on slug membership, NOT ``is_user_defined`` — section 3 of + ``list_authenticated_providers`` sets ``is_user_defined=True`` on + rows from the ``providers:`` config dict even when the slug is + canonical. Keying on the flag would silently demote canonical + providers configured via the new keyed schema. + """ + from hermes_cli.models import CANONICAL_PROVIDERS + + order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)} + canon = sorted( + (r for r in rows if r["slug"] in order), + key=lambda r: order[r["slug"]], + ) + extras = [r for r in rows if r["slug"] not in order] + return canon + extras diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e8aa0d761c46..e448e2b18ee6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1452,6 +1452,17 @@ def cmd_gateway(args): gateway_command(args) +def cmd_proxy(args): + """Local OpenAI-compatible proxy to OAuth providers.""" + # Lazy import — pulls in aiohttp, which is gated behind an extras install + # for users who don't run the proxy or the messaging gateway. + from hermes_cli.proxy.cli import cmd_proxy as _cmd_proxy + + rc = _cmd_proxy(args) + if isinstance(rc, int) and rc != 0: + raise SystemExit(rc) + + def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" _require_tty("whatsapp") @@ -2414,30 +2425,31 @@ def _prompt_provider_choice(choices, *, default=0): def _model_flow_openrouter(config, current_model=""): """OpenRouter provider: ensure API key, then pick model.""" from hermes_cli.auth import ( + ProviderConfig, _prompt_model_selection, _save_model_choice, deactivate_provider, ) - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value - api_key = get_env_value("OPENROUTER_API_KEY") - if not api_key: - print("No OpenRouter API key configured.") + # Route through _prompt_api_key so users can replace a stale/broken key + # in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand. The + # previous bypass-when-key-exists branch left no way to recover from a + # bad paste short of re-running `hermes setup` from scratch. OpenRouter + # isn't in PROVIDER_REGISTRY so we synthesize a minimal pconfig. + pconfig = ProviderConfig( + id="openrouter", + name="OpenRouter", + auth_type="api_key", + api_key_env_vars=("OPENROUTER_API_KEY",), + ) + existing_key = get_env_value("OPENROUTER_API_KEY") or "" + if not existing_key: print("Get one at: https://openrouter.ai/keys") print() - try: - import getpass - - key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not key: - print("Cancelled.") - return - save_env_value("OPENROUTER_API_KEY", key) - print("API key saved.") - print() + _resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="openrouter") + if abort: + return from hermes_cli.models import model_ids, get_pricing_for_provider @@ -2473,33 +2485,26 @@ def _model_flow_openrouter(config, current_model=""): def _model_flow_ai_gateway(config, current_model=""): """Vercel AI Gateway provider: ensure API key, then pick model with pricing.""" from hermes_cli.auth import ( + PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, deactivate_provider, ) - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value - api_key = get_env_value("AI_GATEWAY_API_KEY") - if not api_key: - print("No Vercel AI Gateway API key configured.") + # Route through _prompt_api_key so users can replace a stale/broken key + # in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand. + pconfig = PROVIDER_REGISTRY["ai-gateway"] + existing_key = get_env_value("AI_GATEWAY_API_KEY") or "" + if not existing_key: print( "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway" ) print("Add a payment method to get $5 in free credits.") print() - try: - import getpass - - key = getpass.getpass("AI Gateway API key (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not key: - print("Cancelled.") - return - save_env_value("AI_GATEWAY_API_KEY", key) - print("API key saved.") - print() + _resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="ai-gateway") + if abort: + return from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider @@ -3079,6 +3084,21 @@ def _model_flow_custom(config): else: print(f" If /v1 should not be in the base URL, try: {suggested}") + # Prompt for API compatibility mode explicitly so codex-compatible custom + # providers don't silently fall back to chat_completions. + current_model_cfg = config.get("model") + current_api_mode = "" + if isinstance(current_model_cfg, dict): + current_api_mode = str(current_model_cfg.get("api_mode") or "").strip() + api_mode = _prompt_custom_api_mode_selection( + effective_url, + current_api_mode=current_api_mode, + ) + if api_mode: + print(f" API mode: {api_mode}") + else: + print(" API mode: auto-detect") + # Select model — use probe results when available, fall back to manual input model_name = "" detected_models = probe.get("models") or [] @@ -3142,7 +3162,10 @@ def _model_flow_custom(config): model["base_url"] = effective_url if effective_key: model["api_key"] = effective_key - model.pop("api_mode", None) # let runtime auto-detect from URL + if api_mode: + model["api_mode"] = api_mode + else: + model.pop("api_mode", None) save_config(cfg) deactivate_provider() @@ -3165,7 +3188,10 @@ def _model_flow_custom(config): _caller_model["base_url"] = effective_url if effective_key: _caller_model["api_key"] = effective_key - _caller_model.pop("api_mode", None) + if api_mode: + _caller_model["api_mode"] = api_mode + else: + _caller_model.pop("api_mode", None) config["model"] = _caller_model print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") @@ -3176,9 +3202,80 @@ def _model_flow_custom(config): model_name or "", context_length=context_length, name=display_name, + api_mode=api_mode, ) +def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: + """Prompt for a custom provider API mode. + + Returns an explicit mode string, or None to keep auto-detect behavior. + """ + from hermes_cli.runtime_provider import _detect_api_mode_for_url + + detected_mode = _detect_api_mode_for_url(base_url) + normalized_current = str(current_api_mode or "").strip().lower() + default_mode = normalized_current or detected_mode or "" + + mode_options = [ + ( + "", + "Auto-detect", + "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.", + ), + ( + "chat_completions", + "Chat Completions", + "Use /chat/completions for standard OpenAI-compatible servers.", + ), + ( + "codex_responses", + "Responses / Codex", + "Use /responses for Codex-compatible tool-calling backends.", + ), + ( + "anthropic_messages", + "Anthropic Messages", + "Use /v1/messages for Anthropic-compatible endpoints.", + ), + ] + + print() + print("Select API compatibility mode:") + for idx, (value, label, description) in enumerate(mode_options, 1): + markers = [] + if value == detected_mode: + markers.append("detected") + if value == default_mode: + markers.append("current") + suffix = f" [{' / '.join(markers)}]" if markers else "" + print(f" {idx}. {label}{suffix}") + print(f" {description}") + + try: + raw = input( + "Choice [1-4, Enter to keep current/detected]: " + ).strip().lower() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + raise + + if not raw: + return default_mode or None + + if raw in {"1", "auto", "detect", "auto-detect"}: + return None + if raw in {"2", "chat", "chat_completions", "completions"}: + return "chat_completions" + if raw in {"3", "responses", "codex", "codex_responses"}: + return "codex_responses" + if raw in {"4", "anthropic", "anthropic_messages", "messages"}: + return "anthropic_messages" + + print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.") + return None + + def _auto_provider_name(base_url: str) -> str: """Generate a display name from a custom endpoint URL. @@ -3214,12 +3311,12 @@ def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""): def _save_custom_provider( - base_url, api_key="", model="", context_length=None, name=None + base_url, api_key="", model="", context_length=None, name=None, api_mode=None ): """Save a custom endpoint to custom_providers in config.yaml. Deduplicates by base_url — if the URL already exists, updates the - model name and context_length but doesn't add a duplicate entry. + model name, context_length, and api_mode but doesn't add a duplicate entry. Uses *name* when provided, otherwise auto-generates from the URL. """ from hermes_cli.config import load_config, save_config @@ -3245,6 +3342,13 @@ def _save_custom_provider( models_cfg[model] = {"context_length": context_length} entry["models"] = models_cfg changed = True + if api_mode: + if entry.get("api_mode") != api_mode: + entry["api_mode"] = api_mode + changed = True + elif "api_mode" in entry: + entry.pop("api_mode", None) + changed = True if changed: cfg["custom_providers"] = providers save_config(cfg) @@ -3259,6 +3363,8 @@ def _save_custom_provider( entry["api_key"] = api_key if model: entry["model"] = model + if api_mode: + entry["api_mode"] = api_mode if model and context_length: entry["models"] = {model: {"context_length": context_length}} @@ -3712,7 +3818,7 @@ def _model_flow_named_custom(config, provider_info): save_config(cfg) else: # Save model name to the custom_providers entry for next time - _save_custom_provider(base_url, config_api_key, model_name) + _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode) print(f"\n✅ Model set to: {model_name}") print(f" Provider: {name} ({base_url})") @@ -4869,6 +4975,37 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): ) if model_list: print(f" Found {len(model_list)} model(s) from Ollama Cloud") + elif provider_id == "novita": + from hermes_cli.models import fetch_api_models + + api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") + curated = _PROVIDER_MODELS.get(provider_id, []) + live_models = fetch_api_models(api_key_for_probe, effective_base) + if live_models: + model_list = live_models + print(f" Found {len(model_list)} model(s) from {pconfig.name} API") + else: + mdev_models: list = [] + try: + from agent.models_dev import list_agentic_models + + mdev_models = list_agentic_models(provider_id) + except Exception: + pass + if mdev_models: + seen = {m.lower() for m in mdev_models} + model_list = list(mdev_models) + for m in curated: + if m.lower() not in seen: + model_list.append(m) + seen.add(m.lower()) + print(f" Found {len(model_list)} model(s) from models.dev registry") + else: + model_list = curated + if model_list: + print( + f' Showing {len(model_list)} curated models — use "Enter custom model name" for others.' + ) else: curated = _PROVIDER_MODELS.get(provider_id, []) @@ -5544,21 +5681,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if not _web_ui_build_needed(web_dir): return True + # Console-encoding-safe print: Windows consoles default to cp1252 + # (or similar) and will raise UnicodeEncodeError on arrow / check + # glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print + # in this function through _say() with errors="replace" keeps the + # build path usable on a stock `py -m hermes_cli.main web` invocation. + def _say(text: str) -> None: + try: + print(text) + except UnicodeEncodeError: + encoding = getattr(sys.stdout, "encoding", None) or "ascii" + print(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) + npm = shutil.which("npm") if not npm: if fatal: - print("Web UI frontend not built and npm is not available.") - print("Install Node.js, then run: cd web && npm install && npm run build") + _say("Web UI frontend not built and npm is not available.") + _say("Install Node.js, then run: cd web && npm install && npm run build") return not fatal - print("→ Building web UI...") + _say("→ Building web UI...") + + def _relay(result: "subprocess.CompletedProcess") -> None: + """Print captured npm output so users can see *why* a step failed. + + Windows users hitting `rm -rf` / `cp -r` errors (or any other + sync-assets / Vite failure) would otherwise see only ``Web UI + build failed`` with no hint of the underlying cause, because + the npm calls run with ``capture_output=True``. + """ + for blob in (result.stdout, result.stderr): + if not blob: + continue + text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() + if text: + _say(text) + r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",)) if r1.returncode != 0: - print( + _say( f" {'✗' if fatal else '⚠'} Web UI npm install failed" + ("" if fatal else " (hermes web will not be available)") ) + _relay(r1) if fatal: - print(" Run manually: cd web && npm install && npm run build") + _say(" Run manually: cd web && npm install && npm run build") return False # First attempt r2 = subprocess.run( @@ -5593,21 +5759,20 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: # 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") + _say(" ⚠ Web UI build failed — serving stale dist as fallback") if stderr_tail: - print(f" Build error:\n {stderr_tail}") + _say(f" Build error:\n {stderr_tail}") return True - print( + _say( 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}") + _relay(r2) if fatal: - print(" Run manually: cd web && npm install && npm run build") + _say(" Run manually: cd web && npm install && npm run build") return False - print(" ✓ Web UI built") + _say(" ✓ Web UI built") return True @@ -6701,6 +6866,74 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: pass +def _refresh_active_lazy_features() -> None: + """Refresh lazy-installed backends after a code update. + + When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most + optional backends moved to ``tools/lazy_deps.py`` and only install on + first use. ``hermes update`` runs ``uv pip install -e .[all]`` which + leaves those packages untouched — so if we bump a pin in + :data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already + activated the backend keep the stale version forever. + + This function asks lazy_deps which features the user has previously + activated and reinstalls them under the current pins. Features the + user never enabled stay quiet — no churn for cold backends. + + Never raises. A failure here must not block the rest of the update. + """ + try: + from tools import lazy_deps + except Exception as exc: + logger.debug("Lazy refresh skipped (import failed): %s", exc) + return + + try: + active = lazy_deps.active_features() + except Exception as exc: + logger.debug("Lazy refresh skipped (active_features failed): %s", exc) + return + + if not active: + return + + print() + print(f"→ Refreshing {len(active)} active lazy backend(s)...") + + try: + results = lazy_deps.refresh_active_features(prompt=False) + except Exception as exc: + # refresh_active_features is documented as never-raise, but defend + # the update flow against future regressions. + print(f" ⚠ Lazy refresh failed unexpectedly: {exc}") + return + + refreshed = [f for f, s in results.items() if s == "refreshed"] + current = [f for f, s in results.items() if s == "current"] + failed = [(f, s) for f, s in results.items() if s.startswith("failed:")] + skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")] + + if refreshed: + print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}") + if current: + print(f" ✓ {len(current)} already current") + if skipped: + # Most common reason: security.allow_lazy_installs=false. Show one + # line so the user knows why; not an error. + names = ", ".join(f for f, _ in skipped) + reason = skipped[0][1].split(": ", 1)[-1] + print(f" · {len(skipped)} skipped ({reason}): {names}") + if failed: + for feature, status in failed: + reason = status.split(": ", 1)[-1] + # Clip noisy pip stderr to keep update output legible. + if len(reason) > 200: + reason = reason[:200] + "..." + print(f" ⚠ {feature} failed to refresh: {reason}") + print(" Backends keep their previously-installed version; rerun") + print(" `hermes update` once the upstream issue is resolved.") + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -7623,6 +7856,8 @@ def _cmd_update_impl(args, gateway_mode: bool): _install_psutil_android_compat(pip_cmd) _install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) + _refresh_active_lazy_features() + _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") @@ -9168,7 +9403,7 @@ def _build_provider_choices() -> list[str]: "auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry", "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", - "stepfun", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee", + "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go", ] @@ -9189,7 +9424,7 @@ def _build_provider_choices() -> list[str]: "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", - "model", "pairing", "plugins", "profile", "sessions", "setup", + "model", "pairing", "plugins", "profile", "proxy", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "chat", # Help-ish invocations — plugin commands not being listed in @@ -9531,6 +9766,51 @@ def main(): help="Skip the confirmation prompt", ) + # ========================================================================= + # proxy command — local OpenAI-compatible proxy that attaches the user's + # OAuth-authenticated provider credentials to outbound requests. Lets + # external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in + # subscription without copy-pasting static API keys. + # ========================================================================= + proxy_parser = subparsers.add_parser( + "proxy", + help="Local OpenAI-compatible proxy to OAuth providers", + description=( + "Run a local HTTP server that forwards OpenAI-compatible requests " + "to an OAuth-authenticated provider (e.g. Nous Portal). External " + "apps can point at the proxy with any bearer token; the proxy " + "attaches your real credentials." + ), + ) + proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command") + + proxy_start = proxy_subparsers.add_parser( + "start", help="Run the proxy in the foreground" + ) + proxy_start.add_argument( + "--provider", + default="nous", + help="Upstream provider (default: nous). See `hermes proxy providers`.", + ) + proxy_start.add_argument( + "--host", + default=None, + help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.", + ) + proxy_start.add_argument( + "--port", + type=int, + default=None, + help="Bind port (default: 8645)", + ) + + proxy_subparsers.add_parser( + "status", help="Show which proxy upstreams are ready" + ) + proxy_subparsers.add_parser( + "providers", help="List available proxy upstream providers" + ) + proxy_parser.set_defaults(func=cmd_proxy) gateway_parser.set_defaults(func=cmd_gateway) # ========================================================================= diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 7b2c60672883..1ee5ed2ec8ec 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -10,6 +10,7 @@ import getpass import os import sys +import shlex from pathlib import Path from hermes_constants import get_hermes_home @@ -134,7 +135,7 @@ def _install_dependencies(provider_name: str) -> None: if check_cmd: try: subprocess.run( - check_cmd, shell=True, capture_output=True, timeout=5 + shlex.split(check_cmd), check=True, capture_output=True, timeout=5 ) except Exception: if install_cmd: @@ -378,6 +379,12 @@ def _write_env_vars(env_path: Path, env_writes: dict) -> None: new_lines.append(f"{key}={val}") env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + # Restrict permissions — .env holds API keys and tokens. + try: + import stat + env_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 + except OSError: + pass # Windows or read-only FS # --------------------------------------------------------------------------- diff --git a/hermes_cli/models.py b/hermes_cli/models.py index eb55b59ee5db..ce7592452d66 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -320,6 +320,36 @@ def _xai_curated_models() -> list[str]: "trinity-large-preview", "trinity-mini", ], + "auriko": [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gpt-5.5-2026-04-23", + "gpt-5.4-2026-03-05", + "o4-mini-2025-04-16", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v3.2", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-3.1-pro-preview", + "grok-4.3", + "grok-4-fast-reasoning", + "kimi-k2.6", + "kimi-k2.5", + "kimi-k2-thinking", + "minimax-m2-7", + "minimax-m2-7-highspeed", + "minimax-m2", + "glm-5.1", + "glm-5", + "glm-4.7", + "glm-4.5-flash", + "qwen-3.6-plus", + "qwen-3.5-397b-a17b", + "qwen-3-vl-30b-a3b-thinking", + ], "gmi": [ "zai-org/GLM-5.1-FP8", "deepseek-ai/DeepSeek-V3.2", @@ -445,6 +475,14 @@ def _xai_curated_models() -> list[str]: # Azure Foundry: user-provided endpoint and model. # Empty list because models depend on the endpoint configuration. "azure-foundry": [], + "novita": [ + "moonshotai/kimi-k2.5", + "minimax/minimax-m2.7", + "zai-org/glm-5", + "deepseek/deepseek-v3-0324", + "deepseek/deepseek-r1-0528", + "qwen/qwen3-235b-a22b-fp8", + ], } # Vercel AI Gateway: derive the bare-model-id catalog from the curated @@ -905,6 +943,7 @@ class ProviderEntry(NamedTuple): CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"), ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"), + ProviderEntry("novita", "NovitaAI", "NovitaAI (AI-native cloud: Model API, Agent Sandbox, GPU Cloud)"), ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"), ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"), ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"), @@ -984,6 +1023,7 @@ class ProviderEntry(NamedTuple): "stepfun-coding-plan": "stepfun", "arcee-ai": "arcee", "arceeai": "arcee", + "auriko-ai": "auriko", "gmi-cloud": "gmi", "gmicloud": "gmi", "minimax-china": "minimax-cn", @@ -1014,6 +1054,8 @@ class ProviderEntry(NamedTuple): "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", + "novita-ai": "novita", + "novitaai": "novita", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "tencent": "tencent-tokenhub", @@ -1494,7 +1536,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: - """Return live pricing for providers that support it (openrouter, nous, ai-gateway).""" + """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita).""" normalized = normalize_provider(provider) if normalized == "openrouter": return fetch_models_with_pricing( @@ -1504,6 +1546,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d ) if normalized == "ai-gateway": return fetch_ai_gateway_pricing(force_refresh=force_refresh) + if normalized == "novita": + return _fetch_novita_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1520,6 +1564,65 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d return {} +def _fetch_novita_pricing( + timeout: float = 8.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Fetch pricing from NovitaAI /v1/models. + + NovitaAI returns input/output prices per million tokens in units of + 0.0001 USD. Convert them to the per-token strings used by the shared + pricing formatter. + + Results are cached in ``_pricing_cache`` keyed on the resolved base URL, + matching the pattern used by ``fetch_ai_gateway_pricing`` — without this, + every menu render or pricing lookup re-hits the network. + """ + api_key = os.getenv("NOVITA_API_KEY", "").strip() + if not api_key: + return {} + + base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1" + cache_key = base_url.rstrip("/") + if not force_refresh and cache_key in _pricing_cache: + return _pricing_cache[cache_key] + + url = cache_key + "/models" + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": _HERMES_USER_AGENT, + } + + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _pricing_cache[cache_key] = {} + return {} + + result: dict[str, dict[str, str]] = {} + for item in payload.get("data", []): + if not isinstance(item, dict): + continue + mid = item.get("id") + if not mid: + continue + inp = item.get("input_token_price_per_m") + out = item.get("output_token_price_per_m") + if inp is None and out is None: + continue + result[str(mid)] = { + "prompt": str(float(inp or 0) / 10_000 / 1_000_000), + "completion": str(float(out or 0) / 10_000 / 1_000_000), + } + + _pricing_cache[cache_key] = result + return result + + # All provider IDs and aliases that are valid for the provider:model syntax. _KNOWN_PROVIDER_NAMES: set[str] = ( set(_PROVIDER_LABELS.keys()) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 70b0dc9cd7f5..9e9af0e0644d 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -542,6 +542,61 @@ def register_image_gen_provider(self, provider) -> None: self.manifest.name, provider.name, ) + # -- video gen provider registration ------------------------------------- + + def register_video_gen_provider(self, provider) -> None: + """Register a video generation backend. + + ``provider`` must be an instance of + :class:`agent.video_gen_provider.VideoGenProvider`. The + ``provider.name`` attribute is what ``video_gen.provider`` in + ``config.yaml`` matches against when routing ``video_generate`` + tool calls. + """ + from agent.video_gen_provider import VideoGenProvider + from agent.video_gen_registry import register_provider as _register_video_provider + + if not isinstance(provider, VideoGenProvider): + logger.warning( + "Plugin '%s' tried to register a video_gen provider that does " + "not inherit from VideoGenProvider. Ignoring.", + self.manifest.name, + ) + return + _register_video_provider(provider) + logger.info( + "Plugin '%s' registered video_gen provider: %s", + self.manifest.name, provider.name, + ) + + # -- web search/extract provider registration ---------------------------- + + def register_web_search_provider(self, provider) -> None: + """Register a web search/extract backend. + + ``provider`` must be an instance of + :class:`agent.web_search_provider.WebSearchProvider`. The + ``provider.name`` attribute is what ``web.search_backend`` / + ``web.extract_backend`` / ``web.backend`` in ``config.yaml`` + matches against when routing ``web_search`` / ``web_extract`` + tool calls. + """ + from agent.web_search_provider import WebSearchProvider + from agent.web_search_registry import register_provider as _register_web_provider + + if not isinstance(provider, WebSearchProvider): + logger.warning( + "Plugin '%s' tried to register a web provider that does " + "not inherit from WebSearchProvider. Ignoring.", + self.manifest.name, + ) + return + _register_web_provider(provider) + logger.info( + "Plugin '%s' registered web provider: %s", + self.manifest.name, provider.name, + ) + # -- platform adapter registration --------------------------------------- def register_platform( @@ -1312,6 +1367,21 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: +_thread_tool_whitelist = threading.local() + + +def set_thread_tool_whitelist( + allowed: Optional[Set[str]], + deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist", +) -> None: + _thread_tool_whitelist.allowed = allowed + _thread_tool_whitelist.fmt = deny_msg_fmt + + +def clear_thread_tool_whitelist() -> None: + _thread_tool_whitelist.allowed = None + + def get_pre_tool_call_block_message( tool_name: str, args: Optional[Dict[str, Any]], @@ -1330,6 +1400,11 @@ def get_pre_tool_call_block_message( directive wins. Invalid or irrelevant hook return values are silently ignored so existing observer-only hooks are unaffected. """ + allowed = getattr(_thread_tool_whitelist, "allowed", None) + if allowed is not None and tool_name not in allowed: + fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied") + return fmt.format(tool_name=tool_name) + hook_results = invoke_hook( "pre_tool_call", tool_name=tool_name, diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index f766a50ebf95..b9c064755551 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -156,6 +156,11 @@ class HermesOverlay: is_aggregator=True, base_url_env_var="HF_BASE_URL", ), + "novita": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + base_url_env_var="NOVITA_BASE_URL", + ), "xai": HermesOverlay( transport="codex_responses", base_url_override="https://api.x.ai/v1", @@ -179,6 +184,11 @@ class HermesOverlay: base_url_override="https://api.arcee.ai/api/v1", base_url_env_var="ARCEE_BASE_URL", ), + "auriko": HermesOverlay( + transport="openai_chat", + base_url_override="https://api.auriko.ai/v1", + base_url_env_var="AURIKO_BASE_URL", + ), "gmi": HermesOverlay( transport="openai_chat", extra_env_vars=("GMI_API_KEY",), @@ -309,6 +319,10 @@ class ProviderDef: "hugging-face": "huggingface", "huggingface-hub": "huggingface", + # novita + "novita-ai": "novita", + "novitaai": "novita", + # xiaomi "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", @@ -329,6 +343,9 @@ class ProviderDef: "arcee-ai": "arcee", "arceeai": "arcee", + # auriko + "auriko-ai": "auriko", + # gmi "gmi-cloud": "gmi", "gmicloud": "gmi", @@ -356,6 +373,7 @@ class ProviderDef: "stepfun": "StepFun Step Plan", "xiaomi": "Xiaomi MiMo", "gmi": "GMI Cloud", + "auriko": "Auriko", "tencent-tokenhub": "Tencent TokenHub", "lmstudio": "LM Studio", "local": "Local endpoint", diff --git a/hermes_cli/proxy/__init__.py b/hermes_cli/proxy/__init__.py new file mode 100644 index 000000000000..c8775990fa6e --- /dev/null +++ b/hermes_cli/proxy/__init__.py @@ -0,0 +1,20 @@ +"""Local OpenAI-compatible proxy that forwards to OAuth-authenticated upstreams. + +Lets external apps (OpenViking, Karakeep, Open WebUI, ...) ride the user's +already-logged-in provider subscription instead of needing a static API key +copy-pasted into each app's config. + +The proxy listens on ``127.0.0.1:``, accepts any bearer (the client's +``Authorization`` header is discarded), and attaches the user's real +upstream credential to the forwarded request. The credential is refreshed +automatically when it approaches expiry. + +First-class adapter: + - ``nous`` — Nous Portal (https://inference-api.nousresearch.com/v1) + +Future adapters can plug in by implementing ``UpstreamAdapter``. +""" + +from hermes_cli.proxy.adapters.base import UpstreamAdapter + +__all__ = ["UpstreamAdapter"] diff --git a/hermes_cli/proxy/adapters/__init__.py b/hermes_cli/proxy/adapters/__init__.py new file mode 100644 index 000000000000..163d1e66f987 --- /dev/null +++ b/hermes_cli/proxy/adapters/__init__.py @@ -0,0 +1,35 @@ +"""Upstream adapter registry for the local proxy server. + +Each adapter wraps a provider's OAuth state and exposes a uniform interface +the proxy server can use to forward requests with a freshly-minted bearer +token. See :class:`UpstreamAdapter` for the contract. +""" + +from typing import Dict, Type + +from hermes_cli.proxy.adapters.base import UpstreamAdapter +from hermes_cli.proxy.adapters.nous_portal import NousPortalAdapter + +# Registry of available adapter classes keyed by provider name as used on +# the ``hermes proxy start --provider `` CLI flag. +ADAPTERS: Dict[str, Type[UpstreamAdapter]] = { + "nous": NousPortalAdapter, +} + + +def get_adapter(name: str) -> UpstreamAdapter: + """Instantiate an adapter by provider name. + + Raises: + ValueError: if ``name`` is not a registered adapter. + """ + key = (name or "").strip().lower() + if key not in ADAPTERS: + available = ", ".join(sorted(ADAPTERS)) or "(none)" + raise ValueError( + f"Unknown proxy upstream provider: {name!r}. Available: {available}" + ) + return ADAPTERS[key]() + + +__all__ = ["UpstreamAdapter", "ADAPTERS", "get_adapter"] diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py new file mode 100644 index 000000000000..5ac8a5dcedd2 --- /dev/null +++ b/hermes_cli/proxy/adapters/base.py @@ -0,0 +1,94 @@ +"""Abstract base for proxy upstream adapters. + +An :class:`UpstreamAdapter` represents one OAuth-authenticated provider the +local proxy can forward requests to. The adapter is responsible for: + + - locating the user's auth state for that provider + - refreshing/minting credentials when needed + - reporting the resolved upstream base URL + - declaring which request paths it accepts + +The proxy server is otherwise provider-agnostic. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import FrozenSet, Optional + + +@dataclass(frozen=True) +class UpstreamCredential: + """A resolved bearer + base URL ready to forward to.""" + + bearer: str + """Authorization header value to send upstream (token only, no ``Bearer`` prefix).""" + + base_url: str + """Upstream base URL, e.g. ``https://inference-api.nousresearch.com/v1``.""" + + token_type: str = "Bearer" + """Auth scheme — currently always ``Bearer`` for supported providers.""" + + expires_at: Optional[str] = None + """ISO-8601 expiry timestamp for the bearer, when known. Informational.""" + + +class UpstreamAdapter(ABC): + """Contract for an upstream provider the proxy can forward to.""" + + @property + @abstractmethod + def name(self) -> str: + """Adapter key used on the CLI (e.g. ``"nous"``).""" + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable provider name for logs and ``proxy status``.""" + + @property + @abstractmethod + def allowed_paths(self) -> FrozenSet[str]: + """Set of relative request paths the upstream accepts. + + Paths are relative to the proxy's ``/v1`` mount point. For example, + ``"/chat/completions"`` corresponds to a client request to + ``http://127.0.0.1:/v1/chat/completions``. Requests to paths + not in this set get a 404 with a helpful error body. + """ + + @abstractmethod + def is_authenticated(self) -> bool: + """Return True if the user has usable credentials for this upstream. + + Should be cheap — no network calls. Used by ``proxy start`` for a + clear up-front error before binding a port. + """ + + @abstractmethod + def get_credential(self) -> UpstreamCredential: + """Return a fresh credential, refreshing/minting if necessary. + + Implementations should: + - refresh the access token if it's near expiry + - mint/rotate the upstream bearer key if it's near expiry + - persist any refreshed state back to disk + + Raises: + RuntimeError: if the user isn't authenticated or the upstream + refresh fails. The proxy will return 401 to the client. + """ + + def describe(self) -> str: + """One-line status summary for ``proxy status``.""" + try: + cred = self.get_credential() + except Exception as exc: # pragma: no cover - defensive + return f"{self.display_name}: not ready ({exc})" + ttl = f" (expires {cred.expires_at})" if cred.expires_at else "" + return f"{self.display_name}: {cred.base_url}{ttl}" + + +__all__ = ["UpstreamAdapter", "UpstreamCredential"] diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py new file mode 100644 index 000000000000..b72cbd305b33 --- /dev/null +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -0,0 +1,137 @@ +"""Nous Portal upstream adapter. + +Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes +the access token and mints a fresh agent key when needed, and exposes the +upstream base URL plus minted bearer for the proxy server to forward to. + +The minted ``agent_key`` (not the OAuth ``access_token``) is what +``inference-api.nousresearch.com`` accepts as a bearer. The refresh helper +already handles both — see :func:`hermes_cli.auth.refresh_nous_oauth_from_state`. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Dict, FrozenSet, Optional + +from hermes_cli.auth import ( + DEFAULT_NOUS_INFERENCE_URL, + _load_auth_store, + _save_auth_store, + _write_shared_nous_state, + refresh_nous_oauth_from_state, +) +from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential + +logger = logging.getLogger(__name__) + +# Endpoints inference-api.nousresearch.com actually serves. Anything else +# the proxy will reject with 404 — keeps stray clients from leaking weird +# requests to the upstream. +_ALLOWED_PATHS: FrozenSet[str] = frozenset( + { + "/chat/completions", + "/completions", + "/embeddings", + "/models", + } +) + + +class NousPortalAdapter(UpstreamAdapter): + """Proxy upstream for the Nous Portal inference API.""" + + def __init__(self) -> None: + # Lock guards _load → refresh → _save against parallel proxy requests + # racing to refresh expired tokens. Refresh itself is HTTP, so we + # hold the lock across the network call (brief; OAuth refresh is fast). + self._lock = threading.Lock() + + @property + def name(self) -> str: + return "nous" + + @property + def display_name(self) -> str: + return "Nous Portal" + + @property + def allowed_paths(self) -> FrozenSet[str]: + return _ALLOWED_PATHS + + def is_authenticated(self) -> bool: + state = self._read_state() + if state is None: + return False + # We need either a usable agent_key OR (refresh_token + access_token) + # to recover. The refresh helper will mint/refresh as needed. + return bool( + state.get("agent_key") + or (state.get("refresh_token") and state.get("access_token")) + ) + + def get_credential(self) -> UpstreamCredential: + with self._lock: + state = self._read_state() + if state is None: + raise RuntimeError( + "Not logged into Nous Portal. Run `hermes login nous` first." + ) + + try: + refreshed = refresh_nous_oauth_from_state(state) + except Exception as exc: + raise RuntimeError( + f"Failed to refresh Nous Portal credentials: {exc}" + ) from exc + + self._save_state(refreshed) + + agent_key = refreshed.get("agent_key") + if not agent_key: + raise RuntimeError( + "Nous Portal refresh did not return a usable agent_key. " + "Try `hermes login nous` to re-authenticate." + ) + + base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL + base_url = base_url.rstrip("/") + + return UpstreamCredential( + bearer=agent_key, + base_url=base_url, + expires_at=refreshed.get("agent_key_expires_at"), + ) + + # ------------------------------------------------------------------ + # Internal helpers — auth.json access. Kept local rather than added + # to hermes_cli.auth to avoid expanding that module's public surface. + # ------------------------------------------------------------------ + + def _read_state(self) -> Optional[Dict[str, Any]]: + try: + store = _load_auth_store() + except Exception as exc: + logger.warning("proxy: failed to load auth store: %s", exc) + return None + providers = store.get("providers") or {} + state = providers.get("nous") + if not isinstance(state, dict): + return None + return dict(state) # copy so the refresh helper can mutate freely + + def _save_state(self, state: Dict[str, Any]) -> None: + try: + store = _load_auth_store() + providers = store.setdefault("providers", {}) + providers["nous"] = state + _save_auth_store(store) + _write_shared_nous_state(state) + except Exception as exc: + # Best effort — we still return the fresh credential. The next + # request just won't see cached state, which means another refresh. + logger.warning("proxy: failed to persist refreshed Nous state: %s", exc) + + +__all__ = ["NousPortalAdapter"] diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py new file mode 100644 index 000000000000..83c2d34035b6 --- /dev/null +++ b/hermes_cli/proxy/cli.py @@ -0,0 +1,141 @@ +"""CLI handlers for the ``hermes proxy`` subcommand.""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from typing import Any + +from hermes_cli.proxy.adapters import ADAPTERS, get_adapter +from hermes_cli.proxy.server import ( + AIOHTTP_AVAILABLE, + DEFAULT_HOST, + DEFAULT_PORT, + run_server, +) + +logger = logging.getLogger(__name__) + + +def _print_aiohttp_missing() -> None: + print( + "hermes proxy requires aiohttp. Install one of:\n" + " pip install 'hermes-agent[messaging]'\n" + " pip install aiohttp", + file=sys.stderr, + ) + + +def cmd_proxy_start(args: Any) -> int: + """Run the proxy server in the foreground. + + Returns process exit code (0 on clean shutdown). + """ + if not AIOHTTP_AVAILABLE: + _print_aiohttp_missing() + return 1 + + provider = getattr(args, "provider", None) or "nous" + try: + adapter = get_adapter(provider) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + if not adapter.is_authenticated(): + print( + f"Not logged into {adapter.display_name}. " + f"Run `hermes login {adapter.name}` first.", + file=sys.stderr, + ) + return 2 + + host = getattr(args, "host", None) or DEFAULT_HOST + port = getattr(args, "port", None) or DEFAULT_PORT + + print( + f"Starting Hermes proxy for {adapter.display_name}\n" + f" Listening on: http://{host}:{port}/v1\n" + f" Forwarding to: (resolved per-request from your subscription)\n" + f" Use any bearer token in the client — the proxy attaches your real credential.\n" + f"\n" + f"Press Ctrl+C to stop.", + file=sys.stderr, + ) + + try: + asyncio.run(run_server(adapter, host=host, port=port)) + except KeyboardInterrupt: + print("\nproxy: stopped", file=sys.stderr) + except OSError as exc: + print(f"proxy: failed to bind {host}:{port}: {exc}", file=sys.stderr) + return 1 + return 0 + + +def cmd_proxy_status(args: Any) -> int: + """Print the status of each configured upstream adapter.""" + print("Hermes proxy upstream adapters\n") + for name in sorted(ADAPTERS): + adapter = get_adapter(name) + if not adapter.is_authenticated(): + print(f" [{name:8s}] {adapter.display_name} — not logged in") + continue + try: + cred = adapter.get_credential() + except Exception as exc: + print( + f" [{name:8s}] {adapter.display_name} — credentials need attention " + f"({exc})" + ) + continue + expires = f" (bearer expires {cred.expires_at})" if cred.expires_at else "" + print(f" [{name:8s}] {adapter.display_name} — ready{expires}") + print( + "\nStart the proxy with: hermes proxy start [--provider ]" + ) + return 0 + + +def cmd_proxy_list_providers(args: Any) -> int: + """List available proxy upstream providers.""" + print("Available proxy upstream providers:") + for name in sorted(ADAPTERS): + adapter = get_adapter(name) + print(f" {name} — {adapter.display_name}") + return 0 + + +def cmd_proxy(args: Any) -> int: + """Dispatch ``hermes proxy ``.""" + sub = getattr(args, "proxy_command", None) + if sub == "start": + return cmd_proxy_start(args) + if sub == "status": + return cmd_proxy_status(args) + if sub in ("providers", "list"): + return cmd_proxy_list_providers(args) + # No subcommand → print short help. + print( + "hermes proxy — local OpenAI-compatible proxy that attaches your\n" + "OAuth-authenticated provider credentials to outbound requests.\n" + "\n" + "Subcommands:\n" + " hermes proxy start [--provider nous] [--host 127.0.0.1] [--port 8645]\n" + " Run the proxy in the foreground.\n" + " hermes proxy status\n" + " Show which upstream adapters are ready.\n" + " hermes proxy providers\n" + " List available upstream providers.\n", + file=sys.stderr, + ) + return 0 + + +__all__ = [ + "cmd_proxy", + "cmd_proxy_start", + "cmd_proxy_status", + "cmd_proxy_list_providers", +] diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py new file mode 100644 index 000000000000..48de784afe4f --- /dev/null +++ b/hermes_cli/proxy/server.py @@ -0,0 +1,265 @@ +"""HTTP server that forwards OpenAI-compatible requests to a configured upstream. + +Listens on ``http://:/v1/`` and forwards each request to +``/`` with the client's ``Authorization`` header +replaced by a freshly-resolved bearer from the configured adapter. The +response is streamed back unmodified, preserving SSE. + +The server is intentionally minimal: it does NOT mediate, log, transform, +or rewrite request/response bodies. It's a credential-attaching forwarder. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import signal +from typing import Optional + +try: + import aiohttp + from aiohttp import web + AIOHTTP_AVAILABLE = True +except ImportError: + aiohttp = None # type: ignore[assignment] + web = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +from hermes_cli.proxy.adapters.base import UpstreamAdapter + +logger = logging.getLogger(__name__) + +# Headers we strip when forwarding to the upstream. ``host``/``content-length`` +# are recomputed by aiohttp; ``authorization`` is replaced with our bearer. +# Everything else (content-type, accept, user-agent, x-* headers) passes through. +_HOP_BY_HOP_HEADERS = frozenset( + { + "host", + "content-length", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "authorization", # we replace this one + } +) + +DEFAULT_PORT = 8645 +DEFAULT_HOST = "127.0.0.1" + + +def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response": + """Return an OpenAI-style error JSON response.""" + body = {"error": {"message": message, "type": code, "code": code}} + return web.json_response(body, status=status) + + +def _filter_request_headers(headers: "aiohttp.typedefs.LooseHeaders") -> dict: + """Strip hop-by-hop + auth headers from the inbound request.""" + out = {} + for key, value in headers.items(): + if key.lower() in _HOP_BY_HOP_HEADERS: + continue + out[key] = value + return out + + +def _filter_response_headers(headers) -> dict: + """Strip hop-by-hop headers from the upstream response.""" + out = {} + for key, value in headers.items(): + if key.lower() in _HOP_BY_HOP_HEADERS: + continue + # aiohttp recomputes Content-Encoding/Content-Length on stream — let it. + if key.lower() in ("content-encoding", "content-length"): + continue + out[key] = value + return out + + +def create_app(adapter: UpstreamAdapter) -> "web.Application": + """Build the aiohttp application bound to a specific upstream adapter.""" + if not AIOHTTP_AVAILABLE: + raise RuntimeError( + "aiohttp is required for `hermes proxy`. Install with: " + "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + ) + + app = web.Application() + # AppKey ensures forward-compat with future aiohttp versions that strip + # bare-string keys. + _adapter_key = web.AppKey("adapter", UpstreamAdapter) + app[_adapter_key] = adapter + + async def handle_health(request: "web.Request") -> "web.Response": + return web.json_response( + { + "status": "ok", + "upstream": adapter.display_name, + "authenticated": adapter.is_authenticated(), + } + ) + + async def handle_models_fallback(request: "web.Request") -> "web.Response": + # Most clients hit /v1/models on startup. If the upstream doesn't + # serve /models, synthesize a minimal response so clients don't + # crash. The actual forwarding path handles /models when allowed. + return web.json_response( + { + "object": "list", + "data": [], + } + ) + + async def handle_proxy(request: "web.Request") -> "web.StreamResponse": + # Extract the path *after* /v1 + rel_path = request.match_info.get("tail", "") + rel_path = "/" + rel_path.lstrip("/") + + if rel_path not in adapter.allowed_paths: + allowed = ", ".join(sorted(adapter.allowed_paths)) + return _json_error( + 404, + f"Path /v1{rel_path} is not forwarded by this proxy. " + f"Allowed: {allowed}", + code="path_not_allowed", + ) + + try: + cred = adapter.get_credential() + except Exception as exc: + logger.warning("proxy: credential resolution failed: %s", exc) + return _json_error(401, str(exc), code="upstream_auth_failed") + + upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}" + # Preserve query string verbatim. + if request.query_string: + upstream_url = f"{upstream_url}?{request.query_string}" + + # Forward body verbatim. Read into memory once — request bodies for + # chat/completions/embeddings are small (<1MB typically). If we ever + # need to forward large multipart uploads we'll switch to streaming + # the request body too. + body = await request.read() + + fwd_headers = _filter_request_headers(request.headers) + fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}" + + logger.debug( + "proxy: forwarding %s %s -> %s (body=%d bytes)", + request.method, rel_path, upstream_url, len(body), + ) + + # Use a per-request session so connection state doesn't leak between + # clients. Could be optimized to a shared session later. + timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300) + try: + session = aiohttp.ClientSession(timeout=timeout) + except Exception as exc: # pragma: no cover - aiohttp setup issue + return _json_error(500, f"proxy session init failed: {exc}") + + try: + upstream_resp = await session.request( + request.method, + upstream_url, + data=body if body else None, + headers=fwd_headers, + allow_redirects=False, + ) + except aiohttp.ClientError as exc: + await session.close() + logger.warning("proxy: upstream connection failed: %s", exc) + return _json_error(502, f"upstream connection failed: {exc}", + code="upstream_unreachable") + except asyncio.TimeoutError: + await session.close() + return _json_error(504, "upstream request timed out", + code="upstream_timeout") + + # Stream response back. Headers first, then chunked body. + resp = web.StreamResponse( + status=upstream_resp.status, + headers=_filter_response_headers(upstream_resp.headers), + ) + await resp.prepare(request) + + try: + async for chunk in upstream_resp.content.iter_any(): + if chunk: + await resp.write(chunk) + except (aiohttp.ClientError, asyncio.CancelledError) as exc: + logger.warning("proxy: streaming interrupted: %s", exc) + finally: + upstream_resp.release() + await session.close() + + await resp.write_eof() + return resp + + # /health doesn't go through the upstream + app.router.add_get("/health", handle_health) + # Catch-all under /v1 — forwards if the path is allowed. + app.router.add_route("*", "/v1/{tail:.*}", handle_proxy) + + return app + + +async def run_server( + adapter: UpstreamAdapter, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + shutdown_event: Optional[asyncio.Event] = None, +) -> None: + """Run the proxy in the current event loop until shutdown_event is set. + + If shutdown_event is None, runs until cancelled (Ctrl+C or SIGTERM). + """ + if not AIOHTTP_AVAILABLE: + raise RuntimeError( + "aiohttp is required for `hermes proxy`. Install with: " + "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + ) + + app = create_app(adapter) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, host=host, port=port) + await site.start() + + logger.info( + "proxy: listening on http://%s:%d/v1 -> %s", + host, port, adapter.display_name, + ) + + stop_event = shutdown_event or asyncio.Event() + + # Wire signal handlers when we own the loop's lifetime. + if shutdown_event is None: + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok + except NotImplementedError: + # Windows / restricted environments — Ctrl+C will still + # raise KeyboardInterrupt and unwind us. + pass + + try: + await stop_event.wait() + finally: + logger.info("proxy: shutting down") + await runner.cleanup() + + +__all__ = [ + "create_app", + "run_server", + "DEFAULT_HOST", + "DEFAULT_PORT", + "AIOHTTP_AVAILABLE", +] diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1652b72034c5..4ac21ea45687 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -164,7 +164,18 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: return "chat_completions" -_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"} +_VALID_API_MODES = { + "chat_completions", + "codex_responses", + "anthropic_messages", + "bedrock_converse", + # Optional opt-in: hand the entire turn to a `codex app-server` subprocess + # so terminal/file-ops/patching/sandboxing run inside Codex's own runtime + # instead of Hermes' tool dispatch. Gated behind config key + # `model.openai_runtime == "codex_app_server"` AND provider in + # {"openai", "openai-codex"}. Default is unchanged. + "codex_app_server", +} def _parse_api_mode(raw: Any) -> Optional[str]: @@ -176,6 +187,32 @@ def _parse_api_mode(raw: Any) -> Optional[str]: return None +def _maybe_apply_codex_app_server_runtime( + *, + provider: str, + api_mode: str, + model_cfg: Optional[Dict[str, Any]], +) -> str: + """Optional opt-in: rewrite api_mode → "codex_app_server" for OpenAI/Codex + providers when the user has explicitly enabled that runtime via + `model.openai_runtime: codex_app_server` in config.yaml. + + Default behavior is preserved: when the key is unset, "auto", or empty, + this function is a no-op. Only providers in {"openai", "openai-codex"} + are eligible — other providers (anthropic, openrouter, etc.) cannot be + rerouted through codex. + + Returns the (possibly-rewritten) api_mode.""" + if not model_cfg: + return api_mode + if provider not in ("openai", "openai-codex"): + return api_mode + runtime = str(model_cfg.get("openai_runtime") or "").strip().lower() + if runtime == "codex_app_server": + return "codex_app_server" + return api_mode + + def _resolve_runtime_from_pool_entry( *, provider: str, @@ -293,6 +330,12 @@ def _resolve_runtime_from_pool_entry( if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) + # Optional opt-in: route OpenAI/Codex turns through `codex app-server`. + # Inert when `model.openai_runtime` is unset or "auto". + api_mode = _maybe_apply_codex_app_server_runtime( + provider=provider, api_mode=api_mode, model_cfg=model_cfg + ) + return { "provider": provider, "api_mode": api_mode, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index df4e88e0006a..6a8bf950589c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -454,6 +454,26 @@ def _print_setup_summary(config: dict, hermes_home): else: tool_status.append(("Image Generation", False, "FAL_KEY or OPENAI_API_KEY")) + # Video generation — opt-in via `hermes tools` → Video Generation. + # Only show the row when a plugin reports available so we don't badger + # users who don't care about video gen with a "missing" status line. + try: + from agent.video_gen_registry import list_providers as _list_video_providers + from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins + _ensure_plugins() + _video_backend = None + for _vp in _list_video_providers(): + try: + if _vp.is_available(): + _video_backend = _vp.display_name + break + except Exception: + continue + except Exception: + _video_backend = None + if _video_backend: + tool_status.append((f"Video Generation ({_video_backend})", True, None)) + # TTS — show configured provider tts_provider = cfg_get(config, "tts", "provider", default="edge") if subscription_features.tts.managed_by_nous: @@ -3246,18 +3266,6 @@ def run_setup_wizard(args): print_info(f" cp {_backup_path} {config_path}") _print_setup_summary(config, hermes_home) - _offer_launch_chat() - - -def _offer_launch_chat(): - """Prompt the user to jump straight into chat after setup.""" - print() - if not prompt_yes_no("Launch hermes chat now?", True): - return - - from hermes_cli.relaunch import relaunch - relaunch(["chat"]) - def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): """Streamlined first-time setup: provider, model, terminal & messaging. @@ -3301,8 +3309,6 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): _print_setup_summary(config, hermes_home) - _offer_launch_chat() - def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 0acb41d6878c..f4d894c1e7ab 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -666,25 +666,46 @@ def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: return None +def _mapping_or_empty(value: Any, *, section: str, skin_name: str) -> Dict[str, Any]: + """Return a mapping value or an empty dict when the section type is invalid.""" + if isinstance(value, dict): + return value + if value is None: + return {} + logger.warning( + "Skin '%s' has invalid '%s' section type (%s); ignoring section", + skin_name, + section, + type(value).__name__, + ) + return {} + + def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: """Build a SkinConfig from a raw dict (built-in or loaded from YAML).""" # Start with default values as base for missing keys default = _BUILTIN_SKINS["default"] + skin_name = str(data.get("name", "unknown")) + color_overrides = _mapping_or_empty(data.get("colors"), section="colors", skin_name=skin_name) + spinner_overrides = _mapping_or_empty(data.get("spinner"), section="spinner", skin_name=skin_name) + branding_overrides = _mapping_or_empty(data.get("branding"), section="branding", skin_name=skin_name) + emoji_overrides = _mapping_or_empty(data.get("tool_emojis"), section="tool_emojis", skin_name=skin_name) + colors = dict(default.get("colors", {})) - colors.update(data.get("colors", {})) + colors.update(color_overrides) spinner = dict(default.get("spinner", {})) - spinner.update(data.get("spinner", {})) + spinner.update(spinner_overrides) branding = dict(default.get("branding", {})) - branding.update(data.get("branding", {})) + branding.update(branding_overrides) return SkinConfig( - name=data.get("name", "unknown"), + name=skin_name, description=data.get("description", ""), colors=colors, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), - tool_emojis=data.get("tool_emojis", {}), + tool_emojis=emoji_overrides, banner_logo=data.get("banner_logo", ""), banner_hero=data.get("banner_hero", ""), ) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f5e464f163ee..874740405301 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -60,6 +60,7 @@ ("vision", "👁️ Vision / Image Analysis", "vision_analyze"), ("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"), ("image_gen", "🎨 Image Generation", "image_generate"), + ("video_gen", "🎬 Video Generation", "video_generate (text-to-video + image-to-video)"), ("moa", "🧠 Mixture of Agents", "mixture_of_agents"), ("tts", "🔊 Text-to-Speech", "text_to_speech"), ("skills", "📚 Skills", "list, view, manage"), @@ -82,7 +83,11 @@ # Toolsets that are OFF by default for new installs. # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # but the setup checklist won't pre-select them for first-time users. -_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"} +# +# Video gen is off by default — it's a niche, paid, slow feature. Users +# who want it opt in via `hermes tools` → Video Generation, which walks +# them through provider + model selection. +_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video", "video_gen"} # Platform-scoped toolsets: only appear in the `hermes tools` checklist for # these platforms, and only resolve/save for these platforms. A toolset @@ -240,6 +245,15 @@ def _get_plugin_toolset_keys() -> set: "setup_title": "Select Search Provider", "setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.", "icon": "🔍", + # Per-provider rows are injected at runtime from + # plugins.web..provider via _plugin_web_search_providers() + # in _visible_providers(). Only non-provider UX setup-flow rows + # for the firecrawl backend are listed here: + # - "Nous Subscription" — managed Firecrawl billed via Nous + # subscription (requires_nous_auth + override_env_vars). + # - "Firecrawl Self-Hosted" — points firecrawl at a private + # Docker instance via FIRECRAWL_API_URL only. + # See PR #25182 for the migration rationale. "providers": [ { "name": "Nous Subscription", @@ -251,42 +265,6 @@ def _get_plugin_toolset_keys() -> set: "managed_nous_feature": "web", "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"], }, - { - "name": "Firecrawl Cloud", - "badge": "★ recommended", - "tag": "Full-featured search, extract, and crawl", - "web_backend": "firecrawl", - "env_vars": [ - {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, - ], - }, - { - "name": "Exa", - "badge": "paid", - "tag": "Neural search with semantic understanding", - "web_backend": "exa", - "env_vars": [ - {"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"}, - ], - }, - { - "name": "Parallel", - "badge": "paid", - "tag": "AI-powered search and extract", - "web_backend": "parallel", - "env_vars": [ - {"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"}, - ], - }, - { - "name": "Tavily", - "badge": "free tier", - "tag": "Search, extract, and crawl — 1000 free searches/mo", - "web_backend": "tavily", - "env_vars": [ - {"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"}, - ], - }, { "name": "Firecrawl Self-Hosted", "badge": "free · self-hosted", @@ -296,32 +274,6 @@ def _get_plugin_toolset_keys() -> set: {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}, ], }, - { - "name": "SearXNG", - "badge": "free · self-hosted · search only", - "tag": "Privacy-respecting metasearch engine — search only (pair with any extract provider)", - "web_backend": "searxng", - "env_vars": [ - {"key": "SEARXNG_URL", "prompt": "Your SearXNG instance URL (e.g., http://localhost:8080)", "url": "https://searxng.github.io/searxng/"}, - ], - }, - { - "name": "Brave Search (Free Tier)", - "badge": "free tier · search only", - "tag": "2,000 queries/mo free — search only (pair with any extract provider)", - "web_backend": "brave-free", - "env_vars": [ - {"key": "BRAVE_SEARCH_API_KEY", "prompt": "Brave Search subscription token", "url": "https://brave.com/search/api/"}, - ], - }, - { - "name": "DuckDuckGo (ddgs)", - "badge": "free · no key · search only", - "tag": "Search via the ddgs Python package — no API key (pair with any extract provider)", - "web_backend": "ddgs", - "env_vars": [], - "post_setup": "ddgs", - }, ], }, "image_gen": { @@ -349,6 +301,15 @@ def _get_plugin_toolset_keys() -> set: }, ], }, + "video_gen": { + "name": "Video Generation", + "icon": "🎬", + # Providers list is intentionally empty — every video gen backend + # is a plugin, surfaced by ``_plugin_video_gen_providers()`` and + # injected by ``_visible_providers``. Mirrors the design we'll + # converge image_gen toward. + "providers": [], + }, "browser": { "name": "Browser Automation", "icon": "🌐", @@ -1525,6 +1486,101 @@ def _plugin_image_gen_providers() -> list[dict]: return rows +def _plugin_video_gen_providers() -> list[dict]: + """Build picker-row dicts from plugin-registered video gen providers. + + Mirrors ``_plugin_image_gen_providers`` exactly — every video backend + is a plugin, so this function is the *only* source of provider rows + for the Video Generation category. The hardcoded ``TOOL_CATEGORIES`` + entry for ``video_gen`` keeps an empty providers list. + """ + try: + from agent.video_gen_registry import list_providers + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + providers = list_providers() + except Exception: + return [] + + rows: list[dict] = [] + for provider in providers: + try: + schema = provider.get_setup_schema() + except Exception: + continue + if not isinstance(schema, dict): + continue + rows.append( + { + "name": schema.get("name", provider.display_name), + "badge": schema.get("badge", ""), + "tag": schema.get("tag", ""), + "env_vars": schema.get("env_vars", []), + "video_gen_plugin_name": provider.name, + } + ) + return rows + + +# Mirror of _plugin_image_gen_providers for web search backends. Surfaces +# every plugin-registered web provider so it appears in the +# "Web Search & Extract" picker. All seven providers (brave-free, ddgs, +# searxng, exa, parallel, tavily, firecrawl) live as plugins after +# PR #25182 — this helper is the sole source of truth for the category's +# provider rows. The hardcoded entries that used to drive the category +# were deleted in the same PR; only the two non-provider UX rows +# ("Nous Subscription" managed-gateway entry, "Firecrawl Self-Hosted") +# remain in TOOL_CATEGORIES because they describe alternative *setup +# flows* for the firecrawl backend rather than distinct providers. +def _plugin_web_search_providers() -> list[dict]: + """Build picker-row dicts from plugin-registered web search providers. + + Each returned dict is a regular ``TOOL_CATEGORIES`` provider row. It + populates both ``web_backend`` (legacy field consumed by setup + + selection helpers) and ``web_search_plugin_name`` (informational + marker) so the picker behaves identically whether a provider is + hardcoded or plugin-registered. + + After PR #25182, all seven web providers (brave-free, ddgs, searxng, + exa, parallel, tavily, firecrawl) are plugins; this helper is the sole + source of provider rows for the Web Search & Extract category. + """ + try: + from agent.web_search_registry import list_providers as _list_web_providers + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + providers = _list_web_providers() + except Exception: + return [] + + rows: list[dict] = [] + for provider in providers: + name = getattr(provider, "name", None) + if not name: + continue + try: + schema = provider.get_setup_schema() + except Exception: + continue + if not isinstance(schema, dict): + continue + row = { + "name": schema.get("name", provider.display_name), + "badge": schema.get("badge", ""), + "tag": schema.get("tag", ""), + "env_vars": schema.get("env_vars", []), + "web_backend": name, + "web_search_plugin_name": name, + } + # Optional pass-through fields the schema can opt into. + if schema.get("post_setup"): + row["post_setup"] = schema["post_setup"] + rows.append(row) + return rows + + def _visible_providers(cat: dict, config: dict) -> list[dict]: """Return provider entries visible for the current auth/config state.""" features = get_nous_subscription_features(config) @@ -1541,6 +1597,19 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: if cat.get("name") == "Image Generation": visible.extend(_plugin_image_gen_providers()) + # Inject plugin-registered video_gen backends. Unlike image_gen, + # video_gen has NO hardcoded providers — every backend is a plugin. + if cat.get("name") == "Video Generation": + visible.extend(_plugin_video_gen_providers()) + + # Inject plugin-registered web search backends. After PR #25182, this + # is the SOLE source of provider rows for the Web Search & Extract + # category — the per-provider hardcoded entries were deleted. The two + # remaining hardcoded rows ("Nous Subscription", "Firecrawl + # Self-Hosted") are non-provider UX setup-flow rows for firecrawl. + if cat.get("name") == "Web Search & Extract": + visible.extend(_plugin_web_search_providers()) + return visible @@ -1608,6 +1677,23 @@ def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool: from agent.image_gen_registry import list_providers from hermes_cli.plugins import _ensure_plugins_discovered + _ensure_plugins_discovered() + for provider in list_providers(): + try: + if provider.is_available(): + return False + except Exception: + continue + except Exception: + pass + return True + if ts_key == "video_gen": + # Satisfied when any plugin-registered video gen provider reports + # available — no in-tree fallback (every backend is a plugin). + try: + from agent.video_gen_registry import list_providers + from hermes_cli.plugins import _ensure_plugins_discovered + _ensure_plugins_discovered() for provider in list_providers(): try: @@ -1952,6 +2038,106 @@ def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None: _configure_imagegen_model_for_plugin(plugin_name, config) +# ─── Video Generation Model Pickers ─────────────────────────────────────────── + + +def _plugin_video_gen_catalog(plugin_name: str): + """Return ``(catalog_dict, default_model_id)`` for a video gen plugin. + + Mirrors :func:`_plugin_image_gen_catalog`. Returns ``({}, None)`` when + the plugin isn't registered or has no models. + """ + try: + from agent.video_gen_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + provider = get_provider(plugin_name) + except Exception: + return {}, None + if provider is None: + return {}, None + try: + models = provider.list_models() or [] + default = provider.default_model() + except Exception: + return {}, None + catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m} + return catalog, default + + +def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None: + """Prompt for a video gen model from a plugin's catalog. + + Mirrors :func:`_configure_imagegen_model_for_plugin`. Writes the + selection to ``video_gen.model``. + """ + catalog, default_model = _plugin_video_gen_catalog(plugin_name) + if not catalog: + return + + cur_cfg = config.setdefault("video_gen", {}) + if not isinstance(cur_cfg, dict): + cur_cfg = {} + config["video_gen"] = cur_cfg + current_model = cur_cfg.get("model") or default_model + if current_model not in catalog: + current_model = default_model + + model_ids = list(catalog.keys()) + ordered = [current_model] + [m for m in model_ids if m != current_model] + + widths = { + "model": max(len(m) for m in model_ids), + "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6), + "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0), + } + + print() + header = ( + f" {'Model':<{widths['model']}} " + f"{'Speed':<{widths['speed']}} " + f"{'Strengths':<{widths['strengths']}} " + f"Price" + ) + print(color(header, Colors.CYAN)) + + rows = [] + for mid in ordered: + meta = catalog[mid] + row = ( + f" {mid:<{widths['model']}} " + f"{meta.get('speed', ''):<{widths['speed']}} " + f"{meta.get('strengths', ''):<{widths['strengths']}} " + f"{meta.get('price', '')}" + ) + if mid == current_model: + row += " ← currently in use" + rows.append(row) + + idx = _prompt_choice( + f" Choose {plugin_name} model:", + rows, + default=0, + ) + + chosen = ordered[idx] + cur_cfg["model"] = chosen + _print_success(f" Model set to: {chosen}") + + +def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None: + """Persist a plugin-backed video generation provider selection.""" + vid_cfg = config.setdefault("video_gen", {}) + if not isinstance(vid_cfg, dict): + vid_cfg = {} + config["video_gen"] = vid_cfg + vid_cfg["provider"] = plugin_name + vid_cfg["use_gateway"] = False + _print_success(f" video_gen.provider set to: {plugin_name}") + _configure_videogen_model_for_plugin(plugin_name, config) + + def _configure_provider(provider: dict, config: dict): """Configure a single provider - prompt for API keys and set config.""" env_vars = provider.get("env_vars", []) @@ -2014,6 +2200,12 @@ def _configure_provider(provider: dict, config: dict): if plugin_name: _select_plugin_image_gen_provider(plugin_name, config) return + # Plugin-registered video_gen provider — same flow, different + # registry. + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return # Imagegen backends prompt for model selection after backend pick. backend = provider.get("imagegen_backend") if backend: @@ -2062,6 +2254,10 @@ def _configure_provider(provider: dict, config: dict): if plugin_name: _select_plugin_image_gen_provider(plugin_name, config) return + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return # Imagegen backends prompt for model selection after env vars are in. backend = provider.get("imagegen_backend") if backend: @@ -2286,6 +2482,11 @@ def _reconfigure_provider(provider: dict, config: dict): if plugin_name: _select_plugin_image_gen_provider(plugin_name, config) return + # Plugin-registered video_gen provider — same flow, different registry. + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return # Imagegen backends prompt for model selection on reconfig too. backend = provider.get("imagegen_backend") if backend: @@ -2318,6 +2519,12 @@ def _reconfigure_provider(provider: dict, config: dict): _select_plugin_image_gen_provider(plugin_name, config) return + # Plugin-registered video_gen provider — same flow, different registry. + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return + backend = provider.get("imagegen_backend") if backend: _configure_imagegen_model(backend, config) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3f0eae0aebc2..bdb24554f87b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -994,39 +994,9 @@ def get_model_options(): can share the same types. """ try: - from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.inventory import build_models_payload, load_picker_context - cfg = load_config() - model_cfg = cfg.get("model", {}) - if isinstance(model_cfg, dict): - current_model = model_cfg.get("default", model_cfg.get("name", "")) or "" - current_provider = model_cfg.get("provider", "") or "" - current_base_url = model_cfg.get("base_url", "") or "" - else: - current_model = str(model_cfg) if model_cfg else "" - current_provider = "" - current_base_url = "" - - user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} - custom_providers = ( - cfg.get("custom_providers") - if isinstance(cfg.get("custom_providers"), list) - else [] - ) - - providers = list_authenticated_providers( - current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, - user_providers=user_providers, - custom_providers=custom_providers, - max_models=50, - ) - return { - "providers": providers, - "model": current_model, - "provider": current_provider, - } + return build_models_payload(load_picker_context(), max_models=50) except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") diff --git a/hermes_state.py b/hermes_state.py index adbdff19ac96..f693f391f78e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1597,10 +1597,10 @@ def _do(conn): self._execute_write(_do) def get_messages(self, session_id: str) -> List[Dict[str, Any]]: - """Load all messages for a session, ordered by timestamp.""" + """Load all messages for a session, ordered by insertion order.""" with self._lock: cursor = self._conn.execute( - "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id", + "SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,), ) rows = cursor.fetchall() @@ -1700,7 +1700,7 @@ def get_messages_as_conversation( "SELECT role, content, tool_call_id, tool_calls, tool_name, " "finish_reason, reasoning, reasoning_content, reasoning_details, " "codex_reasoning_items, codex_message_items " - f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY timestamp, id", + f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY id", tuple(session_ids), ).fetchall() diff --git a/optional-skills/blockchain/base/SKILL.md b/optional-skills/blockchain/base/SKILL.md deleted file mode 100644 index b5c041a97147..000000000000 --- a/optional-skills/blockchain/base/SKILL.md +++ /dev/null @@ -1,232 +0,0 @@ ---- -name: base -description: Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required. -version: 0.1.0 -author: youssefea -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [Base, Blockchain, Crypto, Web3, RPC, DeFi, EVM, L2, Ethereum] - related_skills: [] ---- - -# Base Blockchain Skill - -Query Base (Ethereum L2) on-chain data enriched with USD pricing via CoinGecko. -8 commands: wallet portfolio, token info, transactions, gas analysis, -contract inspection, whale detection, network stats, and price lookup. - -No API key needed. Uses only Python standard library (urllib, json, argparse). - ---- - -## When to Use - -- User asks for a Base wallet balance, token holdings, or portfolio value -- User wants to inspect a specific transaction by hash -- User wants ERC-20 token metadata, price, supply, or market cap -- User wants to understand Base gas costs and L1 data fees -- User wants to inspect a contract (ERC type detection, proxy resolution) -- User wants to find large ETH transfers (whale detection) -- User wants Base network health, gas price, or ETH price -- User asks "what's the price of USDC/AERO/DEGEN/ETH?" - ---- - -## Prerequisites - -The helper script uses only Python standard library (urllib, json, argparse). -No external packages required. - -Pricing data comes from CoinGecko's free API (no key needed, rate-limited -to ~10-30 requests/minute). For faster lookups, use `--no-prices` flag. - ---- - -## Quick Reference - -RPC endpoint (default): https://mainnet.base.org -Override: export BASE_RPC_URL=https://your-private-rpc.com - -Helper script path: ~/.hermes/skills/blockchain/base/scripts/base_client.py - -``` -python3 base_client.py wallet
[--limit N] [--all] [--no-prices] -python3 base_client.py tx -python3 base_client.py token -python3 base_client.py gas -python3 base_client.py contract
-python3 base_client.py whales [--min-eth N] -python3 base_client.py stats -python3 base_client.py price -``` - ---- - -## Procedure - -### 0. Setup Check - -```bash -python3 --version - -# Optional: set a private RPC for better rate limits -export BASE_RPC_URL="https://mainnet.base.org" - -# Confirm connectivity -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` - -### 1. Wallet Portfolio - -Get ETH balance and ERC-20 token holdings with USD values. -Checks ~15 well-known Base tokens (USDC, WETH, AERO, DEGEN, etc.) -via on-chain `balanceOf` calls. Tokens sorted by value, dust filtered. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -``` - -Flags: -- `--limit N` — show top N tokens (default: 20) -- `--all` — show all tokens, no dust filter, no limit -- `--no-prices` — skip CoinGecko price lookups (faster, RPC-only) - -Output includes: ETH balance + USD value, token list with prices sorted -by value, dust count, total portfolio value in USD. - -Note: Only checks known tokens. Unknown ERC-20s are not discovered. -Use the `token` command with a specific contract address for any token. - -### 2. Transaction Details - -Inspect a full transaction by its hash. Shows ETH value transferred, -gas used, fee in ETH/USD, status, and decoded ERC-20/ERC-721 transfers. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - tx 0xabc123...your_tx_hash_here -``` - -Output: hash, block, from, to, value (ETH + USD), gas price, gas used, -fee, status, contract creation address (if any), token transfers. - -### 3. Token Info - -Get ERC-20 token metadata: name, symbol, decimals, total supply, price, -market cap, and contract code size. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Output: name, symbol, decimals, total supply, price, market cap. -Reads name/symbol/decimals directly from the contract via eth_call. - -### 4. Gas Analysis - -Detailed gas analysis with cost estimates for common operations. -Shows current gas price, base fee trends over 10 blocks, block -utilization, and estimated costs for ETH transfers, ERC-20 transfers, -and swaps. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py gas -``` - -Output: current gas price, base fee, block utilization, 10-block trend, -cost estimates in ETH and USD. - -Note: Base is an L2 — actual transaction costs include an L1 data -posting fee that depends on calldata size and L1 gas prices. The -estimates shown are for L2 execution only. - -### 5. Contract Inspection - -Inspect an address: determine if it's an EOA or contract, detect -ERC-20/ERC-721/ERC-1155 interfaces, resolve EIP-1967 proxy -implementation addresses. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Output: is_contract, code size, ETH balance, detected interfaces -(ERC-20, ERC-721, ERC-1155), ERC-20 metadata, proxy implementation -address. - -### 6. Whale Detector - -Scan the most recent block for large ETH transfers with USD values. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - whales --min-eth 1.0 -``` - -Note: scans the latest block only — point-in-time snapshot, not historical. -Default threshold is 1.0 ETH (lower than Solana's default since ETH -values are higher). - -### 7. Network Stats - -Live Base network health: latest block, chain ID, gas price, base fee, -block utilization, transaction count, and ETH price. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` - -### 8. Price Lookup - -Quick price check for any token by contract address or known symbol. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price ETH -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price USDC -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price AERO -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price DEGEN -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Known symbols: ETH, WETH, USDC, cbETH, AERO, DEGEN, TOSHI, BRETT, -WELL, wstETH, rETH, cbBTC. - ---- - -## Pitfalls - -- **CoinGecko rate-limits** — free tier allows ~10-30 requests/minute. - Price lookups use 1 request per token. Use `--no-prices` for speed. -- **Public RPC rate-limits** — Base's public RPC limits requests. - For production use, set BASE_RPC_URL to a private endpoint - (Alchemy, QuickNode, Infura). -- **Wallet shows known tokens only** — unlike Solana, EVM chains have no - built-in "get all tokens" RPC. The wallet command checks ~15 popular - Base tokens via `balanceOf`. Unknown ERC-20s won't appear. Use the - `token` command for any specific contract. -- **Token names read from contract** — if a contract doesn't implement - `name()` or `symbol()`, these fields may be empty. Known tokens have - hardcoded labels as fallback. -- **Gas estimates are L2 only** — Base transaction costs include an L1 - data posting fee (depends on calldata size and L1 gas prices). The gas - command estimates L2 execution cost only. -- **Whale detector scans latest block only** — not historical. Results - vary by the moment you query. Default threshold is 1.0 ETH. -- **Proxy detection** — only EIP-1967 proxies are detected. Other proxy - patterns (EIP-1167 minimal proxy, custom storage slots) are not checked. -- **Retry on 429** — both RPC and CoinGecko calls retry up to 2 times - with exponential backoff on rate-limit errors. - ---- - -## Verification - -```bash -# Should print Base chain ID (8453), latest block, gas price, and ETH price -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` diff --git a/optional-skills/blockchain/base/scripts/base_client.py b/optional-skills/blockchain/base/scripts/base_client.py deleted file mode 100644 index cafffb49f2ed..000000000000 --- a/optional-skills/blockchain/base/scripts/base_client.py +++ /dev/null @@ -1,1008 +0,0 @@ -#!/usr/bin/env python3 -""" -Base Blockchain CLI Tool for Hermes Agent ------------------------------------------- -Queries the Base (Ethereum L2) JSON-RPC API and CoinGecko for enriched on-chain data. -Uses only Python standard library — no external packages required. - -Usage: - python3 base_client.py stats - python3 base_client.py wallet
[--limit N] [--all] [--no-prices] - python3 base_client.py tx - python3 base_client.py token - python3 base_client.py gas - python3 base_client.py contract
- python3 base_client.py whales [--min-eth N] - python3 base_client.py price - -Environment: - BASE_RPC_URL Override the default RPC endpoint (default: https://mainnet.base.org) -""" - -import argparse -import json -import os -import sys -import time -import urllib.request -import urllib.error -from typing import Any, Dict, List, Optional, Tuple - -RPC_URL = os.environ.get( - "BASE_RPC_URL", - "https://mainnet.base.org", -) - -WEI_PER_ETH = 10**18 -GWEI = 10**9 - -# ERC-20 function selectors (first 4 bytes of keccak256 hash) -SEL_BALANCE_OF = "70a08231" -SEL_NAME = "06fdde03" -SEL_SYMBOL = "95d89b41" -SEL_DECIMALS = "313ce567" -SEL_TOTAL_SUPPLY = "18160ddd" - -# ERC-165 supportsInterface(bytes4) selector -SEL_SUPPORTS_INTERFACE = "01ffc9a7" - -# Interface IDs for ERC-165 detection -IFACE_ERC721 = "80ac58cd" -IFACE_ERC1155 = "d9b67a26" - -# Transfer(address,address,uint256) event topic -TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" - -# Well-known Base tokens — maps lowercase address -> (symbol, name, decimals). -KNOWN_TOKENS: Dict[str, Tuple[str, str, int]] = { - "0x4200000000000000000000000000000000000006": ("WETH", "Wrapped Ether", 18), - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": ("USDC", "USD Coin", 6), - "0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22": ("cbETH", "Coinbase Wrapped Staked ETH", 18), - "0x940181a94a35a4569e4529a3cdfb74e38fd98631": ("AERO", "Aerodrome Finance", 18), - "0x4ed4e862860bed51a9570b96d89af5e1b0efefed": ("DEGEN", "Degen", 18), - "0xac1bd2486aaf3b5c0fc3fd868558b082a531b2b4": ("TOSHI", "Toshi", 18), - "0x532f27101965dd16442e59d40670faf5ebb142e4": ("BRETT", "Brett", 18), - "0xa88594d404727625a9437c3f886c7643872296ae": ("WELL", "Moonwell", 18), - "0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452": ("wstETH", "Wrapped Lido Staked ETH", 18), - "0xb6fe221fe9eef5aba221c348ba20a1bf5e73624c": ("rETH", "Rocket Pool ETH", 18), - "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf": ("cbBTC", "Coinbase Wrapped BTC", 8), -} - -# Reverse lookup: symbol -> contract address (for the `price` command). -_SYMBOL_TO_ADDRESS = {v[0].upper(): k for k, v in KNOWN_TOKENS.items()} -_SYMBOL_TO_ADDRESS["ETH"] = "ETH" - - -# --------------------------------------------------------------------------- -# HTTP / RPC helpers -# --------------------------------------------------------------------------- - -def _http_get_json(url: str, timeout: int = 10, retries: int = 2) -> Any: - """GET JSON from a URL with retry on 429 rate-limit. Returns parsed JSON or None.""" - for attempt in range(retries + 1): - req = urllib.request.Request( - url, headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"}, - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.load(resp) - except urllib.error.HTTPError as exc: - if exc.code == 429 and attempt < retries: - time.sleep(2.0 * (attempt + 1)) - continue - return None - except Exception: - return None - return None - - -def _rpc_call(method: str, params: list = None, retries: int = 2) -> Any: - """Send a JSON-RPC request with retry on 429 rate-limit.""" - payload = json.dumps({ - "jsonrpc": "2.0", "id": 1, - "method": method, "params": params or [], - }).encode() - - _headers = {"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"} - - for attempt in range(retries + 1): - req = urllib.request.Request( - RPC_URL, data=payload, headers=_headers, method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=20) as resp: - body = json.load(resp) - if "error" in body: - err = body["error"] - if isinstance(err, dict) and err.get("code") == 429: - if attempt < retries: - time.sleep(1.5 * (attempt + 1)) - continue - sys.exit(f"RPC error: {err}") - return body.get("result") - except urllib.error.HTTPError as exc: - if exc.code == 429 and attempt < retries: - time.sleep(1.5 * (attempt + 1)) - continue - sys.exit(f"RPC HTTP error: {exc}") - except urllib.error.URLError as exc: - sys.exit(f"RPC connection error: {exc}") - return None - - -# Keep backward compat alias. -rpc = _rpc_call - - -_BATCH_LIMIT = 10 # Base public RPC limits to 10 calls per batch - - -def _rpc_batch_chunk(items: list) -> list: - """Send a single batch of JSON-RPC requests (max _BATCH_LIMIT).""" - payload = json.dumps(items).encode() - _headers = {"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"} - - for attempt in range(3): - req = urllib.request.Request( - RPC_URL, data=payload, headers=_headers, method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - data = json.load(resp) - # If the RPC returns an error dict instead of a list, treat as failure - if isinstance(data, dict) and "error" in data: - sys.exit(f"RPC batch error: {data['error']}") - return data if isinstance(data, list) else [] - except urllib.error.HTTPError as exc: - if exc.code == 429 and attempt < 2: - time.sleep(1.5 * (attempt + 1)) - continue - sys.exit(f"RPC batch HTTP error: {exc}") - except urllib.error.URLError as exc: - sys.exit(f"RPC batch error: {exc}") - return [] - - -def rpc_batch(calls: list) -> list: - """Send a batch of JSON-RPC requests, auto-chunking to respect limits.""" - items = [ - {"jsonrpc": "2.0", "id": i, "method": c["method"], "params": c.get("params", [])} - for i, c in enumerate(calls) - ] - - if len(items) <= _BATCH_LIMIT: - return _rpc_batch_chunk(items) - - # Split into chunks of _BATCH_LIMIT - all_results = [] - for start in range(0, len(items), _BATCH_LIMIT): - chunk = items[start:start + _BATCH_LIMIT] - all_results.extend(_rpc_batch_chunk(chunk)) - return all_results - - -def wei_to_eth(wei: int) -> float: - return wei / WEI_PER_ETH - - -def wei_to_gwei(wei: int) -> float: - return wei / GWEI - - -def hex_to_int(hex_str: Optional[str]) -> int: - """Convert hex string (0x...) to int. Returns 0 for None/empty.""" - if not hex_str or hex_str == "0x": - return 0 - return int(hex_str, 16) - - -def print_json(obj: Any) -> None: - print(json.dumps(obj, indent=2)) - - -def _short_addr(addr: str) -> str: - """Abbreviate an address for display: first 6 + last 4.""" - if len(addr) <= 14: - return addr - return f"{addr[:6]}...{addr[-4:]}" - - -# --------------------------------------------------------------------------- -# ABI encoding / decoding helpers -# --------------------------------------------------------------------------- - -def _encode_address(addr: str) -> str: - """ABI-encode an address as a 32-byte hex string (no 0x prefix).""" - clean = addr.lower().replace("0x", "") - return clean.zfill(64) - - -def _decode_uint(hex_data: Optional[str]) -> int: - """Decode a hex-encoded uint256 return value.""" - if not hex_data or hex_data == "0x": - return 0 - return int(hex_data.replace("0x", ""), 16) - - -def _decode_string(hex_data: Optional[str]) -> str: - """Decode an ABI-encoded string return value.""" - if not hex_data or hex_data == "0x" or len(hex_data) < 130: - return "" - data = hex_data[2:] if hex_data.startswith("0x") else hex_data - try: - length = int(data[64:128], 16) - if length == 0 or length > 256: - return "" - str_hex = data[128:128 + length * 2] - return bytes.fromhex(str_hex).decode("utf-8").strip("\x00") - except (ValueError, UnicodeDecodeError): - return "" - - -def _eth_call(to: str, selector: str, args: str = "", block: str = "latest") -> Optional[str]: - """Execute eth_call with a function selector. Returns None on revert/error.""" - data = "0x" + selector + args - try: - payload = json.dumps({ - "jsonrpc": "2.0", "id": 1, - "method": "eth_call", "params": [{"to": to, "data": data}, block], - }).encode() - req = urllib.request.Request( - RPC_URL, data=payload, - headers={"Content-Type": "application/json", "User-Agent": "HermesAgent/1.0"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=20) as resp: - body = json.load(resp) - if "error" in body: - return None - return body.get("result") - except Exception: - return None - - -# --------------------------------------------------------------------------- -# Price & token name helpers (CoinGecko — free, no API key) -# --------------------------------------------------------------------------- - -def fetch_prices(addresses: List[str], max_lookups: int = 20) -> Dict[str, float]: - """Fetch USD prices for Base token addresses via CoinGecko (one per request). - - CoinGecko free tier doesn't support batch Base token lookups, - so we do individual calls — capped at *max_lookups* to stay within - rate limits. Returns {lowercase_address: usd_price}. - """ - prices: Dict[str, float] = {} - for i, addr in enumerate(addresses[:max_lookups]): - url = ( - f"https://api.coingecko.com/api/v3/simple/token_price/base" - f"?contract_addresses={addr}&vs_currencies=usd" - ) - data = _http_get_json(url, timeout=10) - if data and isinstance(data, dict): - for key, info in data.items(): - if isinstance(info, dict) and "usd" in info: - prices[addr.lower()] = info["usd"] - break - # Pause between calls to respect CoinGecko free-tier rate-limits - if i < len(addresses[:max_lookups]) - 1: - time.sleep(1.0) - return prices - - -def fetch_eth_price() -> Optional[float]: - """Fetch current ETH price in USD via CoinGecko.""" - data = _http_get_json( - "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" - ) - if data and "ethereum" in data: - return data["ethereum"].get("usd") - return None - - -def resolve_token_name(addr: str) -> Optional[Dict[str, str]]: - """Look up token name and symbol. Checks known tokens first, then on-chain. - - Returns {"name": ..., "symbol": ...} or None. - """ - addr_lower = addr.lower() - if addr_lower in KNOWN_TOKENS: - sym, name, _ = KNOWN_TOKENS[addr_lower] - return {"symbol": sym, "name": name} - # Try reading name() and symbol() from the contract - name_hex = _eth_call(addr, SEL_NAME) - symbol_hex = _eth_call(addr, SEL_SYMBOL) - name = _decode_string(name_hex) if name_hex else "" - symbol = _decode_string(symbol_hex) if symbol_hex else "" - if symbol: - return {"symbol": symbol.upper(), "name": name} - return None - - -def _token_label(addr: str) -> str: - """Return a human-readable label: symbol if known, else abbreviated address.""" - addr_lower = addr.lower() - if addr_lower in KNOWN_TOKENS: - return KNOWN_TOKENS[addr_lower][0] - return _short_addr(addr) - - -# --------------------------------------------------------------------------- -# 1. Network Stats -# --------------------------------------------------------------------------- - -def cmd_stats(_args): - """Base network health: block, gas, chain ID, ETH price.""" - results = rpc_batch([ - {"method": "eth_blockNumber"}, - {"method": "eth_gasPrice"}, - {"method": "eth_chainId"}, - {"method": "eth_getBlockByNumber", "params": ["latest", False]}, - ]) - - by_id = {r["id"]: r.get("result") for r in results} - - block_num = hex_to_int(by_id.get(0)) - gas_price = hex_to_int(by_id.get(1)) - chain_id = hex_to_int(by_id.get(2)) - block = by_id.get(3) or {} - - base_fee = hex_to_int(block.get("baseFeePerGas")) if block.get("baseFeePerGas") else None - timestamp = hex_to_int(block.get("timestamp")) if block.get("timestamp") else None - gas_used = hex_to_int(block.get("gasUsed")) if block.get("gasUsed") else None - gas_limit = hex_to_int(block.get("gasLimit")) if block.get("gasLimit") else None - tx_count = len(block.get("transactions", [])) - - eth_price = fetch_eth_price() - - out = { - "chain": "Base" if chain_id == 8453 else f"Chain {chain_id}", - "chain_id": chain_id, - "latest_block": block_num, - "gas_price_gwei": round(wei_to_gwei(gas_price), 4), - } - if base_fee is not None: - out["base_fee_gwei"] = round(wei_to_gwei(base_fee), 4) - if timestamp: - out["block_timestamp"] = timestamp - if gas_used is not None and gas_limit: - out["block_gas_used"] = gas_used - out["block_gas_limit"] = gas_limit - out["block_utilization_pct"] = round(gas_used / gas_limit * 100, 2) - out["block_tx_count"] = tx_count - if eth_price is not None: - out["eth_price_usd"] = eth_price - print_json(out) - - -# --------------------------------------------------------------------------- -# 2. Wallet Info (ETH + ERC-20 balances with prices) -# --------------------------------------------------------------------------- - -def cmd_wallet(args): - """ETH balance + ERC-20 token holdings with USD values.""" - address = args.address.lower() - show_all = getattr(args, "all", False) - limit = getattr(args, "limit", 20) or 20 - skip_prices = getattr(args, "no_prices", False) - - # Batch: ETH balance + balanceOf for all known tokens - calls = [{"method": "eth_getBalance", "params": [address, "latest"]}] - token_addrs = list(KNOWN_TOKENS.keys()) - for token_addr in token_addrs: - calls.append({ - "method": "eth_call", - "params": [ - {"to": token_addr, "data": "0x" + SEL_BALANCE_OF + _encode_address(address)}, - "latest", - ], - }) - - results = rpc_batch(calls) - by_id = {r["id"]: r.get("result") for r in results} - - eth_balance = wei_to_eth(hex_to_int(by_id.get(0))) - - # Parse token balances - tokens = [] - for i, token_addr in enumerate(token_addrs): - raw = hex_to_int(by_id.get(i + 1)) - if raw == 0: - continue - sym, name, decimals = KNOWN_TOKENS[token_addr] - amount = raw / (10 ** decimals) - tokens.append({ - "address": token_addr, - "symbol": sym, - "name": name, - "amount": amount, - "decimals": decimals, - }) - - # Fetch prices - eth_price = None - prices: Dict[str, float] = {} - if not skip_prices: - eth_price = fetch_eth_price() - if tokens: - mints_to_price = [t["address"] for t in tokens] - prices = fetch_prices(mints_to_price, max_lookups=20) - - # Enrich with USD values, filter dust, sort - enriched = [] - dust_count = 0 - dust_value = 0.0 - for t in tokens: - usd_price = prices.get(t["address"]) - usd_value = round(usd_price * t["amount"], 2) if usd_price else None - - if not show_all and usd_value is not None and usd_value < 0.01: - dust_count += 1 - dust_value += usd_value - continue - - entry = {"token": t["symbol"], "address": t["address"], "amount": t["amount"]} - if usd_price is not None: - entry["price_usd"] = usd_price - entry["value_usd"] = usd_value - enriched.append(entry) - - # Sort: tokens with known USD value first (highest->lowest), then unknowns - enriched.sort( - key=lambda x: (x.get("value_usd") is not None, x.get("value_usd") or 0), - reverse=True, - ) - - # Apply limit unless --all - total_tokens = len(enriched) - if not show_all and len(enriched) > limit: - enriched = enriched[:limit] - hidden_tokens = total_tokens - len(enriched) - - # Compute portfolio total - total_usd = sum(t.get("value_usd", 0) for t in enriched) - eth_value_usd = round(eth_price * eth_balance, 2) if eth_price else None - if eth_value_usd: - total_usd += eth_value_usd - total_usd += dust_value - - output = { - "address": args.address, - "eth_balance": round(eth_balance, 18), - } - if eth_price: - output["eth_price_usd"] = eth_price - output["eth_value_usd"] = eth_value_usd - output["tokens_shown"] = len(enriched) - if hidden_tokens > 0: - output["tokens_hidden"] = hidden_tokens - output["erc20_tokens"] = enriched - if dust_count > 0: - output["dust_filtered"] = {"count": dust_count, "total_value_usd": round(dust_value, 4)} - if total_usd > 0: - output["portfolio_total_usd"] = round(total_usd, 2) - if hidden_tokens > 0 and not show_all: - output["warning"] = ( - "portfolio_total_usd may be partial because hidden tokens are not " - "included when --limit is applied." - ) - output["note"] = f"Checked {len(KNOWN_TOKENS)} known Base tokens. Unknown ERC-20s not shown." - - print_json(output) - - -# --------------------------------------------------------------------------- -# 3. Transaction Details -# --------------------------------------------------------------------------- - -def cmd_tx(args): - """Full transaction details by hash.""" - tx_hash = args.hash - - results = rpc_batch([ - {"method": "eth_getTransactionByHash", "params": [tx_hash]}, - {"method": "eth_getTransactionReceipt", "params": [tx_hash]}, - ]) - - by_id = {r["id"]: r.get("result") for r in results} - tx = by_id.get(0) - receipt = by_id.get(1) - - if tx is None: - sys.exit("Transaction not found.") - - value_wei = hex_to_int(tx.get("value")) - tx_gas_price = hex_to_int(tx.get("gasPrice")) - gas_used = hex_to_int(receipt.get("gasUsed")) if receipt else None - effective_gas_price = ( - hex_to_int(receipt.get("effectiveGasPrice")) if receipt and receipt.get("effectiveGasPrice") - else tx_gas_price - ) - l2_fee_wei = effective_gas_price * gas_used if gas_used is not None else None - l1_fee_wei = hex_to_int(receipt.get("l1Fee")) if receipt and receipt.get("l1Fee") else 0 - fee_wei = (l2_fee_wei + l1_fee_wei) if l2_fee_wei is not None else None - - eth_price = fetch_eth_price() - - out = { - "hash": tx_hash, - "block": hex_to_int(tx.get("blockNumber")), - "from": tx.get("from"), - "to": tx.get("to"), - "value_ETH": round(wei_to_eth(value_wei), 18) if value_wei else 0, - "gas_price_gwei": round(wei_to_gwei(effective_gas_price), 4), - } - if gas_used is not None: - out["gas_used"] = gas_used - if l2_fee_wei is not None: - out["l2_fee_ETH"] = round(wei_to_eth(l2_fee_wei), 12) - if l1_fee_wei: - out["l1_fee_ETH"] = round(wei_to_eth(l1_fee_wei), 12) - if fee_wei is not None: - out["fee_ETH"] = round(wei_to_eth(fee_wei), 12) - if receipt: - out["status"] = "success" if receipt.get("status") == "0x1" else "failed" - out["contract_created"] = receipt.get("contractAddress") - out["log_count"] = len(receipt.get("logs", [])) - - # Decode ERC-20 transfers from logs - transfers = [] - if receipt: - for log in receipt.get("logs", []): - topics = log.get("topics", []) - if len(topics) >= 3 and topics[0] == TRANSFER_TOPIC: - from_addr = "0x" + topics[1][-40:] - to_addr = "0x" + topics[2][-40:] - token_contract = log.get("address", "") - label = _token_label(token_contract) - - entry = { - "token": label, - "contract": token_contract, - "from": from_addr, - "to": to_addr, - } - # ERC-20: 3 topics, amount in data - if len(topics) == 3: - amount_hex = log.get("data", "0x") - if amount_hex and amount_hex != "0x": - raw_amount = hex_to_int(amount_hex) - addr_lower = token_contract.lower() - if addr_lower in KNOWN_TOKENS: - decimals = KNOWN_TOKENS[addr_lower][2] - entry["amount"] = raw_amount / (10 ** decimals) - else: - entry["raw_amount"] = raw_amount - # ERC-721: 4 topics, tokenId in topics[3] - elif len(topics) == 4: - entry["token_id"] = hex_to_int(topics[3]) - entry["type"] = "ERC-721" - - transfers.append(entry) - - if transfers: - out["token_transfers"] = transfers - - if eth_price is not None: - if value_wei: - out["value_USD"] = round(wei_to_eth(value_wei) * eth_price, 2) - if l2_fee_wei is not None: - out["l2_fee_USD"] = round(wei_to_eth(l2_fee_wei) * eth_price, 4) - if l1_fee_wei: - out["l1_fee_USD"] = round(wei_to_eth(l1_fee_wei) * eth_price, 4) - if fee_wei is not None: - out["fee_USD"] = round(wei_to_eth(fee_wei) * eth_price, 4) - - print_json(out) - - -# --------------------------------------------------------------------------- -# 4. Token Info -# --------------------------------------------------------------------------- - -def cmd_token(args): - """ERC-20 token metadata, supply, price, market cap.""" - addr = args.address.lower() - - # Batch: name, symbol, decimals, totalSupply, code check - calls = [ - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_NAME}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_SYMBOL}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_DECIMALS}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_TOTAL_SUPPLY}, "latest"]}, - {"method": "eth_getCode", "params": [addr, "latest"]}, - ] - results = rpc_batch(calls) - by_id = {r["id"]: r.get("result") for r in results} - - code = by_id.get(4) - if not code or code == "0x": - sys.exit("Address is not a contract.") - - name = _decode_string(by_id.get(0)) - symbol = _decode_string(by_id.get(1)) - decimals_raw = by_id.get(2) - decimals = _decode_uint(decimals_raw) - total_supply_raw = _decode_uint(by_id.get(3)) - - # Fall back to known tokens if on-chain read failed - if not symbol and addr in KNOWN_TOKENS: - symbol = KNOWN_TOKENS[addr][0] - name = KNOWN_TOKENS[addr][1] - decimals = KNOWN_TOKENS[addr][2] - - is_known_token = addr in KNOWN_TOKENS - is_erc20 = bool((symbol or is_known_token) and decimals_raw and decimals_raw != "0x") - if not is_erc20: - sys.exit("Contract does not appear to be an ERC-20 token.") - - total_supply = total_supply_raw / (10 ** decimals) if decimals else total_supply_raw - - # Fetch price - price_data = fetch_prices([addr]) - - out = {"address": args.address} - if name: - out["name"] = name - if symbol: - out["symbol"] = symbol - out["decimals"] = decimals - out["total_supply"] = round(total_supply, min(decimals, 6)) - out["code_size_bytes"] = (len(code) - 2) // 2 - if addr in price_data: - out["price_usd"] = price_data[addr] - out["market_cap_usd"] = round(price_data[addr] * total_supply, 0) - - print_json(out) - - -# --------------------------------------------------------------------------- -# 5. Gas Analysis (Base-specific: L2 execution + L1 data costs) -# --------------------------------------------------------------------------- - -def cmd_gas(_args): - """Detailed gas analysis with L1 data fee context and cost estimates.""" - latest_hex = _rpc_call("eth_blockNumber") - latest = hex_to_int(latest_hex) - - # Get last 10 blocks for trend analysis + current gas price - block_calls = [] - for i in range(10): - block_calls.append({ - "method": "eth_getBlockByNumber", - "params": [hex(latest - i), False], - }) - block_calls.append({"method": "eth_gasPrice"}) - - results = rpc_batch(block_calls) - by_id = {r["id"]: r.get("result") for r in results} - - current_gas_price = hex_to_int(by_id.get(10)) - - base_fees = [] - gas_utilizations = [] - tx_counts = [] - latest_block_info = None - - for i in range(10): - b = by_id.get(i) - if not b: - continue - bf = hex_to_int(b.get("baseFeePerGas", "0x0")) - gu = hex_to_int(b.get("gasUsed", "0x0")) - gl = hex_to_int(b.get("gasLimit", "0x0")) - txc = len(b.get("transactions", [])) - base_fees.append(bf) - if gl > 0: - gas_utilizations.append(gu / gl * 100) - tx_counts.append(txc) - - if i == 0: - latest_block_info = { - "block": hex_to_int(b.get("number")), - "base_fee_gwei": round(wei_to_gwei(bf), 6), - "gas_used": gu, - "gas_limit": gl, - "utilization_pct": round(gu / gl * 100, 2) if gl > 0 else 0, - "tx_count": txc, - } - - avg_base_fee = sum(base_fees) / len(base_fees) if base_fees else 0 - avg_utilization = sum(gas_utilizations) / len(gas_utilizations) if gas_utilizations else 0 - avg_tx_count = sum(tx_counts) / len(tx_counts) if tx_counts else 0 - - # Estimate costs for common operations - eth_price = fetch_eth_price() - - simple_transfer_gas = 21_000 - erc20_transfer_gas = 65_000 - swap_gas = 200_000 - - def _estimate_cost(gas: int) -> Dict[str, Any]: - cost_wei = gas * current_gas_price - cost_eth = wei_to_eth(cost_wei) - entry: Dict[str, Any] = {"gas_units": gas, "cost_ETH": round(cost_eth, 10)} - if eth_price: - entry["cost_USD"] = round(cost_eth * eth_price, 6) - return entry - - out: Dict[str, Any] = { - "current_gas_price_gwei": round(wei_to_gwei(current_gas_price), 6), - "latest_block": latest_block_info, - "trend_10_blocks": { - "avg_base_fee_gwei": round(wei_to_gwei(avg_base_fee), 6), - "avg_utilization_pct": round(avg_utilization, 2), - "avg_tx_count": round(avg_tx_count, 1), - "min_base_fee_gwei": round(wei_to_gwei(min(base_fees)), 6) if base_fees else None, - "max_base_fee_gwei": round(wei_to_gwei(max(base_fees)), 6) if base_fees else None, - }, - "cost_estimates": { - "eth_transfer": _estimate_cost(simple_transfer_gas), - "erc20_transfer": _estimate_cost(erc20_transfer_gas), - "swap": _estimate_cost(swap_gas), - }, - "note": "Base is an L2. Total tx cost = L2 execution fee + L1 data posting fee. " - "L1 data fee depends on calldata size and L1 gas prices (not shown here). " - "Actual costs may be slightly higher than estimates.", - } - if eth_price: - out["eth_price_usd"] = eth_price - print_json(out) - - -# --------------------------------------------------------------------------- -# 6. Contract Inspection -# --------------------------------------------------------------------------- - -def cmd_contract(args): - """Inspect an address: EOA vs contract, ERC type detection, proxy resolution.""" - addr = args.address.lower() - - # Batch: getCode, getBalance, name, symbol, decimals, totalSupply, ERC-721, ERC-1155 - calls = [ - {"method": "eth_getCode", "params": [addr, "latest"]}, - {"method": "eth_getBalance", "params": [addr, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_NAME}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_SYMBOL}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_DECIMALS}, "latest"]}, - {"method": "eth_call", "params": [{"to": addr, "data": "0x" + SEL_TOTAL_SUPPLY}, "latest"]}, - {"method": "eth_call", "params": [ - {"to": addr, "data": "0x" + SEL_SUPPORTS_INTERFACE + IFACE_ERC721.zfill(64)}, - "latest", - ]}, - {"method": "eth_call", "params": [ - {"to": addr, "data": "0x" + SEL_SUPPORTS_INTERFACE + IFACE_ERC1155.zfill(64)}, - "latest", - ]}, - ] - results = rpc_batch(calls) - - # Handle per-item errors gracefully - by_id: Dict[int, Any] = {} - for r in results: - if "error" not in r: - by_id[r["id"]] = r.get("result") - else: - by_id[r["id"]] = None - - code = by_id.get(0, "0x") - eth_balance = hex_to_int(by_id.get(1)) - - if not code or code == "0x": - out = { - "address": args.address, - "is_contract": False, - "eth_balance": round(wei_to_eth(eth_balance), 18), - "note": "This is an externally owned account (EOA), not a contract.", - } - print_json(out) - return - - code_size = (len(code) - 2) // 2 - - # Check ERC-20 - name = _decode_string(by_id.get(2)) - symbol = _decode_string(by_id.get(3)) - decimals_raw = by_id.get(4) - supply_raw = by_id.get(5) - is_erc20 = bool(symbol and decimals_raw and decimals_raw != "0x") - - # Check ERC-721 / ERC-1155 via ERC-165 - erc721_result = by_id.get(6) - erc1155_result = by_id.get(7) - is_erc721 = erc721_result is not None and _decode_uint(erc721_result) == 1 - is_erc1155 = erc1155_result is not None and _decode_uint(erc1155_result) == 1 - - # Detect proxy pattern (EIP-1967 implementation slot) - impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" - impl_result = _rpc_call("eth_getStorageAt", [addr, impl_slot, "latest"]) - is_proxy = False - impl_address = None - if impl_result and impl_result != "0x" + "0" * 64: - impl_address = "0x" + impl_result[-40:] - if impl_address != "0x" + "0" * 40: - is_proxy = True - - out: Dict[str, Any] = { - "address": args.address, - "is_contract": True, - "code_size_bytes": code_size, - "eth_balance": round(wei_to_eth(eth_balance), 18), - } - - interfaces = [] - if is_erc20: - interfaces.append("ERC-20") - if is_erc721: - interfaces.append("ERC-721") - if is_erc1155: - interfaces.append("ERC-1155") - if interfaces: - out["detected_interfaces"] = interfaces - - if is_erc20: - decimals = _decode_uint(decimals_raw) - supply = _decode_uint(supply_raw) - out["erc20"] = { - "name": name, - "symbol": symbol, - "decimals": decimals, - "total_supply": supply / (10 ** decimals) if decimals else supply, - } - - if is_proxy: - out["proxy"] = { - "is_proxy": True, - "implementation": impl_address, - "standard": "EIP-1967", - } - - # Check known tokens - if addr in KNOWN_TOKENS: - sym, tname, _ = KNOWN_TOKENS[addr] - out["known_token"] = {"symbol": sym, "name": tname} - - print_json(out) - - -# --------------------------------------------------------------------------- -# 7. Whale Detector -# --------------------------------------------------------------------------- - -def cmd_whales(args): - """Scan the latest block for large ETH transfers with USD values.""" - min_wei = int(args.min_eth * WEI_PER_ETH) - - block = rpc("eth_getBlockByNumber", ["latest", True]) - if block is None: - sys.exit("Could not retrieve latest block.") - - eth_price = fetch_eth_price() - - whales = [] - for tx in (block.get("transactions") or []): - value = hex_to_int(tx.get("value")) - if value >= min_wei: - entry: Dict[str, Any] = { - "hash": tx.get("hash"), - "from": tx.get("from"), - "to": tx.get("to"), - "value_ETH": round(wei_to_eth(value), 6), - } - if eth_price: - entry["value_USD"] = round(wei_to_eth(value) * eth_price, 2) - whales.append(entry) - - # Sort by value descending - whales.sort(key=lambda x: x["value_ETH"], reverse=True) - - out: Dict[str, Any] = { - "block": hex_to_int(block.get("number")), - "block_time": hex_to_int(block.get("timestamp")), - "min_threshold_ETH": args.min_eth, - "large_transfers": whales, - "note": "Scans latest block only — point-in-time snapshot.", - } - if eth_price: - out["eth_price_usd"] = eth_price - print_json(out) - - -# --------------------------------------------------------------------------- -# 8. Price Lookup -# --------------------------------------------------------------------------- - -def cmd_price(args): - """Quick price lookup for a token by contract address or known symbol.""" - query = args.token - - # Check if it's a known symbol - addr = _SYMBOL_TO_ADDRESS.get(query.upper(), query).lower() - - # Special case: ETH itself - if addr == "eth": - eth_price = fetch_eth_price() - out: Dict[str, Any] = {"query": query, "token": "ETH", "name": "Ethereum"} - if eth_price: - out["price_usd"] = eth_price - else: - out["price_usd"] = None - out["note"] = "Price not available." - print_json(out) - return - - # Resolve name - token_meta = resolve_token_name(addr) - - # Fetch price - prices = fetch_prices([addr]) - - out = {"query": query, "address": addr} - if token_meta: - out["name"] = token_meta["name"] - out["symbol"] = token_meta["symbol"] - if addr in prices: - out["price_usd"] = prices[addr] - else: - out["price_usd"] = None - out["note"] = "Price not available — token may not be listed on CoinGecko." - print_json(out) - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - prog="base_client.py", - description="Base blockchain query tool for Hermes Agent", - ) - sub = parser.add_subparsers(dest="command", required=True) - - sub.add_parser("stats", help="Network stats: block, gas, chain ID, ETH price") - - p_wallet = sub.add_parser("wallet", help="ETH balance + ERC-20 tokens with USD values") - p_wallet.add_argument("address") - p_wallet.add_argument("--limit", type=int, default=20, - help="Max tokens to display (default: 20)") - p_wallet.add_argument("--all", action="store_true", - help="Show all tokens (no limit, no dust filter)") - p_wallet.add_argument("--no-prices", action="store_true", - help="Skip price lookups (faster, RPC-only)") - - p_tx = sub.add_parser("tx", help="Transaction details by hash") - p_tx.add_argument("hash") - - p_token = sub.add_parser("token", help="ERC-20 token metadata, price, and market cap") - p_token.add_argument("address") - - sub.add_parser("gas", help="Gas analysis with cost estimates and L1 data fee context") - - p_contract = sub.add_parser("contract", help="Contract inspection: type detection, proxy check") - p_contract.add_argument("address") - - p_whales = sub.add_parser("whales", help="Large ETH transfers in the latest block") - p_whales.add_argument("--min-eth", type=float, default=1.0, - help="Minimum ETH transfer size (default: 1.0)") - - p_price = sub.add_parser("price", help="Quick price lookup by address or symbol") - p_price.add_argument("token", help="Contract address or known symbol (ETH, USDC, AERO, ...)") - - args = parser.parse_args() - - dispatch = { - "stats": cmd_stats, - "wallet": cmd_wallet, - "tx": cmd_tx, - "token": cmd_token, - "gas": cmd_gas, - "contract": cmd_contract, - "whales": cmd_whales, - "price": cmd_price, - } - dispatch[args.command](args) - - -if __name__ == "__main__": - main() diff --git a/optional-skills/blockchain/evm/SKILL.md b/optional-skills/blockchain/evm/SKILL.md new file mode 100644 index 000000000000..989d59509f33 --- /dev/null +++ b/optional-skills/blockchain/evm/SKILL.md @@ -0,0 +1,211 @@ +--- +name: evm +description: "Read-only EVM client: wallets, tokens, gas across 8 chains." +version: 1.0.0 +author: Mibayy (@Mibayy), youssefea (@youssefea), ethernet8023 (@ethernet8023), Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [EVM, Ethereum, BNB, BSC, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync, Blockchain, Crypto, Web3, DeFi, NFT, ENS, Whale, Security] + category: blockchain + related_skills: [solana] + requires_toolsets: [terminal] +--- + +# EVM Blockchain Skill + +Query EVM-compatible blockchain data across 8 chains with USD pricing. +14 commands: wallet portfolio, token info, transactions, activity, gas tracker, +network stats, price lookup, multi-chain scan, whale detection, ENS resolution, +allowance checker, contract inspector, and transaction decoder. + +Supports 8 chains: Ethereum, BNB Chain (BSC), Base, Arbitrum One, Polygon, +Optimism, Avalanche (C-Chain), zkSync Era. + +No API key needed. Zero external dependencies — Python standard library only +(urllib, json, argparse, threading). + +> **Supersedes the standalone `base` skill.** Base-specific tokens (AERO, DEGEN, +> TOSHI, BRETT, WELL, cbETH, cbBTC, wstETH, rETH) and all Base RPC functionality +> previously living under `optional-skills/blockchain/base/` have been folded +> into this skill. Pass `--chain base` to any command for Base coverage. + +--- + +## When to Use +- User asks for a wallet balance or portfolio on any EVM chain +- User wants to check the same wallet across ALL chains at once +- User wants to inspect a transaction by hash (or decode what it did) +- User wants ERC-20 token metadata, price, supply, or market cap +- User wants recent transaction history for an address +- User wants current gas prices or to compare fees across chains +- User wants to find large whale transfers in recent blocks +- User asks to resolve an ENS name (vitalik.eth) or reverse-lookup an address +- User wants to check if a contract has dangerous token approvals +- User wants to inspect a smart contract (proxy? ERC-20? ERC-721? bytecode size?) +- User wants to compare gas costs across chains before a transaction + +--- + +## Prerequisites +Python 3.8+ standard library only. No pip installs required. +Pricing: CoinGecko free API (rate-limited, ~10-30 req/min). +ENS: ensideas.com public API. +Tx decoding: 4byte.directory public API. + +Override RPC endpoint: `export EVM_RPC_URL=https://your-rpc.com` + +Helper script path: `~/.hermes/skills/blockchain/evm/scripts/evm_client.py` + +--- + +## Quick Reference + +``` +SCRIPT=~/.hermes/skills/blockchain/evm/scripts/evm_client.py + +# Network & prices +python3 $SCRIPT stats # Ethereum stats +python3 $SCRIPT stats --chain arbitrum # Arbitrum stats +python3 $SCRIPT compare # Gas + prices ALL 8 chains + +# Wallet +python3 $SCRIPT wallet 0xd8dA...96045 # Portfolio (ETH + ERC-20) +python3 $SCRIPT wallet 0xd8dA...96045 --chain bsc +python3 $SCRIPT multichain 0xd8dA...96045 # Same wallet on ALL chains + +# Tokens & prices +python3 $SCRIPT price ETH +python3 $SCRIPT price 0xdAC1...1ec7 # By contract address +python3 $SCRIPT token 0xdAC1...1ec7 # ERC-20 metadata + market cap + +# Transactions +python3 $SCRIPT tx 0x5c50...f060 # Transaction details +python3 $SCRIPT decode 0x5c50...f060 # Decode input data (4byte.directory) +python3 $SCRIPT activity 0xd8dA...96045 # Recent transactions + +# Gas +python3 $SCRIPT gas # Gas prices + cost estimates +python3 $SCRIPT gas --chain optimism + +# Security +python3 $SCRIPT allowance 0xd8dA...96045 # Dangerous ERC-20 approvals +python3 $SCRIPT contract 0xdAC1...1ec7 # Contract inspection (proxy? standards?) + +# ENS +python3 $SCRIPT ens vitalik.eth # Name -> address + profile +python3 $SCRIPT ens 0xd8dA...96045 # Address -> ENS name + +# Whale detection +python3 $SCRIPT whale # Large transfers (last 20 blocks, >$10k) +python3 $SCRIPT whale --blocks 50 --min-usd 100000 --chain arbitrum +``` + +--- + +## Procedure + +### 0. Setup Check +```bash +python3 --version # 3.8+ required +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats +``` + +### 1. Wallet Portfolio +Native balance + known ERC-20 tokens, sorted by USD value. +```bash +python3 $SCRIPT wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +python3 $SCRIPT wallet 0xd8dA... --chain bsc --no-prices # faster +``` + +### 2. Multi-Chain Scan +Scans all 8 chains simultaneously for the same address using threads. +```bash +python3 $SCRIPT multichain 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` +Output: per-chain native balance + token holdings + grand total USD. + +### 3. Compare (Gas + Prices) +All 8 chains queried in parallel. Shows cheapest/most expensive chain. +```bash +python3 $SCRIPT compare +``` + +### 4. Transaction Details & Decode +```bash +python3 $SCRIPT tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 +python3 $SCRIPT decode 0x5c504ed... # Shows human-readable function signature +``` +Decode uses 4byte.directory to translate 0xa9059cbb -> transfer(address,uint256). + +### 5. ENS Resolution +```bash +python3 $SCRIPT ens vitalik.eth # -> 0xd8dA... + avatar + social links +python3 $SCRIPT ens 0xd8dA...96045 # -> vitalik.eth +``` + +### 6. Allowance Checker (Security) +Checks ERC-20 approvals granted to known DEX/bridge contracts. +```bash +python3 $SCRIPT allowance 0xYourWallet +``` +Flags UNLIMITED approvals as HIGH risk. + +### 7. Contract Inspector +```bash +python3 $SCRIPT contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # USDC (proxy) +python3 $SCRIPT contract 0xdAC17F958D2ee523a2206206994597C13D831ec7 # USDT (ERC-20) +``` +Detects: proxy (EIP-1967/EIP-1167), ERC-20, ERC-721, ERC-165. Shows bytecode size and implementation address for proxies. + +### 8. Whale Detection +```bash +python3 $SCRIPT whale # ETH, last 20 blocks, >$10k +python3 $SCRIPT whale --blocks 50 --min-usd 50000 --chain bsc +``` + +### 9. Gas Tracker +```bash +python3 $SCRIPT gas +python3 $SCRIPT gas --chain polygon +``` +Shows gwei price + USD cost for: transfer, ERC-20 transfer, approve, swap, NFT mint, NFT transfer. + +--- + +## Supported Chains +| Key | Name | Native | Chain ID | +|-----------|----------------|--------|----------| +| ethereum | Ethereum | ETH | 1 | +| bsc | BNB Chain | BNB | 56 | +| base | Base | ETH | 8453 | +| arbitrum | Arbitrum One | ETH | 42161 | +| polygon | Polygon | POL | 137 | +| optimism | Optimism | ETH | 10 | +| avalanche | Avalanche C | AVAX | 43114 | +| zksync | zkSync Era | ETH | 324 | + +--- + +## Pitfalls +- CoinGecko free tier: ~10-30 req/min. Use `--no-prices` for faster wallet scans. +- Public RPCs may throttle. Set EVM_RPC_URL to a private endpoint for production. +- `wallet` and `allowance` only check known token list (~30 tokens per chain). Use a block explorer for complete token discovery. +- `activity` scans recent blocks only (max 200). For full history, use Etherscan API. +- `multichain` runs 8 parallel threads — can trigger rate limits on public RPCs. +- ENS resolution depends on a single public endpoint (ensideas.com / ens.vitalik.ca) with no fallback. If that endpoint is down, `ens` will fail — re-run later or use a block explorer. +- Tx decoding depends on a single public endpoint (4byte.directory) with no fallback. Selectors not in their database show up as `unknown`. +- **L2 gas estimates are L2-execution only.** On rollups like Base, Arbitrum, Optimism, and zkSync, the actual transaction cost also includes an L1 data-posting fee that depends on calldata size and current L1 gas prices. The `gas` command does not estimate that L1 component. For Base specifically, see the network's L1 fee oracle (contract `0x420000000000000000000000000000000000000F`). +- Address / tx-hash inputs are validated for 0x-prefix + correct length + hex, but EIP-55 checksum casing is **not** enforced (RPC endpoints accept any-case hex). + +--- + +## Verification +```bash +# Should print current block, gas price, ETH price +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats + +# Should resolve vitalik.eth to 0xd8dA... +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py ens vitalik.eth +``` diff --git a/optional-skills/blockchain/evm/scripts/evm_client.py b/optional-skills/blockchain/evm/scripts/evm_client.py new file mode 100644 index 000000000000..31da48fd192f --- /dev/null +++ b/optional-skills/blockchain/evm/scripts/evm_client.py @@ -0,0 +1,1508 @@ +#!/usr/bin/env python3 +""" +evm_client.py — EVM blockchain CLI tool for the Hermes Agent project. +Zero external dependencies. Uses stdlib only: urllib, json, argparse, time, os, sys, typing. +""" + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Chain registry +# --------------------------------------------------------------------------- + +CHAINS: Dict[str, Dict[str, Any]] = { + "ethereum": { + "chain_id": 1, + "rpc": "https://ethereum-rpc.publicnode.com", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://etherscan.io", + "decimals": 18, + }, + "bsc": { + "chain_id": 56, + "rpc": "https://bsc-dataseed1.binance.org", + "native": "BNB", + "coingecko": "binancecoin", + "explorer": "https://bscscan.com", + "decimals": 18, + }, + "base": { + "chain_id": 8453, + "rpc": "https://mainnet.base.org", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://basescan.org", + "decimals": 18, + }, + "arbitrum": { + "chain_id": 42161, + "rpc": "https://arb1.arbitrum.io/rpc", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://arbiscan.io", + "decimals": 18, + }, + "polygon": { + "chain_id": 137, + "rpc": "https://polygon-rpc.com", + "native": "MATIC", + "coingecko": "matic-network", + "explorer": "https://polygonscan.com", + "decimals": 18, + }, + "optimism": { + "chain_id": 10, + "rpc": "https://mainnet.optimism.io", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://optimistic.etherscan.io", + "decimals": 18, + }, + "avalanche": { + "chain_id": 43114, + "rpc": "https://api.avax.network/ext/bc/C/rpc", + "native": "AVAX", + "coingecko": "avalanche-2", + "explorer": "https://snowtrace.io", + "decimals": 18, + }, + "zksync": { + "chain_id": 324, + "rpc": "https://mainnet.era.zksync.io", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://explorer.zksync.io", + "decimals": 18, + }, +} + +DEFAULT_CHAIN = "ethereum" + +# --------------------------------------------------------------------------- +# Known ERC-20 token registry {chain -> {symbol -> address}} +# --------------------------------------------------------------------------- + +KNOWN_TOKENS: Dict[str, Dict[str, str]] = { + "ethereum": { + "USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA", + "UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "AAVE": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "MKR": "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", + "COMP": "0xc00e94Cb662C3520282E6f5717214004A7f26888", + "SNX": "0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F", + "CRV": "0xD533a949740bb3306d119CC777fa900bA034cd52", + "LDO": "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32", + "RPL": "0xD33526068D116cE69F19A9ee46F0bd304F21A51f", + "MATIC": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", + "SHIB": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + "APE": "0x4d224452801ACEd8B2F0aebE155379bb5D594381", + "GRT": "0xc944E90C64B2c07662A292be6244BDf05Cda44a7", + "FXS": "0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0", + "FRAX": "0x853d955aCEf822Db058eb8505911ED77F175b99e", + "BAL": "0xba100000625a3754423978a60c9317c58a424e3D", + "SUSHI": "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2", + "YFI": "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e", + "1INCH": "0x111111111117dC0aa78b770fA6A738034120C302", + "ENS": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", + "IMX": "0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF", + "SAND": "0x3845badAde8e6dFF049820680d1F14bD3903a5d0", + "MANA": "0x0F5D2fB29fb7d3CFeE444a200298f468908cC942", + "AXS": "0xBB0E17EF65F82Ab018d8EDd776e8DD940327B28b", + "CHZ": "0x3506424F91fD33084466F402d5D97f05F8e3b4AF", + "PEPE": "0x6982508145454Ce325dDbE47a25d4ec3d2311933", + }, + "bsc": { + "USDT": "0x55d398326f99059fF775485246999027B3197955", + "USDC": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "BUSD": "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + "WBNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "CAKE": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", + "XVS": "0xcF6BB5389c92Bdda8a3747Ddb454cB7a64626C63", + "ALPACA":"0x8F0528cE5eF7B51152A59745bEfDD91D97091d2F", + "BAKE": "0xE02dF9e3e622DeBdD69fb838bB799E3F168902c5", + "BURGER":"0xAe9269f27437f0fcBC232d39Ec814844a51d6b8f", + "DOGE": "0xbA2aE424d960c26247Dd6c32edC70B295c744C43", + }, + "base": { + # Stables + wrapped + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + "WETH": "0x4200000000000000000000000000000000000006", + # Liquid-staked ETH variants + "cbETH": "0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cF0DEc22", + "wstETH": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", + "rETH": "0xB6fe221Fe9EeF5aBa221c348bA20A1Bf5e73624c", + "cbBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", + # Base-native DeFi + meme tokens (carried over from the standalone base/ skill) + "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", + "DEGEN": "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + "TOSHI": "0xAC1Bd2486aAf3B5C0fc3Fd868558b082a531B2B4", + "BRETT": "0x532f27101965dd16442E59d40670FaF5eBB142E4", + "WELL": "0xA88594D404727625A9437C3f886C7643872296AE", + }, + "arbitrum": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548", + }, + "optimism": { + "USDC": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "USDT": "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + "WETH": "0x4200000000000000000000000000000000000006", + "OP": "0x4200000000000000000000000000000000000042", + }, + "polygon": { + "USDC": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "USDT": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + "WMATIC":"0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "WETH": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "DAI": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", + }, + "avalanche": { + "USDC": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "USDT": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", + "WAVAX": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + }, +} + +# Gas estimates (units) for common operations +GAS_ESTIMATES = { + "transfer": 21_000, + "erc20": 65_000, + "approve": 46_000, + "swap": 180_000, + "nft_mint": 150_000, + "nft_transfer": 85_000, +} + +# CoinGecko symbol -> id map for common tokens +COINGECKO_IDS: Dict[str, str] = { + "ETH": "ethereum", + "BTC": "bitcoin", + "BNB": "binancecoin", + "MATIC": "matic-network", + "AVAX": "avalanche-2", + "USDT": "tether", + "USDC": "usd-coin", + "DAI": "dai", + "WBTC": "wrapped-bitcoin", + "WETH": "weth", + "LINK": "chainlink", + "UNI": "uniswap", + "AAVE": "aave", + "MKR": "maker", + "COMP": "compound-governance-token", + "SNX": "havven", + "CRV": "curve-dao-token", + "LDO": "lido-dao", + "RPL": "rocket-pool", + "SHIB": "shiba-inu", + "APE": "apecoin", + "GRT": "the-graph", + "BAL": "balancer", + "SUSHI": "sushi", + "YFI": "yearn-finance", + "1INCH": "1inch", + "ENS": "ethereum-name-service", + "IMX": "immutable-x", + "SAND": "the-sandbox", + "MANA": "decentraland", + "AXS": "axie-infinity", + "ARB": "arbitrum", + "OP": "optimism", + "CAKE": "pancakeswap-token", + "PEPE": "pepe", + "CHZ": "chiliz", +} + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + +def hex_to_int(h: str) -> int: + if not h or h == "0x": + return 0 + return int(h, 16) + + +# --------------------------------------------------------------------------- +# Input validation +# --------------------------------------------------------------------------- + +def is_valid_address(s: str) -> bool: + """Return True if `s` looks like a 20-byte hex Ethereum address. + + Does NOT validate EIP-55 checksum — RPC endpoints accept any-case hex. + Just guards against typos / wrong-length input before we burn an RPC call. + """ + if not isinstance(s, str): + return False + if not s.startswith("0x") and not s.startswith("0X"): + return False + if len(s) != 42: + return False + try: + int(s, 16) + except ValueError: + return False + return True + + +def is_valid_txhash(s: str) -> bool: + """Return True if `s` looks like a 32-byte hex transaction hash.""" + if not isinstance(s, str): + return False + if not s.startswith("0x") and not s.startswith("0X"): + return False + if len(s) != 66: + return False + try: + int(s, 16) + except ValueError: + return False + return True + + +def require_address(s: str, *, field: str = "address") -> str: + """Return `s` lowercased if valid, else exit with an error message. + + Centralizing validation here means every subcommand fails fast on bad input + instead of bubbling up an opaque RPC error 30 seconds later. + """ + if not is_valid_address(s): + sys.stderr.write( + f"error: invalid {field} {s!r}: expected 0x-prefixed 40-hex-char address\n" + ) + sys.exit(2) + return s.lower() + + +def require_txhash(s: str, *, field: str = "tx hash") -> str: + """Return `s` lowercased if valid, else exit with an error message.""" + if not is_valid_txhash(s): + sys.stderr.write( + f"error: invalid {field} {s!r}: expected 0x-prefixed 64-hex-char tx hash\n" + ) + sys.exit(2) + return s.lower() + + +def wei_to_native(wei: int, decimals: int = 18) -> float: + return wei / (10 ** decimals) + + +def gwei_from_wei(wei: int) -> float: + return wei / 1e9 + +def _short_addr(addr: str) -> str: + if addr and len(addr) >= 10: + return addr[:6] + "..." + addr[-4:] + return addr or "" + +def print_json(data: Any) -> None: + print(json.dumps(data, indent=2, default=str)) + +# --------------------------------------------------------------------------- +# HTTP / JSON-RPC layer +# --------------------------------------------------------------------------- + +def _http_post(url: str, payload: Any, retries: int = 5, timeout: int = 20) -> Any: + body = json.dumps(payload).encode() + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (compatible; evm_client/1.0)", + } + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + delay = 1.0 + last_err: Exception = RuntimeError("No attempts made") + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 429: + time.sleep(delay) + delay = min(delay * 2, 30) + last_err = e + continue + body_text = "" + try: + body_text = e.read().decode() + except Exception: + pass + raise RuntimeError(f"HTTP {e.code}: {body_text}") from e + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + delay = min(delay * 2, 30) + raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err + +def _http_get(url: str, retries: int = 5, timeout: int = 20) -> Any: + headers = {"Accept": "application/json", "User-Agent": "evm_client/1.0"} + req = urllib.request.Request(url, headers=headers, method="GET") + delay = 1.0 + last_err: Exception = RuntimeError("No attempts made") + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 429: + time.sleep(delay) + delay = min(delay * 2, 30) + last_err = e + continue + body_text = "" + try: + body_text = e.read().decode() + except Exception: + pass + raise RuntimeError(f"HTTP {e.code}: {body_text}") from e + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + delay = min(delay * 2, 30) + raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err + +# --------------------------------------------------------------------------- +# RPC helpers +# --------------------------------------------------------------------------- + +def get_rpc_url(chain: str) -> str: + env = os.environ.get("EVM_RPC_URL", "") + if env: + return env + cfg = CHAINS.get(chain) + if not cfg: + raise ValueError(f"Unknown chain '{chain}'. Available: {', '.join(CHAINS)}") + return cfg["rpc"] + +def rpc_call(chain: str, method: str, params: List[Any], req_id: int = 1) -> Any: + url = get_rpc_url(chain) + payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + resp = _http_post(url, payload) + if "error" in resp: + raise RuntimeError(f"RPC error: {resp['error']}") + return resp.get("result") + +def rpc_batch(chain: str, calls: List[Tuple[str, List[Any]]], batch_limit: int = 10) -> List[Any]: + """Send a batch of JSON-RPC calls; returns list of results in same order. + + Auto-chunks at `batch_limit` (default 10) so we stay under per-RPC limits. + Base's public RPC caps batches at 10 — exceeding that returns a single error + dict instead of a results list, which would mask all our calls. + """ + url = get_rpc_url(chain) + + # Build the full payload, preserving order via JSON-RPC `id` + items = [ + {"jsonrpc": "2.0", "id": i, "method": m, "params": p} + for i, (m, p) in enumerate(calls) + ] + + out: List[Any] = [None] * len(items) + for start in range(0, len(items), batch_limit): + chunk = items[start:start + batch_limit] + resp = _http_post(url, chunk) + if not isinstance(resp, list): + # Single error response (e.g. batch-too-large) — leave this chunk as None + continue + for r in resp: + rid = r.get("id") + if isinstance(rid, int) and 0 <= rid < len(out): + if "error" in r: + out[rid] = None + else: + out[rid] = r.get("result") + return out + +# --------------------------------------------------------------------------- +# ABI encoding helpers (minimal, for ERC-20 calls) +# --------------------------------------------------------------------------- + +def _encode_address(addr: str) -> str: + """Pad address to 32 bytes.""" + return addr.lower().replace("0x", "").zfill(64) + +def _keccak256(data: bytes) -> bytes: + """Pure Python Keccak-256 (Ethereum's hash, NOT SHA3-256).""" + # Keccak-256 round constants + RC = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, + ] + ROT = [ + [0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14], + ] + def rot64(x, n): return ((x << n) | (x >> (64 - n))) & 0xFFFFFFFFFFFFFFFF + rate = 136 # 1088 bits for keccak-256 + # Padding + msg = bytearray(data) + msg.append(0x01) + while len(msg) % rate != 0: + msg.append(0x00) + msg[-1] |= 0x80 + # Absorb + state = [0] * 25 + for block_start in range(0, len(msg), rate): + block = msg[block_start:block_start + rate] + for i in range(rate // 8): + state[i] ^= int.from_bytes(block[i*8:(i+1)*8], "little") + # Keccak-f[1600] + for rnd in range(24): + # Theta + C = [state[x] ^ state[x+5] ^ state[x+10] ^ state[x+15] ^ state[x+20] for x in range(5)] + D = [C[(x-1) % 5] ^ rot64(C[(x+1) % 5], 1) for x in range(5)] + state = [state[i] ^ D[i % 5] for i in range(25)] + # Rho + Pi + B = [0] * 25 + for x in range(5): + for y in range(5): + B[y*5 + ((2*x+3*y) % 5)] = rot64(state[x + 5*y], ROT[x][y]) + # Chi + state = [B[i] ^ ((~B[(i//5)*5 + (i%5+1)%5]) & B[(i//5)*5 + (i%5+2)%5]) for i in range(25)] + # Iota + state[0] ^= RC[rnd] + # Squeeze + out = b"".join(state[i].to_bytes(8, "little") for i in range(4)) + return out + + +def _selector(sig: str) -> str: + """Compute 4-byte function selector via keccak-256.""" + return "0x" + _keccak256(sig.encode()).hex()[:8] + +# Precomputed selectors for ERC-20 functions +ERC20_SELECTORS: Dict[str, str] = { + "name()": "0x06fdde03", + "symbol()": "0x95d89b41", + "decimals()": "0x313ce567", + "totalSupply()": "0x18160ddd", + "balanceOf(address)": "0x70a08231", +} + +def eth_call_erc20(chain: str, contract: str, fn: str, arg_addr: Optional[str] = None) -> str: + selector = ERC20_SELECTORS[fn] + data = selector + if arg_addr: + data += _encode_address(arg_addr) + params = [{"to": contract, "data": data}, "latest"] + return rpc_call(chain, "eth_call", params) or "0x" + +def decode_string(hex_data: str) -> str: + """Decode ABI-encoded string from eth_call result.""" + try: + raw = hex_data[2:] if hex_data.startswith("0x") else hex_data + if len(raw) < 128: + # Try decoding as raw bytes (some tokens return non-ABI strings) + b = bytes.fromhex(raw) + return b.rstrip(b"\x00").decode("utf-8", errors="replace").strip() + # offset (skip 32 bytes), length, data + length = int(raw[64:128], 16) + chars = raw[128:128 + length * 2] + return bytes.fromhex(chars).decode("utf-8", errors="replace").strip() + except Exception: + return "" + +def decode_uint256(hex_data: str) -> int: + try: + raw = hex_data[2:] if hex_data.startswith("0x") else hex_data + if not raw: + return 0 + return int(raw, 16) + except Exception: + return 0 + +def decode_uint8(hex_data: str) -> int: + return decode_uint256(hex_data) + +# --------------------------------------------------------------------------- +# CoinGecko price fetching +# --------------------------------------------------------------------------- + +COINGECKO_BASE = "https://api.coingecko.com/api/v3" + +def cg_price_by_id(cg_id: str) -> Optional[float]: + try: + url = f"{COINGECKO_BASE}/simple/price?ids={cg_id}&vs_currencies=usd" + data = _http_get(url) + return data.get(cg_id, {}).get("usd") + except Exception: + return None + +def cg_price_by_ids(cg_ids: List[str]) -> Dict[str, float]: + """Fetch multiple prices in one request.""" + if not cg_ids: + return {} + try: + joined = ",".join(cg_ids) + url = f"{COINGECKO_BASE}/simple/price?ids={joined}&vs_currencies=usd" + data = _http_get(url) + return {k: v.get("usd", 0.0) for k, v in data.items() if "usd" in v} + except Exception: + return {} + +def cg_price_by_contract(chain: str, contract: str) -> Optional[float]: + cg_platform_map = { + "ethereum": "ethereum", + "bsc": "binance-smart-chain", + "base": "base", + "arbitrum": "arbitrum-one", + "polygon": "polygon-pos", + "optimism": "optimistic-ethereum", + "avalanche":"avalanche", + "zksync": "zksync", + } + platform = cg_platform_map.get(chain) + if not platform: + return None + try: + url = ( + f"{COINGECKO_BASE}/simple/token_price/{platform}" + f"?contract_addresses={contract}&vs_currencies=usd" + ) + data = _http_get(url) + addr_lower = contract.lower() + for k, v in data.items(): + if k.lower() == addr_lower: + return v.get("usd") + return None + except Exception: + return None + +def get_native_price(chain: str) -> Optional[float]: + cg_id = CHAINS[chain]["coingecko"] + return cg_price_by_id(cg_id) + +# --------------------------------------------------------------------------- +# Command implementations +# --------------------------------------------------------------------------- + +def cmd_stats(args: argparse.Namespace) -> None: + chain = args.chain + cfg = CHAINS[chain] + + # Batch: blockNumber + gasPrice + results = rpc_batch(chain, [ + ("eth_blockNumber", []), + ("eth_gasPrice", []), + ]) + block_num = hex_to_int(results[0] or "0x0") + gas_price_wei = hex_to_int(results[1] or "0x0") + + # TPS estimate: compare latest block timestamp with parent + tps: Optional[float] = None + try: + latest_block = rpc_call(chain, "eth_getBlockByNumber", ["latest", False]) + if latest_block: + parent_hex = latest_block.get("parentHash") + parent_block = rpc_call(chain, "eth_getBlockByHash", [parent_hex, False]) + if parent_block: + t1 = hex_to_int(latest_block.get("timestamp", "0x0")) + t0 = hex_to_int(parent_block.get("timestamp", "0x0")) + tx_count = len(latest_block.get("transactions", [])) + if t1 > t0: + tps = round(tx_count / (t1 - t0), 2) + except Exception: + pass + + native_price = get_native_price(chain) + + print_json({ + "chain": chain, + "block_number": block_num, + "gas_price_gwei": round(gwei_from_wei(gas_price_wei), 4), + "gas_price_wei": gas_price_wei, + "native_token": cfg["native"], + "native_price_usd": native_price, + "tps_estimate": tps, + "explorer": cfg["explorer"], + }) + + +def cmd_wallet(args: argparse.Namespace) -> None: + address = require_address(args.address) + chain = args.chain + limit = args.limit + no_prices = args.no_prices + cfg = CHAINS[chain] + + # Native balance + balance_hex = rpc_call(chain, "eth_getBalance", [address, "latest"]) + native_wei = hex_to_int(balance_hex or "0x0") + native_val = wei_to_native(native_wei, cfg["decimals"]) + + native_usd_price: Optional[float] = None + native_usd: Optional[float] = None + if not no_prices: + native_usd_price = get_native_price(chain) + if native_usd_price is not None: + native_usd = round(native_val * native_usd_price, 4) + + # ERC-20 tokens + token_list = list((KNOWN_TOKENS.get(chain) or {}).items())[:limit] + tokens_out = [] + portfolio_usd = native_usd or 0.0 + + if token_list: + # Batch balanceOf calls + balance_calls = [ + ("eth_call", [{"to": addr, "data": ERC20_SELECTORS["balanceOf(address)"] + _encode_address(address)}, "latest"]) + for _, addr in token_list + ] + balances = rpc_batch(chain, balance_calls) + + for idx, (symbol, addr) in enumerate(token_list): + raw_bal = decode_uint256(balances[idx] or "0x0") + if raw_bal == 0: + continue + + # Fetch decimals + dec_hex = eth_call_erc20(chain, addr, "decimals()") + decimals = decode_uint8(dec_hex) if dec_hex and dec_hex != "0x" else 18 + bal_human = wei_to_native(raw_bal, decimals) + + token_price: Optional[float] = None + token_usd: Optional[float] = None + if not no_prices: + try: + cg_id = COINGECKO_IDS.get(symbol) + if cg_id: + token_price = cg_price_by_id(cg_id) + if token_price is None: + token_price = cg_price_by_contract(chain, addr) + if token_price is not None: + token_usd = round(bal_human * token_price, 4) + portfolio_usd += token_usd + except Exception: + pass + + tokens_out.append({ + "symbol": symbol, + "contract": addr, + "balance": round(bal_human, 8), + "price_usd": token_price, + "value_usd": token_usd, + }) + + print_json({ + "chain": chain, + "address": address, + "native_token": cfg["native"], + "native_balance": round(native_val, 8), + "native_price_usd": native_usd_price, + "native_value_usd": native_usd, + "erc20_tokens": tokens_out, + "portfolio_total_usd": round(portfolio_usd, 4) if not no_prices else None, + }) + + +def cmd_tx(args: argparse.Namespace) -> None: + tx_hash = require_txhash(args.hash) + chain = args.chain + cfg = CHAINS[chain] + + results = rpc_batch(chain, [ + ("eth_getTransactionByHash", [tx_hash]), + ("eth_getTransactionReceipt", [tx_hash]), + ]) + tx = results[0] + receipt = results[1] + + if not tx: + print_json({"error": f"Transaction {tx_hash} not found on {chain}"}) + return + + block_num = hex_to_int(tx.get("blockNumber") or "0x0") + timestamp: Optional[int] = None + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(block_num), False]) + if blk: + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + except Exception: + pass + + value_wei = hex_to_int(tx.get("value", "0x0")) + value_eth = wei_to_native(value_wei, cfg["decimals"]) + gas_price = hex_to_int(tx.get("gasPrice") or "0x0") + gas_limit = hex_to_int(tx.get("gas", "0x0")) + gas_used = hex_to_int((receipt or {}).get("gasUsed", "0x0")) if receipt else None + status = None + if receipt: + status = "success" if hex_to_int(receipt.get("status", "0x0")) == 1 else "failed" + + input_data = tx.get("input", "0x") + input_preview = input_data[:66] + ("..." if len(input_data) > 66 else "") + + native_price = get_native_price(chain) + value_usd = round(value_eth * native_price, 4) if native_price else None + + fee_eth: Optional[float] = None + fee_usd: Optional[float] = None + if gas_used is not None: + fee_eth = wei_to_native(gas_used * gas_price, cfg["decimals"]) + if native_price: + fee_usd = round(fee_eth * native_price, 6) + + print_json({ + "chain": chain, + "hash": tx_hash, + "block": block_num, + "timestamp": timestamp, + "from": tx.get("from"), + "to": tx.get("to"), + "value": round(value_eth, 8), + "value_usd": value_usd, + "native_token": cfg["native"], + "gas_limit": gas_limit, + "gas_used": gas_used, + "gas_price_gwei": round(gwei_from_wei(gas_price), 4), + "fee_native": round(fee_eth, 8) if fee_eth is not None else None, + "fee_usd": fee_usd, + "status": status, + "input_preview": input_preview, + "nonce": hex_to_int(tx.get("nonce", "0x0")), + "explorer_url": f"{cfg['explorer']}/tx/{tx_hash}", + }) + + +def cmd_token(args: argparse.Namespace) -> None: + contract = require_address(args.contract, field="contract address") + chain = args.chain + + # Batch all ERC-20 metadata calls + calls = [ + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["name()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["symbol()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["decimals()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["totalSupply()"]}, "latest"]), + ] + results = rpc_batch(chain, calls) + name = decode_string(results[0] or "0x") + symbol = decode_string(results[1] or "0x") + decimals = decode_uint8(results[2] or "0x0") + supply_raw = decode_uint256(results[3] or "0x0") + supply = wei_to_native(supply_raw, decimals) + + price: Optional[float] = None + market_cap: Optional[float] = None + cg_id = COINGECKO_IDS.get(symbol.upper()) + if cg_id: + price = cg_price_by_id(cg_id) + if price is None: + price = cg_price_by_contract(chain, contract) + if price is not None and supply > 0: + market_cap = round(price * supply, 2) + + cfg = CHAINS[chain] + print_json({ + "chain": chain, + "contract": contract, + "name": name, + "symbol": symbol, + "decimals": decimals, + "total_supply": round(supply, 4), + "price_usd": price, + "market_cap_usd": market_cap, + "explorer_url": f"{cfg['explorer']}/token/{contract}", + }) + + +def cmd_activity(args: argparse.Namespace) -> None: + address = require_address(args.address) + chain = args.chain + limit = args.limit + cfg = CHAINS[chain] + + # Get current block + block_hex = rpc_call(chain, "eth_blockNumber", []) + latest = hex_to_int(block_hex or "0x0") + + txs_out: List[Dict[str, Any]] = [] + scan_range = min(200, latest) + blocks_checked = 0 + + for bn in range(latest, max(0, latest - scan_range), -1): + if len(txs_out) >= limit: + break + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True]) + except Exception: + continue + if not blk: + continue + blocks_checked += 1 + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + for tx in blk.get("transactions", []): + if len(txs_out) >= limit: + break + frm = (tx.get("from") or "").lower() + to = (tx.get("to") or "").lower() + addr_lower = address.lower() + if frm == addr_lower or to == addr_lower: + value_wei = hex_to_int(tx.get("value", "0x0")) + value_eth = wei_to_native(value_wei, cfg["decimals"]) + gas_price = hex_to_int(tx.get("gasPrice") or "0x0") + txs_out.append({ + "hash": tx.get("hash"), + "block": bn, + "timestamp": timestamp, + "from": tx.get("from"), + "to": tx.get("to"), + "value": round(value_eth, 8), + "native_token": cfg["native"], + "gas_price_gwei": round(gwei_from_wei(gas_price), 4), + "direction": "out" if frm == addr_lower else "in", + }) + + print_json({ + "chain": chain, + "address": address, + "blocks_scanned": blocks_checked, + "tx_count": len(txs_out), + "transactions": txs_out, + }) + + +def cmd_gas(args: argparse.Namespace) -> None: + chain = args.chain + cfg = CHAINS[chain] + + gas_price_hex = rpc_call(chain, "eth_gasPrice", []) + gas_wei = hex_to_int(gas_price_hex or "0x0") + gas_gwei = gwei_from_wei(gas_wei) + + native_price = get_native_price(chain) + + estimates: Dict[str, Any] = {} + for op, gas_units in GAS_ESTIMATES.items(): + cost_wei = gas_wei * gas_units + cost_native = wei_to_native(cost_wei, cfg["decimals"]) + cost_usd = round(cost_native * native_price, 6) if native_price else None + estimates[op] = { + "gas_units": gas_units, + "cost_native": round(cost_native, 8), + "cost_usd": cost_usd, + } + + print_json({ + "chain": chain, + "native_token": cfg["native"], + "gas_price_gwei": round(gas_gwei, 4), + "gas_price_wei": gas_wei, + "native_price_usd": native_price, + "estimates": estimates, + }) + + +def cmd_price(args: argparse.Namespace) -> None: + token = args.token + chain = args.chain + + price: Optional[float] = None + source = "unknown" + + # Check if it's a contract address + if token.startswith("0x") and len(token) >= 10: + price = cg_price_by_contract(chain, token) + source = "coingecko_contract" + if price is None: + print_json({"error": f"Could not find price for contract {token} on {chain}"}) + return + else: + symbol = token.upper() + cg_id = COINGECKO_IDS.get(symbol) + if cg_id: + price = cg_price_by_id(cg_id) + source = f"coingecko:{cg_id}" + if price is None: + # Try known tokens on given chain + contract = (KNOWN_TOKENS.get(chain) or {}).get(symbol) + if contract: + price = cg_price_by_contract(chain, contract) + source = f"coingecko_contract:{contract}" + if price is None: + print_json({"error": f"Could not find price for '{token}'. Try a contract address."}) + return + + print_json({ + "token": token, + "chain": chain, + "price_usd": price, + "source": source, + }) + + +def _fetch_chain_stats(chain: str) -> Dict[str, Any]: + """Fetch gas price + native price for a single chain (used in compare).""" + try: + gas_hex = rpc_call(chain, "eth_gasPrice", []) + gas_wei = hex_to_int(gas_hex or "0x0") + gas_gwei = round(gwei_from_wei(gas_wei), 4) + except Exception: + gas_gwei = None + + cg_id = CHAINS[chain]["coingecko"] + native_price = cg_price_by_id(cg_id) + + transfer_usd: Optional[float] = None + if gas_gwei is not None and native_price is not None: + gas_wei_val = int(gas_gwei * 1e9) + cost_wei = gas_wei_val * GAS_ESTIMATES["transfer"] + cost_native = wei_to_native(cost_wei, CHAINS[chain]["decimals"]) + transfer_usd = round(cost_native * native_price, 6) + + return { + "chain": chain, + "native_token": CHAINS[chain]["native"], + "gas_price_gwei": gas_gwei, + "native_price_usd": native_price, + "transfer_cost_usd": transfer_usd, + } + + +def cmd_compare(_args: argparse.Namespace) -> None: + """Compare gas prices and native token prices across all chains simultaneously.""" + import threading + + results: Dict[str, Any] = {} + errors: Dict[str, str] = {} + lock = threading.Lock() + + def fetch(chain: str) -> None: + try: + data = _fetch_chain_stats(chain) + with lock: + results[chain] = data + except Exception as e: + with lock: + errors[chain] = str(e) + + threads = [threading.Thread(target=fetch, args=(c,), daemon=True) for c in CHAINS] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + sorted_by_gas = sorted( + results.values(), + key=lambda x: x.get("gas_price_gwei") or float("inf"), + ) + + print_json({ + "comparison": sorted_by_gas, + "errors": errors, + "cheapest_gas": sorted_by_gas[0]["chain"] if sorted_by_gas else None, + "most_expensive_gas": sorted_by_gas[-1]["chain"] if sorted_by_gas else None, + }) + + +def cmd_whale(args: argparse.Namespace) -> None: + chain = args.chain + blocks = args.blocks + min_usd = args.min_usd + cfg = CHAINS[chain] + + native_price = get_native_price(chain) + if native_price is None: + print_json({"error": "Could not fetch native token price for USD conversion."}) + return + + block_hex = rpc_call(chain, "eth_blockNumber", []) + latest = hex_to_int(block_hex or "0x0") + + whales: List[Dict[str, Any]] = [] + blocks_scanned = 0 + + for bn in range(latest, max(0, latest - blocks), -1): + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True]) + except Exception: + continue + if not blk: + continue + blocks_scanned += 1 + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + + for tx in blk.get("transactions", []): + value_wei = hex_to_int(tx.get("value", "0x0")) + if value_wei == 0: + continue + value_native = wei_to_native(value_wei, cfg["decimals"]) + value_usd = value_native * native_price + if value_usd >= min_usd: + whales.append({ + "hash": tx.get("hash"), + "block": bn, + "timestamp": timestamp, + "from": tx.get("from"), + "from_short": _short_addr(tx.get("from") or ""), + "to": tx.get("to"), + "to_short": _short_addr(tx.get("to") or ""), + "value_native": round(value_native, 6), + "native_token": cfg["native"], + "value_usd": round(value_usd, 2), + }) + + whales.sort(key=lambda x: x["value_usd"], reverse=True) + + print_json({ + "chain": chain, + "blocks_scanned": blocks_scanned, + "latest_block": latest, + "min_usd": min_usd, + "native_price_usd": native_price, + "whale_count": len(whales), + "transfers": whales, + }) + + +# --------------------------------------------------------------------------- +# New commands: multichain, allowance, decode, ens, contract +# --------------------------------------------------------------------------- + +def cmd_multichain(args: argparse.Namespace) -> None: + """Scan same wallet across all 8 chains simultaneously.""" + import threading + + address = require_address(args.address) + results: Dict[str, Any] = {} + lock = threading.Lock() + + def scan_chain(chain: str) -> None: + cfg = CHAINS[chain] + try: + bal_hex = rpc_call(chain, "eth_getBalance", [address, "latest"]) + native_bal = int(bal_hex, 16) / 1e18 if bal_hex else 0.0 + native_price = get_native_price(chain) + native_usd = round(native_bal * native_price, 2) if native_price else None + entry: Dict[str, Any] = { + "native_symbol": cfg["native"], + "native_balance": round(native_bal, 8), + "native_price_usd": native_price, + "native_value_usd": native_usd, + "tokens": [], + "total_usd": native_usd or 0.0, + } + # Check known tokens for this chain. + # KNOWN_TOKENS[chain] maps {symbol: contract_address}, not {addr: (sym, name)}. + known = KNOWN_TOKENS.get(chain, {}) + for symbol, contract in known.items(): + raw = eth_call_erc20(chain, contract, "balanceOf(address)", address) + if not raw or raw == "0x": + continue + try: + bal_int = int(raw, 16) + except Exception: + continue + if bal_int == 0: + continue + dec_raw = eth_call_erc20(chain, contract, "decimals()") + decimals = decode_uint8(dec_raw) if dec_raw else 18 + human = bal_int / (10 ** decimals) + tok_price = cg_price_by_contract(chain, contract) + tok_usd = round(human * tok_price, 2) if tok_price else None + entry["tokens"].append({ + "symbol": symbol, + "balance": round(human, 6), + "value_usd": tok_usd, + }) + if tok_usd: + entry["total_usd"] = round(entry["total_usd"] + tok_usd, 2) + with lock: + results[chain] = entry + except Exception as exc: + with lock: + results[chain] = {"error": str(exc)} + + threads = [threading.Thread(target=scan_chain, args=(c,)) for c in CHAINS] + for t in threads: + t.start() + for t in threads: + t.join() + + grand_total = sum( + v.get("total_usd", 0) for v in results.values() if isinstance(v, dict) + ) + print_json({ + "address": address, + "chains": results, + "grand_total_usd": round(grand_total, 2), + }) + + +def cmd_allowance(args: argparse.Namespace) -> None: + """Check dangerous ERC-20 approvals for a wallet (known spenders).""" + address = require_address(args.address) + chain = args.chain + + # Well-known spender contracts (DEXes, bridges, etc.) + KNOWN_SPENDERS = { + "0x000000000022D473030F116dDEE9F6B43aC78BA3": "Permit2 (Uniswap)", + "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap V2 Router", + "0xE592427A0AEce92De3Edee1F18E0157C05861564": "Uniswap V3 Router", + "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45": "Uniswap Universal Router", + "0x1111111254EEB25477B68fb85Ed929f73A960582": "1inch Router V5", + "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5": "KyberSwap Router", + "0xDef1C0ded9bec7F1a1670819833240f027b25EfF": "0x Exchange Proxy", + "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": "Uniswap Universal Router 2", + } + + known = KNOWN_TOKENS.get(chain, {}) + approvals = [] + + # KNOWN_TOKENS[chain] is {symbol: contract_address}, not {addr: (sym, name)}. + for symbol, contract in known.items(): + for spender_addr, spender_name in KNOWN_SPENDERS.items(): + # allowance(owner, spender) = 0xdd62ed3e + owner_pad = address.lower().replace("0x", "").zfill(64) + spender_pad = spender_addr.lower().replace("0x", "").zfill(64) + data = "0xdd62ed3e" + owner_pad + spender_pad + raw = rpc_call(chain, "eth_call", [{"to": contract, "data": data}, "latest"]) + if not raw or raw == "0x": + continue + try: + allowance_int = int(raw, 16) + except Exception: + continue + if allowance_int == 0: + continue + + dec_raw = eth_call_erc20(chain, contract, "decimals()") + decimals = decode_uint8(dec_raw) if dec_raw else 18 + max_uint = 2**256 - 1 + is_unlimited = allowance_int >= max_uint // 2 + + approvals.append({ + "token": symbol, + "contract": contract, + "spender": spender_name, + "spender_address": spender_addr, + "allowance": "UNLIMITED" if is_unlimited else str(round(allowance_int / 10**decimals, 4)), + "risk": "HIGH" if is_unlimited else "LOW", + }) + + print_json({ + "chain": chain, + "address": address, + "approvals_found": len(approvals), + "approvals": approvals, + "note": "Only checks known DEX/bridge spenders. Use a full allowance checker for complete coverage.", + }) + + +def cmd_decode(args: argparse.Namespace) -> None: + """Decode transaction input data using 4byte.directory.""" + chain = args.chain + tx_hash = require_txhash(args.hash) + + tx = rpc_call(chain, "eth_getTransactionByHash", [tx_hash]) + if not tx: + print_json({"error": "Transaction not found"}) + return + + input_data: str = tx.get("input", "0x") + if not input_data or input_data == "0x": + print_json({ + "chain": chain, + "hash": tx_hash, + "decoded": None, + "note": "No input data (plain ETH transfer)", + }) + return + + selector = input_data[:10] # 0x + 4 bytes = 10 chars + + # Query 4byte.directory + url = f"https://www.4byte.directory/api/v1/signatures/?hex_signature={selector}" + data = _http_get(url) + + signatures = [] + if data and data.get("results"): + signatures = [r["text_signature"] for r in data["results"]] + + # Decode known transfer(address,uint256) manually as fallback + decoded_args: Optional[Dict] = None + if signatures and len(input_data) >= 74: + sig = signatures[0] + if sig == "transfer(address,uint256)" and len(input_data) == 138: + to_addr = "0x" + input_data[34:74] + amount_hex = input_data[74:] + try: + amount = int(amount_hex, 16) + decoded_args = {"to": to_addr, "amount_raw": amount} + except Exception: + pass + + print_json({ + "chain": chain, + "hash": tx_hash, + "selector": selector, + "input_length_bytes": (len(input_data) - 2) // 2, + "from": tx.get("from"), + "to": tx.get("to"), + "signatures": signatures, + "primary_signature": signatures[0] if signatures else None, + "decoded_args": decoded_args, + "raw_input_preview": input_data[:74] + ("..." if len(input_data) > 74 else ""), + "source": "4byte.directory", + }) + + +def cmd_ens(args: argparse.Namespace) -> None: + """Resolve ENS name <-> address via ensideas.com public API (no key needed).""" + query = args.name_or_address + + # ensideas.com handles both forward (name->address) and reverse (address->name) + try: + data = _http_get(f"https://api.ensideas.com/ens/resolve/{query}") + except Exception as exc: + print_json({"error": str(exc), "note": "ENS API unavailable"}) + return + + if not data: + print_json({"query": query, "address": None, "ens_name": None, "note": "Not found"}) + return + + print_json({ + "query": query, + "address": data.get("address"), + "ens_name": data.get("name"), + "avatar": data.get("avatar"), + "display": data.get("displayName"), + "twitter": data.get("twitter"), + "github": data.get("github"), + "source": "ensideas.com", + }) + + +def cmd_contract(args: argparse.Namespace) -> None: + """Inspect a smart contract: bytecode size, proxy detection, creation info.""" + chain = args.chain + address = require_address(args.address) + + # Get bytecode + code_hex = rpc_call(chain, "eth_getCode", [address, "latest"]) + if not code_hex or code_hex == "0x": + print_json({"chain": chain, "address": address, "is_contract": False, "note": "EOA (externally owned account)"}) + return + + bytecode_bytes = (len(code_hex) - 2) // 2 + + # Proxy detection patterns + # EIP-1967: implementation slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc + impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + impl_raw = rpc_call(chain, "eth_getStorageAt", [address, impl_slot, "latest"]) + implementation = None + is_proxy = False + if impl_raw and impl_raw != "0x" and int(impl_raw, 16) != 0: + is_proxy = True + implementation = "0x" + impl_raw[-40:] + + # EIP-1167 minimal proxy detection (starts with 0x363d3d37) + if code_hex[2:10] == "363d3d37" or code_hex[2:18] == "3d602d80600a3d39": + is_proxy = True + + # supportsInterface check: ERC-165 + supports_erc165 = False + try: + erc165_data = "0x01ffc9a701ffc9a700000000000000000000000000000000000000000000000000000000" + erc165_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc165_data}, "latest"]) + supports_erc165 = bool(erc165_raw and erc165_raw != "0x" and int(erc165_raw, 16) == 1) + except Exception: + pass + + # Try to detect ERC-20 (has totalSupply) + is_erc20 = False + try: + ts_raw = eth_call_erc20(chain, address, "totalSupply()") + is_erc20 = ts_raw is not None and ts_raw != "0x" and int(ts_raw, 16) > 0 + except Exception: + pass + + # Try to detect ERC-721 (supportsInterface 0x80ac58cd) + is_erc721 = False + try: + erc721_data = "0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000" + erc721_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc721_data}, "latest"]) + is_erc721 = bool(erc721_raw and erc721_raw != "0x" and int(erc721_raw, 16) == 1) + except Exception: + pass + + detected_standards = [] + if is_erc20: + detected_standards.append("ERC-20") + if is_erc721: + detected_standards.append("ERC-721") + if supports_erc165: + detected_standards.append("ERC-165") + + print_json({ + "chain": chain, + "address": address, + "is_contract": True, + "bytecode_size_bytes": bytecode_bytes, + "is_proxy": is_proxy, + "implementation": implementation, + "detected_standards": detected_standards, + "explorer_url": f"{CHAINS[chain]['explorer']}/address/{address}", + "note": "Proxy detected via EIP-1967 storage slot. Standards via EIP-165 + heuristics." if is_proxy else None, + }) + + +# --------------------------------------------------------------------------- +# Argument parsing & dispatch +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + chain_choices = list(CHAINS.keys()) + + parser = argparse.ArgumentParser( + prog="evm_client", + description="EVM blockchain CLI — stdlib only, zero dependencies.", + ) + sub = parser.add_subparsers(dest="command", metavar="COMMAND") + sub.required = True + + # -- stats -- + p_stats = sub.add_parser("stats", help="Chain stats: block, gas price, native price, TPS") + p_stats.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- wallet -- + p_wallet = sub.add_parser("wallet", help="Wallet balance + ERC-20 portfolio") + p_wallet.add_argument("address", help="Wallet address (0x...)") + p_wallet.add_argument("--limit", type=int, default=20, metavar="N", + help="Max number of known tokens to check (default: 20)") + p_wallet.add_argument("--no-prices", action="store_true", + help="Skip USD price lookups (faster)") + p_wallet.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- tx -- + p_tx = sub.add_parser("tx", help="Transaction details") + p_tx.add_argument("hash", help="Transaction hash (0x...)") + p_tx.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- token -- + p_token = sub.add_parser("token", help="ERC-20 token metadata + price") + p_token.add_argument("contract", help="Token contract address (0x...)") + p_token.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- activity -- + p_act = sub.add_parser("activity", help="Recent transactions for an address") + p_act.add_argument("address", help="Wallet address (0x...)") + p_act.add_argument("--limit", type=int, default=10, metavar="N", + help="Max transactions to return (default: 10)") + p_act.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- gas -- + p_gas = sub.add_parser("gas", help="Gas prices and cost estimates") + p_gas.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- price -- + p_price = sub.add_parser("price", help="Token price by symbol or contract address") + p_price.add_argument("token", help="Symbol (e.g. ETH, USDC) or contract address") + p_price.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- compare -- + sub.add_parser("compare", help="Gas + native prices across ALL chains simultaneously") + + # -- whale -- + p_whale = sub.add_parser("whale", help="Scan for large value transfers in recent blocks") + p_whale.add_argument("--blocks", type=int, default=20, metavar="N", + help="Number of recent blocks to scan (default: 20)") + p_whale.add_argument("--min-usd", type=float, default=10_000.0, metavar="N", + help="Minimum USD value to report (default: 10000)") + p_whale.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- multichain -- + p_multi = sub.add_parser("multichain", help="Scan same wallet across ALL chains simultaneously") + p_multi.add_argument("address", help="Wallet address (0x...)") + + # -- allowance -- + p_allow = sub.add_parser("allowance", help="Check dangerous ERC-20 approvals (known DEX/bridge spenders)") + p_allow.add_argument("address", help="Wallet address (0x...)") + p_allow.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- decode -- + p_decode = sub.add_parser("decode", help="Decode transaction input data via 4byte.directory") + p_decode.add_argument("hash", help="Transaction hash (0x...)") + p_decode.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- ens -- + p_ens = sub.add_parser("ens", help="Resolve ENS name <-> address (Ethereum only)") + p_ens.add_argument("name_or_address", help="ENS name (vitalik.eth) or address (0x...)") + + # -- contract -- + p_contract = sub.add_parser("contract", help="Inspect a smart contract: proxy, standards, bytecode size") + p_contract.add_argument("address", help="Contract address (0x...)") + p_contract.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + return parser + + +DISPATCH = { + "stats": cmd_stats, + "wallet": cmd_wallet, + "tx": cmd_tx, + "token": cmd_token, + "activity": cmd_activity, + "gas": cmd_gas, + "price": cmd_price, + "compare": cmd_compare, + "whale": cmd_whale, + "multichain": cmd_multichain, + "allowance": cmd_allowance, + "decode": cmd_decode, + "ens": cmd_ens, + "contract": cmd_contract, +} + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + # Validate chain exists (argparse choices already handles this, but for ENV override) + if hasattr(args, "chain") and args.chain not in CHAINS: + print_json({"error": f"Unknown chain '{args.chain}'. Available: {list(CHAINS.keys())}"}) + sys.exit(1) + + cmd_fn = DISPATCH.get(args.command) + if cmd_fn is None: + print_json({"error": f"Unknown command '{args.command}'"}) + sys.exit(1) + + try: + cmd_fn(args) + except KeyboardInterrupt: + print_json({"error": "Interrupted by user"}) + sys.exit(130) + except Exception as e: + print_json({"error": str(e)}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 612bcd239ce4..de34642911e5 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -21,6 +21,7 @@ from pathlib import Path from hermes_constants import get_hermes_home +from hermes_cli.profiles import _get_default_hermes_home from typing import Any, TYPE_CHECKING if TYPE_CHECKING: @@ -73,7 +74,7 @@ def resolve_config_path() -> Path: return local_path # Default profile's config — host blocks accumulate here via setup/clone - default_path = Path.home() / ".hermes" / "honcho.json" + default_path = _get_default_hermes_home() / "honcho.json" if default_path != local_path and default_path.exists(): return default_path diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 620780008663..ecb02b3de7e0 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -336,10 +336,17 @@ def health(self) -> bool: def _zip_directory(dir_path: Path) -> Path: """Create a temporary zip file containing a directory tree.""" + root = dir_path.resolve() zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: for file_path in dir_path.rglob("*"): + if file_path.is_symlink(): + continue if file_path.is_file(): + try: + file_path.resolve().relative_to(root) + except ValueError: + continue arcname = str(file_path.relative_to(dir_path)).replace("\\", "/") zipf.write(file_path, arcname=arcname) return zip_path diff --git a/plugins/model-providers/auriko/__init__.py b/plugins/model-providers/auriko/__init__.py new file mode 100644 index 000000000000..a48ba07f1684 --- /dev/null +++ b/plugins/model-providers/auriko/__init__.py @@ -0,0 +1,47 @@ +"""Auriko provider profile.""" + +from providers import register_provider +from providers.base import ProviderProfile + +auriko = ProviderProfile( + name="auriko", + aliases=("auriko-ai",), + display_name="Auriko", + description="Auriko — multi-LLM inference gateway", + signup_url="https://auriko.ai/signup", + env_vars=("AURIKO_API_KEY", "AURIKO_BASE_URL"), + base_url="https://api.auriko.ai/v1", + default_aux_model="claude-haiku-4-5-20251001", + fallback_models=( + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gpt-5.5-2026-04-23", + "gpt-5.4-2026-03-05", + "o4-mini-2025-04-16", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v3.2", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-3.1-pro-preview", + "grok-4.3", + "grok-4-fast-reasoning", + "kimi-k2.6", + "kimi-k2.5", + "kimi-k2-thinking", + "minimax-m2-7", + "minimax-m2-7-highspeed", + "minimax-m2", + "glm-5.1", + "glm-5", + "glm-4.7", + "glm-4.5-flash", + "qwen-3.6-plus", + "qwen-3.5-397b-a17b", + "qwen-3-vl-30b-a3b-thinking", + ), +) + +register_provider(auriko) diff --git a/plugins/model-providers/auriko/plugin.yaml b/plugins/model-providers/auriko/plugin.yaml new file mode 100644 index 000000000000..d60a4e0bbd2a --- /dev/null +++ b/plugins/model-providers/auriko/plugin.yaml @@ -0,0 +1,5 @@ +name: auriko-provider +kind: model-provider +version: 1.0.0 +description: Auriko — multi-LLM inference gateway +author: Nous Research diff --git a/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py new file mode 100644 index 000000000000..e49e289a0de4 --- /dev/null +++ b/plugins/model-providers/novita/__init__.py @@ -0,0 +1,27 @@ +"""NovitaAI provider profile.""" + +from providers import register_provider +from providers.base import ProviderProfile + + +novita = ProviderProfile( + name="novita", + aliases=("novita-ai", "novitaai"), + display_name="NovitaAI", + description="NovitaAI — AI-native cloud for builders and agents", + signup_url="https://novita.ai/settings/key-management", + env_vars=("NOVITA_API_KEY", "NOVITA_BASE_URL"), + base_url="https://api.novita.ai/openai/v1", + auth_type="api_key", + default_aux_model="deepseek/deepseek-v3-0324", + fallback_models=( + "moonshotai/kimi-k2.5", + "minimax/minimax-m2.7", + "zai-org/glm-5", + "deepseek/deepseek-v3-0324", + "deepseek/deepseek-r1-0528", + "qwen/qwen3-235b-a22b-fp8", + ), +) + +register_provider(novita) diff --git a/plugins/model-providers/novita/plugin.yaml b/plugins/model-providers/novita/plugin.yaml new file mode 100644 index 000000000000..d572ca616bdd --- /dev/null +++ b/plugins/model-providers/novita/plugin.yaml @@ -0,0 +1,5 @@ +name: novita-provider +kind: model-provider +version: 1.0.0 +description: NovitaAI AI-native cloud for builders and agents +author: Nous Research diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py new file mode 100644 index 000000000000..0f46f62a7a03 --- /dev/null +++ b/plugins/video_gen/fal/__init__.py @@ -0,0 +1,523 @@ +"""FAL.ai video generation backend. + +User-facing surface: pick a **model family** (e.g. "Pixverse v6", +"Veo 3.1", "Seedance 2.0", "Kling v3 4K", "LTX 2.3", "Happy Horse"). +The plugin auto-routes to the family's text-to-video endpoint when +called without ``image_url``, and to its image-to-video endpoint when +``image_url`` is provided. The agent never sees the routing — it just +calls ``video_generate(prompt=..., image_url=...)``. + +Model families (each with t2v + i2v endpoints): + + Cheap tier: + ltx-2.3 fal-ai/ltx-2.3-22b/text-to-video / fal-ai/ltx-2.3-22b/image-to-video + pixverse-v6 fal-ai/pixverse/v6/text-to-video / fal-ai/pixverse/v6/image-to-video + + Premium tier: + veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video + seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video + kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video + happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video + +Selection precedence for the active family: + 1. ``model=`` arg from the tool call + 2. ``FAL_VIDEO_MODEL`` env var + 3. ``video_gen.fal.model`` in ``config.yaml`` + 4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs) + 5. ``DEFAULT_MODEL`` + +Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the +gateway downloads and delivers it. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List, Optional, Tuple + +from agent.video_gen_provider import ( + VideoGenProvider, + error_response, + success_response, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Family catalog +# --------------------------------------------------------------------------- +# +# Each family declares both endpoints (when available) plus a per-family +# capability sheet derived from FAL's OpenAPI schemas. Capability flags +# drive which keys get added to the request payload — keys a family doesn't +# advertise are dropped before send. +# +# Capabilities: +# aspect_ratios : tuple of supported ratios (None = endpoint decides) +# resolutions : tuple of supported resolutions (None = endpoint decides) +# durations : tuple of supported durations OR (min, max) range +# (heuristic: 2-element with gap > 1 is a range) +# audio : True if generate_audio is supported +# negative : True if negative_prompt is supported + +FAL_FAMILIES: Dict[str, Dict[str, Any]] = { + # ─── Cheap / fast tier ───────────────────────────────────────────── + "ltx-2.3": { + "display": "LTX 2.3 (22B)", + "speed": "~30-60s", + "price": "cheap", + "strengths": "22B model with native audio generation. Affordable.", + "tier": "cheap", + "text_endpoint": "fal-ai/ltx-2.3-22b/text-to-video", + "image_endpoint": "fal-ai/ltx-2.3-22b/image-to-video", + # LTX docs don't expose duration/aspect/resolution enums — leave + # blank so we don't send unrecognized payload keys. + "aspect_ratios": None, + "resolutions": None, + "durations": None, + "audio": True, + "negative": True, + }, + "pixverse-v6": { + "display": "Pixverse v6", + "speed": "~30-90s", + "price": "cheap", + "strengths": "Affordable. Negative prompts. 1-15s durations.", + "tier": "cheap", + "text_endpoint": "fal-ai/pixverse/v6/text-to-video", + "image_endpoint": "fal-ai/pixverse/v6/image-to-video", + "aspect_ratios": None, + "resolutions": ("360p", "540p", "720p", "1080p"), + "durations": (1, 15), + "audio": True, + "negative": True, + }, + # ─── Expensive / premium tier ────────────────────────────────────── + "veo3.1": { + "display": "Veo 3.1", + "speed": "~60-120s", + "price": "premium", + "strengths": "Google DeepMind. Cinematic, native audio, strong prompt adherence.", + "tier": "premium", + "text_endpoint": "fal-ai/veo3.1", + "image_endpoint": "fal-ai/veo3.1/image-to-video", + "aspect_ratios": ("16:9", "9:16"), + "resolutions": ("720p", "1080p"), + "durations": (4, 6, 8), + "audio": True, + "negative": True, + }, + "seedance-2.0": { + "display": "Seedance 2.0", + "speed": "~60-120s", + "price": "premium", + "strengths": "ByteDance. Cinematic, synchronized audio + lip-sync, 4-15s.", + "tier": "premium", + "text_endpoint": "bytedance/seedance-2.0/text-to-video", + "image_endpoint": "bytedance/seedance-2.0/image-to-video", + # Seedance accepts "auto" too — we omit it from the enum so the + # agent can't pass it; the endpoint defaults handle the rest. + "aspect_ratios": ("21:9", "16:9", "4:3", "1:1", "3:4", "9:16"), + "resolutions": ("480p", "720p", "1080p"), + "durations": (4, 15), + "audio": True, + "negative": False, + }, + "kling-v3-4k": { + "display": "Kling v3 4K", + "speed": "~120-300s", + "price": "premium", + "strengths": "4K output, native audio (Chinese/English), 3-15s.", + "tier": "premium", + "text_endpoint": "fal-ai/kling-video/v3/4k/text-to-video", + "image_endpoint": "fal-ai/kling-video/v3/4k/image-to-video", + # Kling 4K image-to-video uses `start_image_url` instead of + # `image_url`. Handled in _build_payload via image_param_key. + "image_param_key": "start_image_url", + "aspect_ratios": ("16:9", "9:16", "1:1"), + "resolutions": None, # 4K is implicit + "durations": (3, 15), + "audio": True, + "negative": True, + }, + "happy-horse": { + "display": "Happy Horse 1.0", + "speed": "~60-120s", + "price": "premium", + "strengths": "Alibaba. New model, sparse public docs — conservative defaults.", + "tier": "premium", + "text_endpoint": "fal-ai/happy-horse/text-to-video", + "image_endpoint": "fal-ai/happy-horse/image-to-video", + # Docs don't expose duration/aspect/resolution — let the endpoint + # apply its own defaults. + "aspect_ratios": None, + "resolutions": None, + "durations": None, + "audio": False, + "negative": False, + }, +} + +DEFAULT_MODEL = "pixverse-v6" # cheap, both modalities, sane defaults + + +def _is_duration_range(durations: Any) -> bool: + """Heuristic: a 2-tuple of ints with a gap > 1 is treated as ``(min, max)``.""" + if not isinstance(durations, tuple) or len(durations) != 2: + return False + if not all(isinstance(d, int) for d in durations): + return False + return durations[1] - durations[0] > 1 + + +def _clamp_duration(family: Dict[str, Any], duration: Optional[int]) -> Optional[int]: + durations = family.get("durations") + if not durations: + return duration + if duration is None: + return durations[0] + if _is_duration_range(durations): + lo, hi = durations + return max(lo, min(hi, duration)) + # enum + if duration in durations: + return duration + return min(durations, key=lambda d: abs(d - duration)) + + +# --------------------------------------------------------------------------- +# Config / model resolution +# --------------------------------------------------------------------------- + + +def _load_video_gen_section() -> Dict[str, Any]: + try: + from hermes_cli.config import load_config + + cfg = load_config() + section = cfg.get("video_gen") if isinstance(cfg, dict) else None + return section if isinstance(section, dict) else {} + except Exception as exc: + logger.debug("Could not load video_gen config: %s", exc) + return {} + + +def _resolve_family(explicit: Optional[str]) -> Tuple[str, Dict[str, Any]]: + """Decide which FAL family to use. Returns ``(family_id, meta)``.""" + candidates: List[Optional[str]] = [] + candidates.append(explicit) + candidates.append(os.environ.get("FAL_VIDEO_MODEL")) + + cfg = _load_video_gen_section() + fal_cfg = cfg.get("fal") if isinstance(cfg.get("fal"), dict) else {} + if isinstance(fal_cfg, dict): + candidates.append(fal_cfg.get("model")) + top = cfg.get("model") + if isinstance(top, str): + candidates.append(top) + + for c in candidates: + if isinstance(c, str) and c.strip() and c.strip() in FAL_FAMILIES: + fid = c.strip() + return fid, FAL_FAMILIES[fid] + + return DEFAULT_MODEL, FAL_FAMILIES[DEFAULT_MODEL] + + +# --------------------------------------------------------------------------- +# Payload construction +# --------------------------------------------------------------------------- + + +def _build_payload( + family: Dict[str, Any], + *, + prompt: str, + image_url: Optional[str], + duration: Optional[int], + aspect_ratio: str, + resolution: str, + negative_prompt: Optional[str], + audio: Optional[bool], + seed: Optional[int], +) -> Dict[str, Any]: + """Build a family-specific payload, dropping keys the family doesn't declare.""" + payload: Dict[str, Any] = {} + + if prompt: + payload["prompt"] = prompt + if image_url: + # Some endpoints (e.g. Kling v3 4K image-to-video) expect + # `start_image_url` instead of `image_url`. The family entry can + # declare an override. + key = family.get("image_param_key") or "image_url" + payload[key] = image_url + if seed is not None: + payload["seed"] = seed + + if family.get("aspect_ratios"): + if aspect_ratio in family["aspect_ratios"]: + payload["aspect_ratio"] = aspect_ratio + # otherwise let the endpoint auto-crop / use its default + + if family.get("resolutions"): + if resolution in family["resolutions"]: + payload["resolution"] = resolution + # else: let the endpoint default + + clamped = _clamp_duration(family, duration) + if clamped is not None and family.get("durations"): + # FAL exposes duration as a string in the queue API ("8" not 8). + payload["duration"] = str(clamped) + + if family.get("audio") and audio is not None: + payload["generate_audio"] = bool(audio) + + if family.get("negative") and negative_prompt: + payload["negative_prompt"] = negative_prompt + + return payload + + +# --------------------------------------------------------------------------- +# fal_client lazy import (same pattern as image_generation_tool) +# --------------------------------------------------------------------------- + +_fal_client: Any = None + + +def _load_fal_client() -> Any: + global _fal_client + if _fal_client is not None: + return _fal_client + import fal_client # type: ignore + + _fal_client = fal_client + return fal_client + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +class FALVideoGenProvider(VideoGenProvider): + """FAL.ai multi-family video generation backend. + + Routes between text-to-video and image-to-video endpoints automatically + based on whether ``image_url`` was provided. + """ + + @property + def name(self) -> str: + return "fal" + + @property + def display_name(self) -> str: + return "FAL" + + def is_available(self) -> bool: + if not os.environ.get("FAL_KEY", "").strip(): + return False + try: + import fal_client # noqa: F401 + except ImportError: + return False + return True + + def list_models(self) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for fid, meta in FAL_FAMILIES.items(): + modalities: List[str] = [] + if meta.get("text_endpoint"): + modalities.append("text") + if meta.get("image_endpoint"): + modalities.append("image") + out.append({ + "id": fid, + "display": meta["display"], + "speed": meta["speed"], + "strengths": meta["strengths"], + "price": meta["price"], + "tier": meta.get("tier", "premium"), + "modalities": modalities, + }) + return out + + def default_model(self) -> Optional[str]: + return DEFAULT_MODEL + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "FAL", + "badge": "paid", + "tag": "LTX, Pixverse, Veo 3.1, Seedance 2.0, Kling 4K, Happy Horse — text-to-video & image-to-video", + "env_vars": [ + { + "key": "FAL_KEY", + "prompt": "FAL.ai API key", + "url": "https://fal.ai/dashboard/keys", + }, + ], + } + + def capabilities(self) -> Dict[str, Any]: + return { + "modalities": ["text", "image"], + "aspect_ratios": ["16:9", "9:16", "1:1"], + "resolutions": ["360p", "540p", "720p", "1080p"], + "max_duration": 15, + "min_duration": 1, + "supports_audio": True, + "supports_negative_prompt": True, + "max_reference_images": 0, + } + + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = "16:9", + resolution: str = "720p", + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + if not os.environ.get("FAL_KEY", "").strip(): + return error_response( + error=( + "FAL_KEY not set. Run `hermes tools` → Video Generation " + "→ FAL to configure." + ), + error_type="auth_required", + provider="fal", + prompt=prompt, + ) + + try: + fal_client = _load_fal_client() + except ImportError: + return error_response( + error="fal_client Python package not installed (pip install fal-client)", + error_type="missing_dependency", + provider="fal", + prompt=prompt, + ) + + prompt = (prompt or "").strip() + family_id, family = _resolve_family(model) + + # Route: image_url → image-to-video endpoint; else → text-to-video. + image_url_norm = (image_url or "").strip() or None + if image_url_norm: + endpoint = family.get("image_endpoint") + modality_used = "image" + if not endpoint: + return error_response( + error=( + f"FAL family {family_id} has no image-to-video " + f"endpoint. Pick a family with image-to-video support " + f"via `hermes tools` → Video Generation." + ), + error_type="modality_unsupported", + provider="fal", model=family_id, prompt=prompt, + ) + else: + endpoint = family.get("text_endpoint") + modality_used = "text" + if not endpoint: + return error_response( + error=( + f"FAL family {family_id} has no text-to-video " + f"endpoint. Pass an image_url to use its " + f"image-to-video endpoint, or pick a different family." + ), + error_type="modality_unsupported", + provider="fal", model=family_id, prompt=prompt, + ) + + if not prompt: + return error_response( + error="prompt is required.", + error_type="missing_prompt", + provider="fal", model=family_id, prompt=prompt, + ) + + payload = _build_payload( + family, + prompt=prompt, + image_url=image_url_norm, + duration=duration, + aspect_ratio=aspect_ratio, + resolution=resolution, + negative_prompt=negative_prompt, + audio=audio, + seed=seed, + ) + + try: + result = fal_client.subscribe( + endpoint, + arguments=payload, + with_logs=False, + ) + except Exception as exc: + logger.warning( + "FAL video gen failed (family=%s, endpoint=%s): %s", + family_id, endpoint, exc, exc_info=True, + ) + return error_response( + error=f"FAL video generation failed: {exc}", + error_type="api_error", + provider="fal", model=family_id, prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + video = (result or {}).get("video") if isinstance(result, dict) else None + url: Optional[str] = None + if isinstance(video, dict): + url = video.get("url") + elif isinstance(video, str): + url = video + + if not url: + return error_response( + error="FAL returned no video URL in response", + error_type="empty_response", + provider="fal", model=family_id, prompt=prompt, + ) + + extra: Dict[str, Any] = {"endpoint": endpoint} + if isinstance(video, dict): + if video.get("file_size"): + extra["file_size"] = video["file_size"] + if video.get("content_type"): + extra["content_type"] = video["content_type"] + + return success_response( + video=url, + model=family_id, + prompt=prompt, + modality=modality_used, + aspect_ratio=aspect_ratio if "aspect_ratio" in payload else "", + duration=int(payload["duration"]) if "duration" in payload else 0, + provider="fal", + extra=extra, + ) + + +# --------------------------------------------------------------------------- +# Plugin entry point +# --------------------------------------------------------------------------- + + +def register(ctx) -> None: + """Plugin entry point — wire ``FALVideoGenProvider`` into the registry.""" + ctx.register_video_gen_provider(FALVideoGenProvider()) diff --git a/plugins/video_gen/fal/plugin.yaml b/plugins/video_gen/fal/plugin.yaml new file mode 100644 index 000000000000..2003a817e78b --- /dev/null +++ b/plugins/video_gen/fal/plugin.yaml @@ -0,0 +1,7 @@ +name: fal +version: 1.0.0 +description: "FAL.ai video generation backend. Multi-model — Veo 3.1, Kling, Pixverse — covering text-to-video and image-to-video via fal_client's queue API." +author: NousResearch +kind: backend +requires_env: + - FAL_KEY diff --git a/plugins/video_gen/xai/__init__.py b/plugins/video_gen/xai/__init__.py new file mode 100644 index 000000000000..b7421799044c --- /dev/null +++ b/plugins/video_gen/xai/__init__.py @@ -0,0 +1,402 @@ +"""xAI Grok-Imagine video generation backend. + +Surface: text-to-video and image-to-video (animate an input image) +through xAI's ``/videos/generations`` endpoint. Edit and extend are not +exposed in this unified surface — xAI is the only backend that supports +them and the inconsistency would force per-backend prose in the agent's +tool description. + +Originally salvaged from PR #10600 by @Jaaneek; reshaped into the +:class:`VideoGenProvider` plugin interface and trimmed to the +generate-only surface. + +Authentication via ``XAI_API_KEY``. Output is an HTTPS URL from xAI's +CDN; the gateway downloads and delivers it. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from typing import Any, Dict, List, Optional + +import httpx + +from agent.video_gen_provider import ( + VideoGenProvider, + error_response, + success_response, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" +DEFAULT_MODEL = "grok-imagine-video" +DEFAULT_DURATION = 8 +DEFAULT_ASPECT_RATIO = "16:9" +DEFAULT_RESOLUTION = "720p" +DEFAULT_TIMEOUT_SECONDS = 240 +DEFAULT_POLL_INTERVAL_SECONDS = 5 + +VALID_ASPECT_RATIOS = {"1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"} +VALID_RESOLUTIONS = {"480p", "720p"} +MAX_REFERENCE_IMAGES = 7 + + +_MODELS: Dict[str, Dict[str, Any]] = { + "grok-imagine-video": { + "display": "Grok Imagine Video", + "speed": "~60-240s", + "strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.", + "price": "see https://docs.x.ai/docs/models", + "modalities": ["text", "image"], + }, +} + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +def _xai_base_url() -> str: + return (os.getenv("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL).strip().rstrip("/") + + +def _xai_headers() -> Dict[str, str]: + api_key = os.getenv("XAI_API_KEY", "").strip() + if not api_key: + raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/") + try: + from tools.xai_http import hermes_xai_user_agent + + ua = hermes_xai_user_agent() + except Exception: + ua = "hermes-agent/video_gen" + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "User-Agent": ua, + } + + +def _normalize_reference_images(reference_image_urls: Optional[List[str]]): + refs = [] + for url in reference_image_urls or []: + normalized = (url or "").strip() + if normalized: + refs.append({"url": normalized}) + return refs or None + + +def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int: + value = duration if duration is not None else DEFAULT_DURATION + if value < 1: + value = 1 + if value > 15: + value = 15 + if has_reference_images and value > 10: + value = 10 + return value + + +async def _submit( + client: httpx.AsyncClient, + payload: Dict[str, Any], +) -> str: + """POST to /videos/generations — xAI's only public endpoint for our + text-to-video and image-to-video surface.""" + response = await client.post( + f"{_xai_base_url()}/videos/generations", + headers={**_xai_headers(), "x-idempotency-key": str(uuid.uuid4())}, + json=payload, + timeout=60, + ) + response.raise_for_status() + body = response.json() + request_id = body.get("request_id") + if not request_id: + raise RuntimeError("xAI video response did not include request_id") + return request_id + + +async def _poll( + client: httpx.AsyncClient, + request_id: str, + *, + timeout_seconds: int, + poll_interval: int, +) -> Dict[str, Any]: + elapsed = 0.0 + last_status = "queued" + while elapsed < timeout_seconds: + response = await client.get( + f"{_xai_base_url()}/videos/{request_id}", + headers=_xai_headers(), + timeout=30, + ) + response.raise_for_status() + body = response.json() + last_status = (body.get("status") or "").lower() + + if last_status == "done": + return {"status": "done", "body": body} + if last_status in {"failed", "error", "expired", "cancelled"}: + return {"status": last_status, "body": body} + + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + return {"status": "timeout", "body": {"status": last_status}} + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +class XAIVideoGenProvider(VideoGenProvider): + """xAI grok-imagine-video backend (text-to-video + image-to-video).""" + + @property + def name(self) -> str: + return "xai" + + @property + def display_name(self) -> str: + return "xAI" + + def is_available(self) -> bool: + return bool(os.environ.get("XAI_API_KEY", "").strip()) + + def list_models(self) -> List[Dict[str, Any]]: + return [{"id": mid, **meta} for mid, meta in _MODELS.items()] + + def default_model(self) -> Optional[str]: + return DEFAULT_MODEL + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "xAI", + "badge": "paid", + "tag": "grok-imagine-video — text-to-video & image-to-video with reference images", + "env_vars": [ + { + "key": "XAI_API_KEY", + "prompt": "xAI API key", + "url": "https://console.x.ai/", + }, + ], + } + + def capabilities(self) -> Dict[str, Any]: + return { + "modalities": ["text", "image"], + "aspect_ratios": sorted(VALID_ASPECT_RATIOS), + "resolutions": sorted(VALID_RESOLUTIONS), + "max_duration": 15, + "min_duration": 1, + "supports_audio": False, + "supports_negative_prompt": False, + "max_reference_images": MAX_REFERENCE_IMAGES, + } + + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + resolution: str = DEFAULT_RESOLUTION, + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + try: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(self._generate_async( + prompt=prompt, + model=model, + image_url=image_url, + reference_image_urls=reference_image_urls, + duration=duration, + aspect_ratio=aspect_ratio, + resolution=resolution, + )) + finally: + loop.close() + except Exception as exc: + logger.warning("xAI video gen unexpected failure: %s", exc, exc_info=True) + return error_response( + error=f"xAI video generation failed: {exc}", + error_type="api_error", + provider="xai", + model=model or DEFAULT_MODEL, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + async def _generate_async( + self, + *, + prompt: str, + model: Optional[str], + image_url: Optional[str], + reference_image_urls: Optional[List[str]], + duration: Optional[int], + aspect_ratio: str, + resolution: str, + ) -> Dict[str, Any]: + if not os.environ.get("XAI_API_KEY", "").strip(): + return error_response( + error="XAI_API_KEY not set. Get one at https://console.x.ai/", + error_type="auth_required", + provider="xai", prompt=prompt, + ) + + prompt = (prompt or "").strip() + image_url_norm = (image_url or "").strip() or None + normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip() + normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower() + modality_used = "image" if image_url_norm else "text" + + if not prompt: + return error_response( + error=( + "prompt is required for xAI video generation " + "(text-to-video or image-to-video)" + ), + error_type="missing_prompt", + provider="xai", prompt=prompt, + ) + + refs = _normalize_reference_images(reference_image_urls) + if refs and len(refs) > MAX_REFERENCE_IMAGES: + return error_response( + error=f"reference_image_urls supports at most {MAX_REFERENCE_IMAGES} images on xAI", + error_type="too_many_references", + provider="xai", prompt=prompt, + ) + if image_url_norm and refs: + return error_response( + error="image_url and reference_image_urls cannot be combined on xAI", + error_type="conflicting_inputs", + provider="xai", prompt=prompt, + ) + + clamped_duration = _clamp_duration(duration, has_reference_images=bool(refs)) + + if normalized_aspect_ratio not in VALID_ASPECT_RATIOS: + normalized_aspect_ratio = DEFAULT_ASPECT_RATIO + if normalized_resolution not in VALID_RESOLUTIONS: + normalized_resolution = DEFAULT_RESOLUTION + + payload: Dict[str, Any] = { + "model": model or DEFAULT_MODEL, + "prompt": prompt, + "duration": clamped_duration, + "aspect_ratio": normalized_aspect_ratio, + "resolution": normalized_resolution, + } + if image_url_norm: + payload["image"] = {"url": image_url_norm} + if refs: + payload["reference_images"] = refs + + async with httpx.AsyncClient() as client: + try: + request_id = await _submit(client, payload) + except httpx.HTTPStatusError as exc: + detail = "" + try: + detail = exc.response.text[:500] + except Exception: + pass + return error_response( + error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}", + error_type="api_error", + provider="xai", + model=model or DEFAULT_MODEL, + prompt=prompt, + ) + + poll_result = await _poll( + client, request_id, + timeout_seconds=DEFAULT_TIMEOUT_SECONDS, + poll_interval=DEFAULT_POLL_INTERVAL_SECONDS, + ) + + status = poll_result["status"] + body = poll_result["body"] + + if status == "done": + video = body.get("video") or {} + url = video.get("url") + if not url: + return error_response( + error="xAI video generation completed without a video URL", + error_type="empty_response", + provider="xai", + model=body.get("model") or model or DEFAULT_MODEL, + prompt=prompt, + ) + extra: Dict[str, Any] = { + "request_id": request_id, + "resolution": normalized_resolution, + } + if body.get("usage"): + extra["usage"] = body["usage"] + return success_response( + video=url, + model=body.get("model") or model or DEFAULT_MODEL, + prompt=prompt, + modality=modality_used, + aspect_ratio=normalized_aspect_ratio, + duration=video.get("duration") or clamped_duration, + provider="xai", + extra=extra, + ) + + if status == "timeout": + return error_response( + error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s", + error_type="timeout", + provider="xai", + model=model or DEFAULT_MODEL, + prompt=prompt, + ) + + message = ( + (body.get("error", {}) or {}).get("message") + or body.get("message") + or f"xAI video generation ended with status '{status}'" + ) + return error_response( + error=message, + error_type=f"xai_{status}", + provider="xai", + model=model or DEFAULT_MODEL, + prompt=prompt, + ) + + +# --------------------------------------------------------------------------- +# Plugin entry point +# --------------------------------------------------------------------------- + + +def register(ctx) -> None: + """Plugin entry point — wire ``XAIVideoGenProvider`` into the registry.""" + ctx.register_video_gen_provider(XAIVideoGenProvider()) diff --git a/plugins/video_gen/xai/plugin.yaml b/plugins/video_gen/xai/plugin.yaml new file mode 100644 index 000000000000..85aa6e68f13d --- /dev/null +++ b/plugins/video_gen/xai/plugin.yaml @@ -0,0 +1,7 @@ +name: xai +version: 1.0.0 +description: "xAI Grok-Imagine video generation backend. Supports text-to-video, image-to-video, reference-image-guided generation, video edit, and video extend via the xAI async videos API." +author: NousResearch +kind: backend +requires_env: + - XAI_API_KEY diff --git a/plugins/web/__init__.py b/plugins/web/__init__.py new file mode 100644 index 000000000000..ad557e17744b --- /dev/null +++ b/plugins/web/__init__.py @@ -0,0 +1,7 @@ +# Bundled web search providers — plugins/web/. +# +# Each subdirectory follows the image_gen plugin layout: +# plugins/web//{plugin.yaml, __init__.py, provider.py} +# +# They auto-load via kind: backend and register via +# ctx.register_web_search_provider() into agent.web_search_registry. diff --git a/plugins/web/brave_free/__init__.py b/plugins/web/brave_free/__init__.py new file mode 100644 index 000000000000..6499d5467228 --- /dev/null +++ b/plugins/web/brave_free/__init__.py @@ -0,0 +1,14 @@ +"""Brave Search (free tier) plugin — bundled, auto-loaded. + +Mirrors the ``plugins/image_gen/openai/`` layout: ``provider.py`` holds the +provider class, ``__init__.py::register(ctx)`` registers an instance. +""" + +from __future__ import annotations + +from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + + +def register(ctx) -> None: + """Register the Brave-free provider with the plugin context.""" + ctx.register_web_search_provider(BraveFreeWebSearchProvider()) diff --git a/plugins/web/brave_free/plugin.yaml b/plugins/web/brave_free/plugin.yaml new file mode 100644 index 000000000000..3b39a34e18de --- /dev/null +++ b/plugins/web/brave_free/plugin.yaml @@ -0,0 +1,7 @@ +name: web-brave-free +version: 1.0.0 +description: "Brave Search (free tier) — web search via Brave's Data-for-Search API. Requires BRAVE_SEARCH_API_KEY (free signup at https://brave.com/search/api/, 2k queries/month)." +author: NousResearch +kind: backend +provides_web_providers: + - brave-free diff --git a/tools/web_providers/brave_free.py b/plugins/web/brave_free/provider.py similarity index 56% rename from tools/web_providers/brave_free.py rename to plugins/web/brave_free/provider.py index 52d02dec2a18..df4584f7732c 100644 --- a/tools/web_providers/brave_free.py +++ b/plugins/web/brave_free/provider.py @@ -1,23 +1,20 @@ -"""Brave Search web search provider (free tier). +"""Brave Search (free tier) — plugin form. -Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the -time of writing) after signing up at https://brave.com/search/api/. This -provider implements ``WebSearchProvider`` only — the Data-for-Search endpoint -returns search results, it does not extract/crawl arbitrary URLs. +Subclasses :class:`agent.web_search_provider.WebSearchProvider` (the +plugin-facing ABC). The legacy in-tree module +``tools.web_providers.brave_free`` was removed in the same commit that +moved this code under ``plugins/``; this file is now the canonical +implementation. -Configuration:: +Config keys this provider responds to:: - # ~/.hermes/.env - BRAVE_SEARCH_API_KEY=your-subscription-token - - # ~/.hermes/config.yaml web: - search_backend: "brave-free" - extract_backend: "firecrawl" # pair with an extract provider if needed + search_backend: "brave-free" # explicit per-capability + backend: "brave-free" # shared fallback + +Auth env var:: -The API uses the ``X-Subscription-Token`` header. Free-tier keys are rate -limited (1 qps) and capped at 2k queries/month; see the Brave dashboard for -current quotas. + BRAVE_SEARCH_API_KEY=... # https://brave.com/search/api/ (free tier) """ from __future__ import annotations @@ -26,49 +23,45 @@ import os from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) _BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" -class BraveFreeSearchProvider(WebSearchProvider): - """Search via the Brave Search API (free tier). +class BraveFreeWebSearchProvider(WebSearchProvider): + """Search-only Brave provider using the free-tier Data-for-Search API. - Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the - ``X-Subscription-Token`` header. No extract capability — pair with - Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``. + Free tier is 2,000 queries/month (1 qps). No content-extraction capability — + users pair this with Firecrawl/Tavily/Exa for ``web_extract``. """ - def provider_name(self) -> str: + @property + def name(self) -> str: + # Hyphen form preserved for backward compat with the existing + # ``web.search_backend: "brave-free"`` config keys users have set. return "brave-free" - def is_configured(self) -> bool: + @property + def display_name(self) -> str: + return "Brave Search (Free)" + + def is_available(self) -> bool: """Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value.""" return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip()) - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a search against the Brave Search API. + def supports_search(self) -> bool: + return True - Returns normalized results:: + def supports_extract(self) -> bool: + return False - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a search against the Brave Search API. - On failure returns ``{"success": False, "error": str}``. + Returns ``{"success": True, "data": {"web": [{"title", "url", "description", "position"}]}}`` + on success, or ``{"success": False, "error": str}`` on failure. """ import httpx @@ -128,3 +121,17 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]: ) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Brave Search (Free)", + "badge": "free", + "tag": "Free-tier API key — 2k queries/mo, search only.", + "env_vars": [ + { + "key": "BRAVE_SEARCH_API_KEY", + "prompt": "Brave Search API key (free tier)", + "url": "https://brave.com/search/api/", + }, + ], + } diff --git a/plugins/web/ddgs/__init__.py b/plugins/web/ddgs/__init__.py new file mode 100644 index 000000000000..26eb6407ef8d --- /dev/null +++ b/plugins/web/ddgs/__init__.py @@ -0,0 +1,15 @@ +"""DuckDuckGo search plugin — bundled, auto-loaded. + +Backed by the community ``ddgs`` Python package which scrapes DDG's HTML +results page. No API key required, but the package itself must be installed +(it's an optional dep — gated via :meth:`is_available`). +""" + +from __future__ import annotations + +from plugins.web.ddgs.provider import DDGSWebSearchProvider + + +def register(ctx) -> None: + """Register the DDGS provider with the plugin context.""" + ctx.register_web_search_provider(DDGSWebSearchProvider()) diff --git a/plugins/web/ddgs/plugin.yaml b/plugins/web/ddgs/plugin.yaml new file mode 100644 index 000000000000..e85236c14cf1 --- /dev/null +++ b/plugins/web/ddgs/plugin.yaml @@ -0,0 +1,7 @@ +name: web-ddgs +version: 1.0.0 +description: "DuckDuckGo web search via the ddgs Python package — no API key required. Install with `pip install ddgs`." +author: NousResearch +kind: backend +provides_web_providers: + - ddgs diff --git a/tools/web_providers/ddgs.py b/plugins/web/ddgs/provider.py similarity index 50% rename from tools/web_providers/ddgs.py rename to plugins/web/ddgs/provider.py index b81b97de2cb4..e8846236a24d 100644 --- a/tools/web_providers/ddgs.py +++ b/plugins/web/ddgs/provider.py @@ -1,28 +1,13 @@ -"""DuckDuckGo web search provider via the ``ddgs`` Python package. +"""DuckDuckGo search — plugin form (via the ``ddgs`` package). -DuckDuckGo does not provide an official programmatic search API. The -community-maintained `ddgs `_ package (the -renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results -page and normalizes them. It implements ``WebSearchProvider`` only — there is -no extract capability. +Subclasses the plugin-facing :class:`agent.web_search_provider.WebSearchProvider`. +The legacy in-tree module ``tools.web_providers.ddgs`` was removed in the +same commit that moved this code under ``plugins/``; this file is now the +canonical implementation. -Configuration:: - - # No API key required. Enable by installing the package and pointing the - # web backend at ddgs: - pip install ddgs - - # ~/.hermes/config.yaml - web: - search_backend: "ddgs" - extract_backend: "firecrawl" # pair with an extract provider if needed - -Rate limits are enforced server-side by DuckDuckGo. Expect intermittent -``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider -surfaces them as ``{"success": False, "error": ...}`` rather than crashing -the tool call. - -See https://duckduckgo.com/?q=duckduckgo+tos for terms of use. +The ``ddgs`` package is an optional dependency. ``is_available()`` reflects +whether the package is importable; the plugin still registers either way so +``hermes tools`` can prompt the user to install it. """ from __future__ import annotations @@ -30,39 +15,49 @@ import logging from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -class DDGSSearchProvider(WebSearchProvider): - """Search via the ``ddgs`` package (DuckDuckGo HTML scrape). +class DDGSWebSearchProvider(WebSearchProvider): + """DuckDuckGo HTML-scrape search provider. - No API key required. The provider is considered "configured" when the - ``ddgs`` package is importable — there is nothing else to set up. + No API key needed. Rate limits are enforced server-side by DuckDuckGo; + the provider surfaces ``DuckDuckGoSearchException`` and other ddgs errors + as ``{"success": False, "error": ...}`` rather than raising. """ - def provider_name(self) -> str: + @property + def name(self) -> str: return "ddgs" - def is_configured(self) -> bool: + @property + def display_name(self) -> str: + return "DuckDuckGo (ddgs)" + + def is_available(self) -> bool: """Return True when the ``ddgs`` package is importable. - Called at tool-registration time; must not perform network I/O. + Probes the import once; cheap because Python caches the import. Must + NOT perform network I/O — runs at tool-registration time and on every + ``hermes tools`` paint. """ try: import ddgs # noqa: F401 + return True except ImportError: return False - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a DuckDuckGo search and return normalized results. + def supports_search(self) -> bool: + return True - Returns ``{"success": True, "data": {"web": [...]}}`` on success or - ``{"success": False, "error": str}`` on failure (missing package, - rate-limited, network error, etc.). - """ + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a DuckDuckGo search and return normalized results.""" try: from ddgs import DDGS # type: ignore except ImportError: @@ -96,3 +91,14 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]: logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "DuckDuckGo (ddgs)", + "badge": "free · no key · search only", + "tag": "Search via the ddgs Python package — no API key (pair with any extract provider)", + "env_vars": [], + # Trigger `_run_post_setup("ddgs")` after the user picks this row + # so the ddgs Python package gets pip-installed on first selection. + "post_setup": "ddgs", + } diff --git a/plugins/web/exa/__init__.py b/plugins/web/exa/__init__.py new file mode 100644 index 000000000000..d2ef3f16cf6f --- /dev/null +++ b/plugins/web/exa/__init__.py @@ -0,0 +1,15 @@ +"""Exa web search + extract plugin — bundled, auto-loaded. + +Backed by the official Exa SDK (``exa-py``). Both search and extract are +sync; the dispatcher in :mod:`tools.web_tools` handles the wrap when the +caller is async. +""" + +from __future__ import annotations + +from plugins.web.exa.provider import ExaWebSearchProvider + + +def register(ctx) -> None: + """Register the Exa provider with the plugin context.""" + ctx.register_web_search_provider(ExaWebSearchProvider()) diff --git a/plugins/web/exa/plugin.yaml b/plugins/web/exa/plugin.yaml new file mode 100644 index 000000000000..1eceefb6ac54 --- /dev/null +++ b/plugins/web/exa/plugin.yaml @@ -0,0 +1,7 @@ +name: web-exa +version: 1.0.0 +description: "Exa web search and content extraction. Requires EXA_API_KEY — sign up at https://exa.ai." +author: NousResearch +kind: backend +provides_web_providers: + - exa diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py new file mode 100644 index 000000000000..0fea6fb5a8b7 --- /dev/null +++ b/plugins/web/exa/provider.py @@ -0,0 +1,212 @@ +"""Exa web search + content extraction — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses the +official Exa SDK (``exa-py``) which is lazy-loaded via +:func:`tools.lazy_deps.ensure` so that cold-start CLI users don't pay the +SDK import cost when Exa isn't configured. + +Config keys this provider responds to:: + + web: + search_backend: "exa" # explicit per-capability + extract_backend: "exa" # explicit per-capability + backend: "exa" # shared fallback for both + +Env var:: + + EXA_API_KEY=... # https://exa.ai (paid tier; free trial available) + +The previous in-tree implementation lived at +``tools.web_tools._exa_search`` / ``_exa_extract``; this file is the +canonical replacement. Behavior is bit-for-bit identical aside from the +ABC method-name change. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +# Module-level note: the canonical ``_exa_client`` cache slot lives on +# :mod:`tools.web_tools` so tests that do ``tools.web_tools._exa_client = +# None`` between cases see fresh state. The plugin reads/writes through +# that public module (see :func:`_get_exa_client`). + + +def _get_exa_client() -> Any: + """Lazy-import and cache an Exa SDK client. + + Cache lives on :mod:`tools.web_tools` (as ``_exa_client``) so unit + tests that reset that name between cases keep working. Raises + ``ValueError`` when ``EXA_API_KEY`` is unset. + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_exa_client", None) + if cached is not None: + return cached + + api_key = os.getenv("EXA_API_KEY") + if not api_key: + raise ValueError( + "EXA_API_KEY environment variable not set. " + "Get your API key at https://exa.ai" + ) + + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("search.exa", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints + raise ImportError(str(exc)) + + from exa_py import Exa # noqa: WPS433 — deliberately lazy + + client = Exa(api_key=api_key) + client.headers["x-exa-integration"] = "hermes-agent" + _wt._exa_client = client + return client + + +def _reset_client_for_tests() -> None: + """Drop the cached Exa client so tests can re-instantiate cleanly.""" + import tools.web_tools as _wt + + _wt._exa_client = None + + +class ExaWebSearchProvider(WebSearchProvider): + """Exa search + extract provider. + + Both methods are sync — Exa's SDK is sync-only. The web_extract_tool + dispatcher wraps sync extracts via ``asyncio.to_thread`` when it + needs to keep the event loop responsive. + """ + + @property + def name(self) -> str: + return "exa" + + @property + def display_name(self) -> str: + return "Exa" + + def is_available(self) -> bool: + """Return True when ``EXA_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("EXA_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute an Exa search. + + Returns ``{"success": True, "data": {"web": [{...}, ...]}}`` on + success, ``{"success": False, "error": str}`` on failure (incl. + missing API key and SDK install errors). + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Exa search: '%s' (limit=%d)", query, limit) + response = _get_exa_client().search( + query, + num_results=limit, + contents={"highlights": True}, + ) + + web_results = [] + for i, result in enumerate(response.results or []): + highlights = result.highlights or [] + web_results.append( + { + "url": result.url or "", + "title": result.title or "", + "description": " ".join(highlights) if highlights else "", + "position": i + 1, + } + ) + + return {"success": True, "data": {"web": web_results}} + except ValueError as exc: + # Raised by _get_exa_client when EXA_API_KEY missing + return {"success": False, "error": str(exc)} + except ImportError as exc: + return {"success": False, "error": f"Exa SDK not installed: {exc}"} + except Exception as exc: # noqa: BLE001 — surface as failure + logger.warning("Exa search error: %s", exc) + return {"success": False, "error": f"Exa search failed: {exc}"} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Exa. + + Returns a list of result dicts shaped for the legacy LLM + post-processing pipeline. On per-URL or whole-batch failure, + results carry an ``error`` field rather than raising. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Exa extract: %d URL(s)", len(urls)) + response = _get_exa_client().get_contents(urls, text=True) + + results: List[Dict[str, Any]] = [] + for result in response.results or []: + content = result.text or "" + url = result.url or "" + title = result.title or "" + results.append( + { + "url": url, + "title": title, + "content": content, + "raw_content": content, + "metadata": {"sourceURL": url, "title": title}, + } + ) + return results + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except ImportError as exc: + return [ + {"url": u, "title": "", "content": "", "error": f"Exa SDK not installed: {exc}"} + for u in urls + ] + except Exception as exc: # noqa: BLE001 + logger.warning("Exa extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Exa extract failed: {exc}"} + for u in urls + ] + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Exa", + "badge": "paid", + "tag": "Semantic + neural web search with content extraction.", + "env_vars": [ + { + "key": "EXA_API_KEY", + "prompt": "Exa API key", + "url": "https://exa.ai", + }, + ], + } diff --git a/plugins/web/firecrawl/__init__.py b/plugins/web/firecrawl/__init__.py new file mode 100644 index 000000000000..4cb9dd63d0fc --- /dev/null +++ b/plugins/web/firecrawl/__init__.py @@ -0,0 +1,28 @@ +"""Firecrawl web search + extract plugin — bundled, auto-loaded. + +Largest single plugin in this PR. Captures everything the previous +inline implementation in tools/web_tools.py did: + + - Lazy import of the firecrawl SDK (~200ms cold-start cost) via a + callable proxy that defers the actual import to first use. + - Dual client paths: direct (FIRECRAWL_API_KEY / FIRECRAWL_API_URL) + OR Nous-hosted tool-gateway routing for subscribers, with + web.use_gateway as the tie-breaker. + - Per-URL scrape loop with 60s timeout, SSRF re-check after redirect, + website-policy gating, and format-aware content selection. + - Robust response shape normalization across SDK / direct API / + gateway variants (search returns differ by transport). + +The plugin re-exports ``Firecrawl`` (the lazy proxy) and +``check_firecrawl_api_key`` for backward-compatibility with tests and +external code that imports those names from ``tools.web_tools``. +""" + +from __future__ import annotations + +from plugins.web.firecrawl.provider import FirecrawlWebSearchProvider + + +def register(ctx) -> None: + """Register the Firecrawl provider with the plugin context.""" + ctx.register_web_search_provider(FirecrawlWebSearchProvider()) diff --git a/plugins/web/firecrawl/plugin.yaml b/plugins/web/firecrawl/plugin.yaml new file mode 100644 index 000000000000..063af47d7386 --- /dev/null +++ b/plugins/web/firecrawl/plugin.yaml @@ -0,0 +1,7 @@ +name: web-firecrawl +version: 1.0.0 +description: "Firecrawl web search + content extraction. Supports direct API and Nous-hosted tool-gateway routing for subscribers. Requires FIRECRAWL_API_KEY (or FIRECRAWL_API_URL for self-hosted), or an active Nous subscription with FIRECRAWL_GATEWAY_URL." +author: NousResearch +kind: backend +provides_web_providers: + - firecrawl diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py new file mode 100644 index 000000000000..bcc574ffca39 --- /dev/null +++ b/plugins/web/firecrawl/provider.py @@ -0,0 +1,773 @@ +"""Firecrawl web search + extract — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. This is +the largest provider migrated in this PR; it captures the full inline +firecrawl implementation that previously lived in tools/web_tools.py: + + - :data:`Firecrawl` lazy proxy that defers the ~200ms SDK import to + first use (re-exported by tools.web_tools for backward compat with + existing tests that mock that name). + - :func:`_get_firecrawl_client` with direct + managed-gateway dual + mode, controlled by ``web.use_gateway`` config when both are + configured. + - :func:`check_firecrawl_api_key` re-exported (tests + tools_config + setup hint depend on this name living in tools.web_tools). + - :func:`_extract_web_search_results` / :func:`_extract_scrape_payload` + response-shape normalizers that handle SDK / direct API / gateway + response variants. + - Per-URL extract loop with 60s timeout, redirect-aware SSRF re-check, + website-policy gating, and format-aware content selection. + +Async note: the underlying SDK is sync. ``extract()`` is declared +``async def`` because it performs per-URL I/O that benefits from +running in an executor; the implementation wraps each scrape in +:func:`asyncio.to_thread` with :func:`asyncio.wait_for(timeout=60)` to +guard against hung fetches. + +Config keys this provider responds to:: + + web: + search_backend: "firecrawl" # explicit per-capability + extract_backend: "firecrawl" # explicit per-capability + backend: "firecrawl" # shared fallback (default) + use_gateway: false # prefer managed gateway when both + # direct + gateway credentials exist + +Env vars:: + + FIRECRAWL_API_KEY=... # direct cloud auth + FIRECRAWL_API_URL=... # self-hosted Firecrawl + FIRECRAWL_GATEWAY_URL=... # Nous tool-gateway (subscribers) + TOOL_GATEWAY_DOMAIN=... # alternate gateway env + TOOL_GATEWAY_SCHEME=... + TOOL_GATEWAY_USER_TOKEN=... +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from agent.web_search_provider import WebSearchProvider +from tools.website_policy import check_website_access + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Lazy Firecrawl SDK proxy +# --------------------------------------------------------------------------- +# The firecrawl SDK pulls ~200ms of imports (httpcore, firecrawl.v1/v2 type +# trees) on a cold CLI. We only need it when the backend is actually +# "firecrawl", so defer the import to first use via a callable proxy. +# +# Tests that do ``patch("tools.web_tools.Firecrawl", ...)`` continue to +# work because tools/web_tools.py re-exports ``Firecrawl`` from this +# module — so the patched name still references the same proxy instance. + +if TYPE_CHECKING: + from firecrawl import Firecrawl as FirecrawlSDK # noqa: F401 — type hints only + +_FIRECRAWL_CLS_CACHE: Optional[type] = None + + +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 exc: # noqa: BLE001 — surface install hint + raise ImportError(str(exc)) + from firecrawl import Firecrawl as _cls # noqa: WPS433 — deliberately lazy + + _FIRECRAWL_CLS_CACHE = _cls + return _FIRECRAWL_CLS_CACHE + + +class _FirecrawlProxy: + """Callable proxy that looks like ``firecrawl.Firecrawl`` but imports lazily.""" + + __slots__ = () + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return _load_firecrawl_cls()(*args, **kwargs) + + def __instancecheck__(self, obj: Any) -> bool: + return isinstance(obj, _load_firecrawl_cls()) + + def __repr__(self) -> str: + return "" + + +Firecrawl = _FirecrawlProxy() + + +# --------------------------------------------------------------------------- +# Client construction (direct vs managed-gateway) +# --------------------------------------------------------------------------- +# +# The canonical cache slots live on :mod:`tools.web_tools` so tests that do +# ``tools.web_tools._firecrawl_client = None`` between cases see fresh +# state. The plugin reads/writes through that public module — see +# :func:`_get_firecrawl_client` below. + + +def _get_direct_firecrawl_config() -> Optional[tuple]: + """Return explicit direct Firecrawl kwargs + cache key, or None when unset.""" + api_key = os.getenv("FIRECRAWL_API_KEY", "").strip() + api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/") + + if not api_key and not api_url: + return None + + kwargs: Dict[str, str] = {} + if api_key: + kwargs["api_key"] = api_key + if api_url: + kwargs["api_url"] = api_url + + return kwargs, ("direct", api_url or None, api_key or None) + + +def _get_firecrawl_gateway_url() -> str: + """Return the configured Firecrawl gateway URL.""" + import tools.web_tools as _wt + + return _wt.build_vendor_gateway_url("firecrawl") + + +def _is_tool_gateway_ready() -> bool: + """Return True when gateway URL + Nous Subscriber token are available. + + Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway`` + via :mod:`tools.web_tools` rather than direct imports, so unit tests + that ``patch("tools.web_tools._read_nous_access_token", ...)`` see + their patches honored. The names are re-exported on + :mod:`tools.web_tools` for exactly this reason. + """ + import tools.web_tools as _wt + + return _wt.resolve_managed_tool_gateway( + "firecrawl", token_reader=_wt._read_nous_access_token + ) is not None + + +def _has_direct_firecrawl_config() -> bool: + """Return True when direct Firecrawl config is explicitly configured.""" + return _get_direct_firecrawl_config() is not None + + +def check_firecrawl_api_key() -> bool: + """Return True when Firecrawl backend (direct or gateway) is usable. + + Re-exported by :mod:`tools.web_tools` for backward compatibility with + existing tests and the ``hermes tools`` setup flow. + """ + return _has_direct_firecrawl_config() or _is_tool_gateway_ready() + + +def _firecrawl_backend_help_suffix() -> str: + """Return optional managed-gateway guidance for Firecrawl help text.""" + import tools.web_tools as _wt + + if not _wt.managed_nous_tools_enabled(): + return "" + return ( + ", or use the Nous Tool Gateway via your subscription " + "(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)" + ) + + +def _raise_web_backend_configuration_error() -> None: + """Raise a clear error for unsupported web backend configuration.""" + import tools.web_tools as _wt + + message = ( + "Web tools are not configured. " + "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL " + "for a self-hosted Firecrawl instance." + ) + if _wt.managed_nous_tools_enabled(): + message += ( + " With your Nous subscription you can also use the Tool Gateway — " + "run `hermes tools` and select Nous Subscription as the web provider." + ) + raise ValueError(message) + + +def _get_firecrawl_client() -> Any: + """Get or create the cached Firecrawl client. + + When ``web.use_gateway`` is set in config, the managed Tool Gateway is + preferred even if direct Firecrawl credentials are present. Otherwise + direct Firecrawl takes precedence when explicitly configured. + + Raises ValueError when neither path is usable. + + The cached client is stored on :mod:`tools.web_tools` (as + ``_firecrawl_client`` and ``_firecrawl_client_config``) rather than on + this plugin module so that unit tests that reset the cache via + ``tools.web_tools._firecrawl_client = None`` keep working. Helper + functions (``prefers_gateway``, ``resolve_managed_tool_gateway``, + ``_read_nous_access_token``, ``Firecrawl``) are also looked up via + :mod:`tools.web_tools` for the same reason — see + :func:`_is_tool_gateway_ready`. + """ + import tools.web_tools as _wt + + direct_config = _get_direct_firecrawl_config() + if direct_config is not None and not _wt.prefers_gateway("web"): + kwargs, client_config = direct_config + else: + managed_gateway = _wt.resolve_managed_tool_gateway( + "firecrawl", token_reader=_wt._read_nous_access_token + ) + if managed_gateway is None: + logger.error( + "Firecrawl client initialization failed: " + "missing direct config and tool-gateway auth." + ) + _raise_web_backend_configuration_error() + + kwargs = { + "api_key": managed_gateway.nous_user_token, + "api_url": managed_gateway.gateway_origin, + } + client_config = ( + "tool-gateway", + kwargs["api_url"], + managed_gateway.nous_user_token, + ) + + cached = getattr(_wt, "_firecrawl_client", None) + cached_config = getattr(_wt, "_firecrawl_client_config", None) + if cached is not None and cached_config == client_config: + return cached + + # Construct via the re-exported Firecrawl proxy on tools.web_tools so + # unit tests patching ``tools.web_tools.Firecrawl`` see their mock. + _wt._firecrawl_client = _wt.Firecrawl(**kwargs) + _wt._firecrawl_client_config = client_config + return _wt._firecrawl_client + + +def _reset_client_for_tests() -> None: + """Drop the cached Firecrawl client so tests can re-instantiate cleanly. + + Clears the canonical slots on :mod:`tools.web_tools` (where + :func:`_get_firecrawl_client` reads/writes them). + """ + import tools.web_tools as _wt + + _wt._firecrawl_client = None + _wt._firecrawl_client_config = None + + +# --------------------------------------------------------------------------- +# Response shape normalization (SDK / direct / gateway differ) +# --------------------------------------------------------------------------- + + +def _to_plain_object(value: Any) -> Any: + """Convert SDK objects to plain python data structures when possible.""" + if value is None: + return None + + if isinstance(value, (dict, list, str, int, float, bool)): + return value + + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: # noqa: BLE001 + pass + + if hasattr(value, "__dict__"): + try: + return {k: v for k, v in value.__dict__.items() if not k.startswith("_")} + except Exception: # noqa: BLE001 + pass + + return value + + +def _normalize_result_list(values: Any) -> List[Dict[str, Any]]: + """Normalize mixed SDK/list payloads into a list of dicts.""" + if not isinstance(values, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for item in values: + plain = _to_plain_object(item) + if isinstance(plain, dict): + normalized.append(plain) + return normalized + + +def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]: + """Extract Firecrawl search results across SDK/direct/gateway response shapes.""" + response_plain = _to_plain_object(response) + + if isinstance(response_plain, dict): + data = response_plain.get("data") + if isinstance(data, list): + return _normalize_result_list(data) + + if isinstance(data, dict): + data_web = _normalize_result_list(data.get("web")) + if data_web: + return data_web + data_results = _normalize_result_list(data.get("results")) + if data_results: + return data_results + + top_web = _normalize_result_list(response_plain.get("web")) + if top_web: + return top_web + + top_results = _normalize_result_list(response_plain.get("results")) + if top_results: + return top_results + + if hasattr(response, "web"): + return _normalize_result_list(getattr(response, "web", [])) + + return [] + + +def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]: + """Normalize Firecrawl scrape payload shape across SDK and gateway variants.""" + result_plain = _to_plain_object(scrape_result) + if not isinstance(result_plain, dict): + return {} + + nested = result_plain.get("data") + if isinstance(nested, dict): + return nested + + return result_plain + + +# --------------------------------------------------------------------------- +# Provider class +# --------------------------------------------------------------------------- + + +class FirecrawlWebSearchProvider(WebSearchProvider): + """Firecrawl search + extract provider with dual auth paths.""" + + @property + def name(self) -> str: + return "firecrawl" + + @property + def display_name(self) -> str: + return "Firecrawl" + + def is_available(self) -> bool: + """Return True when direct Firecrawl OR managed-gateway path is configured.""" + return check_firecrawl_api_key() + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Firecrawl search. + + Sync; matches the legacy ``_get_firecrawl_client().search(...)`` + call directly. Normalizes the response across SDK/direct/gateway + shapes via :func:`_extract_web_search_results`. + + Pre-flight errors (``ValueError`` from configuration check, + ``ImportError`` from missing SDK) propagate to the dispatcher's + top-level handler, which wraps them as ``tool_error(...)`` — + matching the legacy ``{"error": "Error searching web: ..."}`` + envelope. Only in-flight errors are caught and surfaced as + ``{"success": False, "error": ...}``. + """ + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Firecrawl search: '%s' (limit=%d)", query, limit) + # _get_firecrawl_client() raises ValueError on unconfigured systems — + # let it propagate so the dispatcher emits the legacy envelope shape. + client = _get_firecrawl_client() + try: + response = client.search(query=query, limit=limit) + web_results = _extract_web_search_results(response) + logger.info("Firecrawl: found %d search results", len(web_results)) + return {"success": True, "data": {"web": web_results}} + except Exception as exc: # noqa: BLE001 + logger.warning("Firecrawl search error: %s", exc) + return {"success": False, "error": f"Firecrawl search failed: {exc}"} + + async def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Firecrawl. + + Async; each URL is scraped in a background thread with a 60s + timeout. After scraping, the final URL (post-redirect) is + re-checked against website-access policy. + + Accepted kwargs (others ignored for forward compat): + - ``format``: ``"markdown"`` or ``"html"``; default is both + (request both, return markdown when available). + + Returns the legacy per-URL list-of-results shape. Per-URL failures + (timeout, SSRF block, scrape error, policy block) become items + with an ``error`` field rather than raising. + """ + from tools.interrupt import is_interrupted as _is_interrupted + + if _is_interrupted(): + return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] + + format = kwargs.get("format") + formats: List[str] = [] + if format == "markdown": + formats = ["markdown"] + elif format == "html": + formats = ["html"] + else: + formats = ["markdown", "html"] + + # check_website_access is the legacy policy gate; imported at + # module level (lazy-friendly because the website_policy import is + # cheap) so monkeypatching it in tests works as expected. + + results: List[Dict[str, Any]] = [] + + for url in urls: + if _is_interrupted(): + results.append({"url": url, "error": "Interrupted", "title": ""}) + continue + + # Pre-scrape website policy gate + blocked = check_website_access(url) + if blocked: + logger.info( + "Blocked web_extract for %s by rule %s", + blocked["host"], + blocked["rule"], + ) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": blocked["message"], + "blocked_by_policy": { + "host": blocked["host"], + "rule": blocked["rule"], + "source": blocked["source"], + }, + } + ) + continue + + try: + logger.info("Firecrawl scraping: %s", url) + try: + scrape_result = await asyncio.wait_for( + asyncio.to_thread( + _get_firecrawl_client().scrape, + url=url, + formats=formats, + ), + timeout=60, + ) + except asyncio.TimeoutError: + logger.warning("Firecrawl scrape timed out for %s", url) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": ( + "Scrape timed out after 60s — page may be too large " + "or unresponsive. Try browser_navigate instead." + ), + } + ) + continue + + scrape_payload = _extract_scrape_payload(scrape_result) + metadata = scrape_payload.get("metadata", {}) + content_markdown = scrape_payload.get("markdown") + content_html = scrape_payload.get("html") + + # Ensure metadata is a dict (SDK may return a typed object) + if not isinstance(metadata, dict): + if hasattr(metadata, "model_dump"): + metadata = metadata.model_dump() + elif hasattr(metadata, "__dict__"): + metadata = metadata.__dict__ + else: + metadata = {} + + title = metadata.get("title", "") + final_url = metadata.get("sourceURL", url) + + # Re-check website-access policy after any redirect + final_blocked = check_website_access(final_url) + if final_blocked: + logger.info( + "Blocked redirected web_extract for %s by rule %s", + final_blocked["host"], + final_blocked["rule"], + ) + results.append( + { + "url": final_url, + "title": title, + "content": "", + "raw_content": "", + "error": final_blocked["message"], + "blocked_by_policy": { + "host": final_blocked["host"], + "rule": final_blocked["rule"], + "source": final_blocked["source"], + }, + } + ) + continue + + # Choose markdown vs html according to the requested format + if format == "markdown" or (format is None and content_markdown): + chosen_content = content_markdown + else: + chosen_content = content_html or content_markdown or "" + + results.append( + { + "url": final_url, + "title": title, + "content": chosen_content, + "raw_content": chosen_content, + "metadata": metadata, + } + ) + except Exception as scrape_err: # noqa: BLE001 + logger.debug("Firecrawl scrape failed for %s: %s", url, scrape_err) + results.append( + { + "url": url, + "title": "", + "content": "", + "raw_content": "", + "error": str(scrape_err), + } + ) + + return results + + async def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + """Crawl a seed URL via Firecrawl's ``/crawl`` endpoint. + + Sync SDK call wrapped in ``asyncio.to_thread`` because the dispatcher + in :func:`tools.web_tools.web_crawl_tool` is async and runs LLM + post-processing on the response. The dispatcher gates the seed URL + against SSRF + website-access policy before calling us; this method + re-checks every crawled page's URL against the policy after the + crawl returns to catch redirected pages that map to a blocked host. + + Accepted kwargs (others ignored for forward compat): + - ``instructions``: str — logged then dropped. Firecrawl's /crawl + endpoint does NOT accept natural-language instructions (that's + an /extract feature), so we record the value for debugging and + proceed without it. Tavily's crawl IS instruction-aware; this + divergence is documented in both plugins' docstrings. + - ``limit``: int — max pages to crawl (default 20). + - ``depth``: str — accepted for API parity with Tavily; ignored + by Firecrawl's crawl endpoint. + + Returns ``{"results": [...]}`` matching the shape that + :func:`tools.web_tools.web_crawl_tool`'s shared LLM-summarization + path expects. Per-page failures (policy block on redirected URL, + bad response shape) are included as items with an ``error`` field + rather than raising. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"results": [{"url": url, "title": "", "content": "", "error": "Interrupted"}]} + + instructions = kwargs.get("instructions") + limit = kwargs.get("limit", 20) + + # Firecrawl's /crawl endpoint does not accept natural-language + # instructions (that's an /extract feature). Log + drop. + if instructions: + logger.info( + "Firecrawl crawl: 'instructions' parameter ignored " + "(not supported by Firecrawl /crawl)" + ) + + logger.info("Firecrawl crawl: %s (limit=%d)", url, limit) + + crawl_params = { + "limit": limit, + "scrape_options": {"formats": ["markdown"]}, + } + + # The SDK call is sync; run in a thread so we don't block the + # gateway event loop on a multi-page crawl. + crawl_result = await asyncio.to_thread( + _get_firecrawl_client().crawl, + url=url, + **crawl_params, + ) + + # CrawlJob normalization across SDK + direct + gateway shapes. + data_list: List[Any] = [] + if hasattr(crawl_result, "data"): + data_list = crawl_result.data if crawl_result.data else [] + logger.info( + "Firecrawl crawl status: %s, %d pages", + getattr(crawl_result, "status", "unknown"), + len(data_list), + ) + elif isinstance(crawl_result, dict) and "data" in crawl_result: + data_list = crawl_result.get("data", []) or [] + else: + logger.warning( + "Firecrawl crawl: unexpected result type %r", + type(crawl_result).__name__, + ) + + pages: List[Dict[str, Any]] = [] + for item in data_list: + # Pydantic model | typed object | dict — handle all shapes. + content_markdown = None + content_html = None + metadata: Any = {} + + if hasattr(item, "model_dump"): + item_dict = item.model_dump() + content_markdown = item_dict.get("markdown") + content_html = item_dict.get("html") + metadata = item_dict.get("metadata", {}) + elif hasattr(item, "__dict__"): + content_markdown = getattr(item, "markdown", None) + content_html = getattr(item, "html", None) + metadata_obj = getattr(item, "metadata", {}) + if hasattr(metadata_obj, "model_dump"): + metadata = metadata_obj.model_dump() + elif hasattr(metadata_obj, "__dict__"): + metadata = metadata_obj.__dict__ + elif isinstance(metadata_obj, dict): + metadata = metadata_obj + else: + metadata = {} + elif isinstance(item, dict): + content_markdown = item.get("markdown") + content_html = item.get("html") + metadata = item.get("metadata", {}) + + # Ensure metadata is a plain dict. + if not isinstance(metadata, dict): + if hasattr(metadata, "model_dump"): + metadata = metadata.model_dump() + elif hasattr(metadata, "__dict__"): + metadata = metadata.__dict__ + else: + metadata = {} + + page_url = metadata.get( + "sourceURL", metadata.get("url", "Unknown URL") + ) + title = metadata.get("title", "") + + # Per-page policy re-check (catches blocked redirects). + page_blocked = check_website_access(page_url) + if page_blocked: + logger.info( + "Blocked crawled page %s by rule %s", + page_blocked["host"], + page_blocked["rule"], + ) + pages.append( + { + "url": page_url, + "title": title, + "content": "", + "raw_content": "", + "error": page_blocked["message"], + "blocked_by_policy": { + "host": page_blocked["host"], + "rule": page_blocked["rule"], + "source": page_blocked["source"], + }, + } + ) + continue + + content = content_markdown or content_html or "" + pages.append( + { + "url": page_url, + "title": title, + "content": content, + "raw_content": content, + "metadata": metadata, + } + ) + + return {"results": pages} + except ValueError as exc: + return {"results": [{"url": url, "title": "", "content": "", "error": str(exc)}]} + except ImportError as exc: + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Firecrawl SDK not installed: {exc}", + } + ] + } + except Exception as exc: # noqa: BLE001 + logger.warning("Firecrawl crawl error: %s", exc) + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Firecrawl crawl failed: {exc}", + } + ] + } + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Firecrawl", + "badge": "paid · optional gateway", + "tag": ( + "Full search + extract + crawl; supports direct API and " + "Nous tool-gateway routing." + ), + "env_vars": [ + { + "key": "FIRECRAWL_API_KEY", + "prompt": "Firecrawl API key (or leave blank for self-hosted)", + "url": "https://docs.firecrawl.dev/introduction", + }, + ], + } diff --git a/plugins/web/parallel/__init__.py b/plugins/web/parallel/__init__.py new file mode 100644 index 000000000000..2a109894dc55 --- /dev/null +++ b/plugins/web/parallel/__init__.py @@ -0,0 +1,16 @@ +"""Parallel.ai web search + extract plugin — bundled, auto-loaded. + +First plugin in this repo to expose an async :meth:`extract` — Parallel's +SDK is async-native (``AsyncParallel.beta.extract``). The web_extract_tool +dispatcher detects coroutines via :func:`inspect.iscoroutinefunction` and +awaits. +""" + +from __future__ import annotations + +from plugins.web.parallel.provider import ParallelWebSearchProvider + + +def register(ctx) -> None: + """Register the Parallel provider with the plugin context.""" + ctx.register_web_search_provider(ParallelWebSearchProvider()) diff --git a/plugins/web/parallel/plugin.yaml b/plugins/web/parallel/plugin.yaml new file mode 100644 index 000000000000..01bf0da58eff --- /dev/null +++ b/plugins/web/parallel/plugin.yaml @@ -0,0 +1,7 @@ +name: web-parallel +version: 1.0.0 +description: "Parallel.ai web search + content extraction. Search returns objective-tuned results; extract uses the async SDK for parallel page fetches. Requires PARALLEL_API_KEY — sign up at https://parallel.ai." +author: NousResearch +kind: backend +provides_web_providers: + - parallel diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py new file mode 100644 index 000000000000..38578e6b52c3 --- /dev/null +++ b/plugins/web/parallel/provider.py @@ -0,0 +1,291 @@ +"""Parallel.ai web search + content extraction — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses two +distinct Parallel SDK clients: + +- ``Parallel`` (sync) — for :meth:`search` +- ``AsyncParallel`` (async) — for :meth:`extract` + +This is the first plugin to exercise the **async-extract** code path in +the ABC: :meth:`extract` is declared ``async def``, and the dispatcher +in :func:`tools.web_tools.web_extract_tool` detects coroutines via +:func:`inspect.iscoroutinefunction` and awaits. + +Config keys this provider responds to:: + + web: + search_backend: "parallel" # explicit per-capability + extract_backend: "parallel" # explicit per-capability + backend: "parallel" # shared fallback + # Optional: search mode (default "agentic"; also "fast" or "one-shot") + # via the PARALLEL_SEARCH_MODE env var. + +Env vars:: + + PARALLEL_API_KEY=... # https://parallel.ai (required) + PARALLEL_SEARCH_MODE=agentic # optional: agentic|fast|one-shot +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +# Module-level note: the canonical cache slots ``_parallel_client`` and +# ``_async_parallel_client`` live on :mod:`tools.web_tools` so tests that do +# ``tools.web_tools._parallel_client = None`` between cases see fresh state. +# The plugin reads/writes through that public module (see +# :func:`_get_sync_client` / :func:`_get_async_client`). + + +def _ensure_parallel_sdk_installed() -> None: + """Trigger lazy install of the parallel SDK if it isn't present. + + Mirrors the lazy-deps pattern used by the legacy implementation. + Swallows benign ImportError from the lazy_deps helper itself; if the + SDK is genuinely missing the subsequent ``from parallel import ...`` + raises ImportError that the caller can handle. + """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 — surface install hint as ImportError + raise ImportError(str(exc)) + + +def _get_sync_client() -> Any: + """Lazy-load + cache the sync Parallel client. + + Cache lives on :mod:`tools.web_tools` (as ``_parallel_client``) so unit + tests that reset that name between cases keep working. + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_parallel_client", None) + if cached is not None: + return cached + + api_key = os.getenv("PARALLEL_API_KEY") + if not api_key: + raise ValueError( + "PARALLEL_API_KEY environment variable not set. " + "Get your API key at https://parallel.ai" + ) + + _ensure_parallel_sdk_installed() + from parallel import Parallel # noqa: WPS433 — deliberately lazy + + client = Parallel(api_key=api_key) + _wt._parallel_client = client + return client + + +def _get_async_client() -> Any: + """Lazy-load + cache the async Parallel client. + + Cache lives on :mod:`tools.web_tools` (as ``_async_parallel_client``). + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_async_parallel_client", None) + if cached is not None: + return cached + + api_key = os.getenv("PARALLEL_API_KEY") + if not api_key: + raise ValueError( + "PARALLEL_API_KEY environment variable not set. " + "Get your API key at https://parallel.ai" + ) + + _ensure_parallel_sdk_installed() + from parallel import AsyncParallel # noqa: WPS433 — deliberately lazy + + client = AsyncParallel(api_key=api_key) + _wt._async_parallel_client = client + return client + + +def _reset_clients_for_tests() -> None: + """Drop both cached clients so tests can re-instantiate cleanly. + + Clears the canonical slots on :mod:`tools.web_tools` (where + :func:`_get_sync_client` / :func:`_get_async_client` read/write them). + """ + import tools.web_tools as _wt + + _wt._parallel_client = None + _wt._async_parallel_client = None + + +# Backward-compatible aliases for the names that lived in tools.web_tools +# before the migration (matches existing tests + external callers). +_get_parallel_client = _get_sync_client +_get_async_parallel_client = _get_async_client + + +def _resolve_search_mode() -> str: + """Return the validated PARALLEL_SEARCH_MODE value (default "agentic").""" + mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() + if mode not in {"fast", "one-shot", "agentic"}: + mode = "agentic" + return mode + + +class ParallelWebSearchProvider(WebSearchProvider): + """Parallel.ai search + async extract provider.""" + + @property + def name(self) -> str: + return "parallel" + + @property + def display_name(self) -> str: + return "Parallel" + + def is_available(self) -> bool: + """Return True when ``PARALLEL_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("PARALLEL_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Parallel search (sync). + + Uses the ``beta.search`` endpoint with the configured mode + (``PARALLEL_SEARCH_MODE`` env var, default "agentic"). Limit is + capped at 20 server-side. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + mode = _resolve_search_mode() + logger.info( + "Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit + ) + response = _get_sync_client().beta.search( + search_queries=[query], + objective=query, + mode=mode, + max_results=min(limit, 20), + ) + + web_results = [] + for i, result in enumerate(response.results or []): + excerpts = result.excerpts or [] + web_results.append( + { + "url": result.url or "", + "title": result.title or "", + "description": " ".join(excerpts) if excerpts else "", + "position": i + 1, + } + ) + + return {"success": True, "data": {"web": web_results}} + except ValueError as exc: + return {"success": False, "error": str(exc)} + except ImportError as exc: + return { + "success": False, + "error": f"Parallel SDK not installed: {exc}", + } + except Exception as exc: # noqa: BLE001 + logger.warning("Parallel search error: %s", exc) + return {"success": False, "error": f"Parallel search failed: {exc}"} + + async def extract( + self, urls: List[str], **kwargs: Any + ) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via the async SDK. + + Returns the legacy list-of-results shape that + :func:`tools.web_tools.web_extract_tool` expects: one entry per + successful URL plus one entry per failed URL with an ``error`` + field. Errors are not raised — they're returned as per-URL items. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Parallel extract: %d URL(s)", len(urls)) + response = await _get_async_client().beta.extract( + urls=urls, + full_content=True, + ) + + results: List[Dict[str, Any]] = [] + for result in response.results or []: + content = result.full_content or "" + if not content: + content = "\n\n".join(result.excerpts or []) + url = result.url or "" + title = result.title or "" + results.append( + { + "url": url, + "title": title, + "content": content, + "raw_content": content, + "metadata": {"sourceURL": url, "title": title}, + } + ) + + for error in response.errors or []: + results.append( + { + "url": error.url or "", + "title": "", + "content": "", + "error": error.content or error.error_type or "extraction failed", + "metadata": {"sourceURL": error.url or ""}, + } + ) + + return results + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except ImportError as exc: + return [ + {"url": u, "title": "", "content": "", "error": f"Parallel SDK not installed: {exc}"} + for u in urls + ] + except Exception as exc: # noqa: BLE001 + logger.warning("Parallel extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Parallel extract failed: {exc}"} + for u in urls + ] + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Parallel", + "badge": "paid", + "tag": "Objective-tuned search + parallel page extraction.", + "env_vars": [ + { + "key": "PARALLEL_API_KEY", + "prompt": "Parallel API key", + "url": "https://parallel.ai", + }, + ], + } diff --git a/plugins/web/searxng/__init__.py b/plugins/web/searxng/__init__.py new file mode 100644 index 000000000000..cea8eabb18ed --- /dev/null +++ b/plugins/web/searxng/__init__.py @@ -0,0 +1,15 @@ +"""SearXNG search plugin — bundled, auto-loaded. + +Backed by a user-hosted SearXNG instance (URL configured via ``SEARXNG_URL``). +Search-only — pair with an extract provider (firecrawl/tavily/exa) for +``web_extract`` calls. +""" + +from __future__ import annotations + +from plugins.web.searxng.provider import SearXNGWebSearchProvider + + +def register(ctx) -> None: + """Register the SearXNG provider with the plugin context.""" + ctx.register_web_search_provider(SearXNGWebSearchProvider()) diff --git a/plugins/web/searxng/plugin.yaml b/plugins/web/searxng/plugin.yaml new file mode 100644 index 000000000000..3d758bad58bd --- /dev/null +++ b/plugins/web/searxng/plugin.yaml @@ -0,0 +1,7 @@ +name: web-searxng +version: 1.0.0 +description: "SearXNG web search — free, self-hosted, privacy-respecting metasearch engine. Requires SEARXNG_URL pointing at your instance." +author: NousResearch +kind: backend +provides_web_providers: + - searxng diff --git a/tools/web_providers/searxng.py b/plugins/web/searxng/provider.py similarity index 51% rename from tools/web_providers/searxng.py rename to plugins/web/searxng/provider.py index 589b0a2b337d..043f6711c1b2 100644 --- a/tools/web_providers/searxng.py +++ b/plugins/web/searxng/provider.py @@ -1,22 +1,23 @@ -"""SearXNG web search provider. +"""SearXNG search — plugin form. -SearXNG is a free, self-hosted, privacy-respecting metasearch engine. -It implements ``WebSearchProvider`` only — there is no extract capability. +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Same JSON +API call (``/search?format=json``), same result normalization. The legacy +in-tree module ``tools.web_providers.searxng`` was removed in the same +commit that moved this code under ``plugins/``; this file is now the +canonical implementation. -Configuration:: +Search-only — SearXNG aggregates results from upstream engines but does not +fetch/extract arbitrary URLs. ``supports_extract()`` returns False. - # ~/.hermes/.env - SEARXNG_URL=http://localhost:8080 +Config keys this provider responds to:: - # Use SearXNG for search, pair with any extract provider: - # ~/.hermes/config.yaml web: - search_backend: "searxng" - extract_backend: "firecrawl" + search_backend: "searxng" # explicit per-capability + backend: "searxng" # shared fallback + +Env var:: -Public SearXNG instances are listed at https://searx.space/ but self-hosting -is recommended for production use (rate limits and availability vary per -public instance). + SEARXNG_URL=http://localhost:8080 """ from __future__ import annotations @@ -25,50 +26,34 @@ import os from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -class SearXNGSearchProvider(WebSearchProvider): - """Search via a SearXNG instance. - - Requires ``SEARXNG_URL`` to be set (e.g. ``http://localhost:8080``). - No API key needed — SearXNG is open-source and self-hosted. +class SearXNGWebSearchProvider(WebSearchProvider): + """Search via a user-hosted SearXNG instance.""" - Uses the SearXNG JSON API (``/search?format=json``). Results are - sorted by SearXNG's own score and truncated to *limit*. - """ - - def provider_name(self) -> str: + @property + def name(self) -> str: return "searxng" - def is_configured(self) -> bool: - """Return True when ``SEARXNG_URL`` is set to a non-empty value.""" - return bool(os.getenv("SEARXNG_URL", "").strip()) + @property + def display_name(self) -> str: + return "SearXNG" - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a search against the configured SearXNG instance. + def is_available(self) -> bool: + """Return True when ``SEARXNG_URL`` is set.""" + return bool(os.getenv("SEARXNG_URL", "").strip()) - Returns normalized results:: + def supports_search(self) -> bool: + return True - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } + def supports_extract(self) -> bool: + return False - On failure returns ``{"success": False, "error": str}``. - """ + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a search against the configured SearXNG instance.""" import httpx base_url = os.getenv("SEARXNG_URL", "").strip().rstrip("/") @@ -91,16 +76,25 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]: resp.raise_for_status() except httpx.HTTPStatusError as exc: logger.warning("SearXNG HTTP error: %s", exc) - return {"success": False, "error": f"SearXNG returned HTTP {exc.response.status_code}"} + return { + "success": False, + "error": f"SearXNG returned HTTP {exc.response.status_code}", + } except httpx.RequestError as exc: logger.warning("SearXNG request error: %s", exc) - return {"success": False, "error": f"Could not reach SearXNG at {base_url}: {exc}"} + return { + "success": False, + "error": f"Could not reach SearXNG at {base_url}: {exc}", + } try: data = resp.json() except Exception as exc: # noqa: BLE001 logger.warning("SearXNG response parse error: %s", exc) - return {"success": False, "error": "Could not parse SearXNG response as JSON"} + return { + "success": False, + "error": "Could not parse SearXNG response as JSON", + } raw_results = data.get("results", []) @@ -130,3 +124,17 @@ def search(self, query: str, limit: int = 5) -> Dict[str, Any]: ) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "SearXNG", + "badge": "free · self-hosted", + "tag": "Free, privacy-respecting metasearch. Point SEARXNG_URL at your instance.", + "env_vars": [ + { + "key": "SEARXNG_URL", + "prompt": "SearXNG instance URL (e.g. http://localhost:8080)", + "url": "https://searx.space/", + }, + ], + } diff --git a/plugins/web/tavily/__init__.py b/plugins/web/tavily/__init__.py new file mode 100644 index 000000000000..be0b21dbe781 --- /dev/null +++ b/plugins/web/tavily/__init__.py @@ -0,0 +1,15 @@ +"""Tavily web search + extract + crawl plugin — bundled, auto-loaded. + +First plugin in this codebase to advertise ``supports_crawl=True``. The +crawl method maps to Tavily's ``/crawl`` endpoint, which accepts a seed +URL plus optional instructions and extract depth. +""" + +from __future__ import annotations + +from plugins.web.tavily.provider import TavilyWebSearchProvider + + +def register(ctx) -> None: + """Register the Tavily provider with the plugin context.""" + ctx.register_web_search_provider(TavilyWebSearchProvider()) diff --git a/plugins/web/tavily/plugin.yaml b/plugins/web/tavily/plugin.yaml new file mode 100644 index 000000000000..7eb1e9fc4561 --- /dev/null +++ b/plugins/web/tavily/plugin.yaml @@ -0,0 +1,7 @@ +name: web-tavily +version: 1.0.0 +description: "Tavily web search + content extraction + crawl. Search + extract are mainstream; crawl is unique to Tavily among built-in providers. Requires TAVILY_API_KEY — sign up at https://app.tavily.com/home." +author: NousResearch +kind: backend +provides_web_providers: + - tavily diff --git a/plugins/web/tavily/provider.py b/plugins/web/tavily/provider.py new file mode 100644 index 000000000000..50e15973fb30 --- /dev/null +++ b/plugins/web/tavily/provider.py @@ -0,0 +1,285 @@ +"""Tavily web search + content extraction + crawl — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Three +capabilities advertised: + +- ``supports_search()`` -> True (Tavily ``/search``) +- ``supports_extract()`` -> True (Tavily ``/extract``) +- ``supports_crawl()`` -> True (Tavily ``/crawl``) — sync HTTP crawl; + Firecrawl also advertises ``supports_crawl=True`` (async) + +All three are sync — the underlying call is ``httpx.post(...)``. The +dispatcher in :func:`tools.web_tools.web_crawl_tool` (which is itself +async) will run sync providers in a thread when appropriate. + +Config keys this provider responds to:: + + web: + search_backend: "tavily" # explicit per-capability + extract_backend: "tavily" # explicit per-capability + crawl_backend: "tavily" # explicit per-capability + backend: "tavily" # shared fallback for all three + +Env vars:: + + TAVILY_API_KEY=... # https://app.tavily.com/home (required) + TAVILY_BASE_URL=... # optional override of https://api.tavily.com + +Auth note: Tavily uses ``api_key`` in the JSON body for /search and +/extract, but **also requires** ``Authorization: Bearer `` for /crawl +(body-only auth returns 401 on /crawl). The plugin handles both. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST to the Tavily API and return the parsed JSON response. + + Mirrors :func:`tools.web_tools._tavily_request`. Raises ``ValueError`` + when ``TAVILY_API_KEY`` is unset; the caller catches and surfaces as + a typed error response. + """ + import httpx + + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + raise ValueError( + "TAVILY_API_KEY environment variable not set. " + "Get your API key at https://app.tavily.com/home" + ) + + base_url = os.getenv("TAVILY_BASE_URL", "https://api.tavily.com") + payload = dict(payload) # don't mutate caller's dict + payload["api_key"] = api_key + url = f"{base_url}/{endpoint.lstrip('/')}" + logger.info("Tavily %s request to %s", endpoint, url) + + # Tavily /crawl requires Bearer header auth in addition to body auth; + # /search and /extract are body-only. + headers = {"Authorization": f"Bearer {api_key}"} if endpoint.strip("/") == "crawl" else {} + + response = httpx.post(url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + return response.json() + + +def _normalize_tavily_search_results(response: Dict[str, Any]) -> Dict[str, Any]: + """Map Tavily ``/search`` response to ``{success, data: {web: [...]}}``.""" + web_results = [] + for i, result in enumerate(response.get("results", [])): + web_results.append( + { + "title": result.get("title", ""), + "url": result.get("url", ""), + "description": result.get("content", ""), + "position": i + 1, + } + ) + return {"success": True, "data": {"web": web_results}} + + +def _normalize_tavily_documents( + response: Dict[str, Any], fallback_url: str = "" +) -> List[Dict[str, Any]]: + """Map Tavily ``/extract`` or ``/crawl`` response to standard documents. + + Documents follow the legacy LLM post-processing shape:: + + {"url", "title", "content", "raw_content", "metadata"} + + Failures (``failed_results``, ``failed_urls``) become result entries + with an ``error`` field rather than raising. + """ + documents: List[Dict[str, Any]] = [] + for result in response.get("results", []): + url = result.get("url", fallback_url) + raw = result.get("raw_content", "") or result.get("content", "") + documents.append( + { + "url": url, + "title": result.get("title", ""), + "content": raw, + "raw_content": raw, + "metadata": {"sourceURL": url, "title": result.get("title", "")}, + } + ) + for fail in response.get("failed_results", []): + documents.append( + { + "url": fail.get("url", fallback_url), + "title": "", + "content": "", + "raw_content": "", + "error": fail.get("error", "extraction failed"), + "metadata": {"sourceURL": fail.get("url", fallback_url)}, + } + ) + for fail_url in response.get("failed_urls", []): + url_str = fail_url if isinstance(fail_url, str) else str(fail_url) + documents.append( + { + "url": url_str, + "title": "", + "content": "", + "raw_content": "", + "error": "extraction failed", + "metadata": {"sourceURL": url_str}, + } + ) + return documents + + +class TavilyWebSearchProvider(WebSearchProvider): + """Tavily search + extract + crawl provider.""" + + @property + def name(self) -> str: + return "tavily" + + @property + def display_name(self) -> str: + return "Tavily" + + def is_available(self) -> bool: + """Return True when ``TAVILY_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("TAVILY_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Tavily search.""" + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Tavily search: '%s' (limit=%d)", query, limit) + raw = _tavily_request( + "search", + { + "query": query, + "max_results": min(limit, 20), + "include_raw_content": False, + "include_images": False, + }, + ) + return _normalize_tavily_search_results(raw) + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: # noqa: BLE001 — including httpx errors + logger.warning("Tavily search error: %s", exc) + return {"success": False, "error": f"Tavily search failed: {exc}"} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Tavily. + + Sync — the underlying call is httpx.post(...). Returns the legacy + list-of-results shape; per-URL failures become items with ``error``. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Tavily extract: %d URL(s)", len(urls)) + raw = _tavily_request( + "extract", + { + "urls": urls, + "include_images": False, + }, + ) + return _normalize_tavily_documents( + raw, fallback_url=urls[0] if urls else "" + ) + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except Exception as exc: # noqa: BLE001 + logger.warning("Tavily extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Tavily extract failed: {exc}"} + for u in urls + ] + + def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + """Crawl a seed URL via Tavily's ``/crawl`` endpoint. + + Accepted kwargs (others ignored for forward compat): + - ``instructions``: str — natural-language guidance for the crawl + - ``depth``: str — ``"basic"`` (default) or ``"advanced"`` + - ``limit``: int — max pages to crawl (default 20) + + Returns ``{"results": [...]}`` shaped to match what + :func:`tools.web_tools.web_crawl_tool` post-processes. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"results": [{"url": url, "title": "", "content": "", "error": "Interrupted"}]} + + instructions = kwargs.get("instructions") + depth = kwargs.get("depth", "basic") + limit = kwargs.get("limit", 20) + + logger.info("Tavily crawl: %s (depth=%s, limit=%d)", url, depth, limit) + payload: Dict[str, Any] = { + "url": url, + "limit": limit, + "extract_depth": depth, + } + if instructions: + payload["instructions"] = instructions + + raw = _tavily_request("crawl", payload) + return { + "results": _normalize_tavily_documents(raw, fallback_url=url) + } + except ValueError as exc: + return {"results": [{"url": url, "title": "", "content": "", "error": str(exc)}]} + except Exception as exc: # noqa: BLE001 + logger.warning("Tavily crawl error: %s", exc) + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Tavily crawl failed: {exc}", + } + ] + } + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Tavily", + "badge": "paid", + "tag": "Search + extract + crawl in one provider.", + "env_vars": [ + { + "key": "TAVILY_API_KEY", + "prompt": "Tavily API key", + "url": "https://app.tavily.com/home", + }, + ], + } diff --git a/pyproject.toml b/pyproject.toml index 118f30c501cb..a880bcb05bf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,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.27.0", "slack-sdk==3.40.1"] +slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"] 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"] diff --git a/run_agent.py b/run_agent.py index f2f3379e0d78..325e1e13ef3c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1271,7 +1271,7 @@ def __init__( self.provider = provider_name or "" self.acp_command = acp_command or command self.acp_args = list(acp_args or args or []) - if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}: + if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: self.api_mode = api_mode elif self.provider == "openai-codex": self.api_mode = "codex_responses" @@ -2115,6 +2115,15 @@ def __init__( 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)) + # protect_first_n is the number of non-system messages to protect at + # the head, in addition to the system prompt (which is always + # implicitly protected by the compressor). Floor at 0 — a value of + # 0 means "preserve only the system prompt + summary + tail", which + # is a legitimate (and common) configuration for long-running + # rolling-compaction sessions. + compression_protect_first = max( + 0, int(_compression_cfg.get("protect_first_n", 3)) + ) # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -2195,6 +2204,10 @@ def __init__( if not isinstance(_custom_providers, list): _custom_providers = [] + # Store for reuse by _check_compression_model_feasibility (auxiliary + # compression model context-length detection needs the same list). + self._custom_providers = _custom_providers + # Check custom_providers per-model context_length if _config_context_length is None and _custom_providers: try: @@ -2315,7 +2328,7 @@ def __init__( self.context_compressor = ContextCompressor( model=self.model, threshold_percent=compression_threshold, - protect_first_n=3, + protect_first_n=compression_protect_first, protect_last_n=compression_protect_last, summary_target_ratio=compression_target_ratio, summary_model_override=None, @@ -3237,6 +3250,7 @@ def _check_compression_model_feasibility(self) -> None: # provider-specific paths (e.g. Bedrock static table, OpenRouter API) # are invoked for the correct client, not inherited from the main model. provider=(_aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(self, "provider", "")), + custom_providers=self._custom_providers, ) # Hard floor: the auxiliary compression model must have at least @@ -4254,6 +4268,7 @@ def _bg_review_auto_deny(command, description, **kwargs): except Exception: pass review_agent = None + review_messages = [] try: with open(os.devnull, "w", encoding="utf-8") as _devnull, \ contextlib.redirect_stdout(_devnull), \ @@ -4267,18 +4282,28 @@ def _bg_review_auto_deny(command, description, **kwargs): # reconstruct auth from scratch -- producing the spurious # "No LLM provider configured" warning at end of turn. _parent_runtime = self._current_main_runtime() + _parent_api_mode = _parent_runtime.get("api_mode") or None + # The review fork needs to call agent-loop tools (memory, + # skill_manage). Those tools require Hermes' own dispatch, + # which the codex_app_server runtime bypasses entirely + # (it runs the turn inside codex's subprocess). So when + # the parent is on codex_app_server, downgrade the review + # fork to codex_responses — same auth/credentials, but + # talks to the OpenAI Responses API directly so Hermes + # owns the loop and the agent-loop tools dispatch. + if _parent_api_mode == "codex_app_server": + _parent_api_mode = "codex_responses" review_agent = AIAgent( model=self.model, max_iterations=16, quiet_mode=True, platform=self.platform, provider=self.provider, - api_mode=_parent_runtime.get("api_mode") or None, + api_mode=_parent_api_mode, base_url=_parent_runtime.get("base_url") or None, api_key=_parent_runtime.get("api_key") or None, credential_pool=getattr(self, "_credential_pool", None), parent_session_id=self.session_id, - enabled_toolsets=["memory", "skills"], ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" @@ -4295,11 +4320,74 @@ def _bg_review_auto_deny(command, description, **kwargs): # _vprint and leak past the stdout redirect (they go via # _print_fn/status_callback, which bypass sys.stdout). review_agent.suppress_status_output = True + # Inherit the parent's cached system prompt verbatim so + # the review fork's outbound HTTP request hits the same + # Anthropic/OpenRouter prefix cache the parent warmed. + # Without this, the fork rebuilds the system prompt from + # scratch (fresh _hermes_now() timestamp, fresh + # session_id, narrower toolset → different skills_prompt) + # and the byte-exact prefix-cache key misses. See + # issue #25322 and PR #17276 for the full analysis + + # measured impact (~26% end-to-end cost reduction on + # Sonnet 4.5). + review_agent._cached_system_prompt = self._cached_system_prompt + # Defensive: pin session_start + session_id to the + # parent's so any code path that re-renders parts of + # the system prompt (compression, plugin hooks) still + # produces byte-identical output. The cached-prompt + # assignment above already short-circuits the normal + # rebuild path, but these pins guarantee parity even + # if a future code path bypasses the cache. + review_agent.session_start = self.session_start + review_agent.session_id = self.session_id + + from model_tools import get_tool_definitions + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, + ) - review_agent.run_conversation( - user_message=prompt, - conversation_history=messages_snapshot, + review_whitelist = { + t["function"]["name"] + for t in get_tool_definitions( + enabled_toolsets=["memory", "skills"], + quiet_mode=True, + ) + } + set_thread_tool_whitelist( + review_whitelist, + deny_msg_fmt=( + "Background review denied non-whitelisted tool: " + "{tool_name}. Only memory/skill tools are allowed." + ), ) + try: + review_agent.run_conversation( + user_message=( + prompt + + "\n\nYou can only call memory and skill " + "management tools. Other tools will be denied " + "at runtime — do not attempt them." + ), + conversation_history=messages_snapshot, + ) + finally: + clear_thread_tool_whitelist() + + # Tear down memory providers while stdout is still + # redirected so background thread teardown (Honcho flush, + # Hindsight sync, etc.) stays silent. The finally block + # below is a safety net for the exception path. + try: + review_agent.shutdown_memory_provider() + except Exception: + pass + try: + review_agent.close() + except Exception: + pass + review_messages = list(getattr(review_agent, "_session_messages", [])) + review_agent = None # Scan the review agent's messages for successful tool actions # and surface a compact summary to the user. Tool messages @@ -4308,7 +4396,7 @@ def _bg_review_auto_deny(command, description, **kwargs): # re-surface stale "created"/"updated" messages from the prior # conversation as if they just happened (issue #14944). actions = self._summarize_background_review_actions( - getattr(review_agent, "_session_messages", []), + review_messages, messages_snapshot, ) @@ -4330,21 +4418,24 @@ def _bg_review_auto_deny(command, description, **kwargs): logger.warning("Background memory/skill review failed: %s", e) self._emit_auxiliary_failure("background review", e) finally: - # Background review agents can initialize memory providers - # (for example Hindsight) that own their own network clients. - # Explicitly stop those providers before closing the agent so - # their aiohttp sessions do not leak until GC/process exit. - # Then close all remaining resources (httpx client, - # subprocesses, etc.) so GC doesn't try to clean them up on a - # dead asyncio event loop (which produces "Event loop is - # closed" errors). + # Safety-net cleanup for the exception path. Normal + # completion already shut down inside redirect_stdout above. + # Re-open devnull here so any teardown output (Honcho flush, + # Hindsight sync, background thread joins) stays silent even + # on the exception path where redirect_stdout already exited. if review_agent is not None: try: - review_agent.shutdown_memory_provider() - except Exception: - pass - try: - review_agent.close() + with open(os.devnull, "w", encoding="utf-8") as _fn, \ + contextlib.redirect_stdout(_fn), \ + contextlib.redirect_stderr(_fn): + try: + review_agent.shutdown_memory_provider() + except Exception: + pass + try: + review_agent.close() + except Exception: + pass except Exception: pass # Clear the approval callback on this bg-review thread so a @@ -9235,6 +9326,46 @@ def _prepare_messages_for_non_vision_model(self, api_messages: list) -> list: ) return transformed + def _tool_result_content_for_active_model(self, tool_name: str, result: Any) -> Any: + """Return the tool message content that is safe for the active model. + + Multimodal tool results normally unwrap to OpenAI-style content parts so + vision-capable models can inspect screenshots. Text-only providers must + not receive those image parts, because a rejected tool result becomes + part of the canonical history and can make the next user turn fail before + the agent has a chance to recover. + """ + if not _is_multimodal_tool_result(result): + return result + + content = result.get("content") or [] + if not self._content_has_image_parts(content): + return content + + if self._model_supports_vision(): + return content + + summary = _multimodal_text_summary(result) + if tool_name == "computer_use": + return json.dumps({ + "error": ( + "computer_use returned screenshot/image content, but the active " + "model/provider does not support image input. Switch to a " + "vision-capable model for desktop computer use, or use browser " + "tools for browser tasks." + ), + "text_summary": summary, + }) + + logger.warning( + "Tool %s returned image content for non-vision model %s/%s; " + "falling back to text summary", + tool_name, + self.provider, + self.model, + ) + return summary + def _try_shrink_image_parts_in_messages(self, api_messages: list) -> bool: """Re-encode all native image parts at a smaller size to recover from image-too-large errors (Anthropic 5 MB, unknown other providers). @@ -9982,11 +10113,12 @@ def _needs_thinking_reasoning_pad(self) -> bool: DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays of assistant tool-call messages that omit ``reasoning_content`` (refs - #15250, #17400). + #15250, #17400). Xiaomi MiMo thinking mode has the same requirement. """ return ( self._needs_deepseek_tool_reasoning() or self._needs_kimi_tool_reasoning() + or self._needs_mimo_tool_reasoning() ) def _needs_kimi_tool_reasoning(self) -> bool: @@ -10018,6 +10150,22 @@ def _needs_deepseek_tool_reasoning(self) -> bool: or base_url_host_matches(self.base_url, "api.deepseek.com") ) + def _needs_mimo_tool_reasoning(self) -> bool: + """Return True when the current provider is Xiaomi MiMo thinking mode. + + MiMo thinking mode requires ``reasoning_content`` on every assistant + tool-call message when replaying history; omitting it causes HTTP 400. + Refs: https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content + """ + provider = (self.provider or "").lower() + model = (self.model or "").lower() + return ( + provider == "xiaomi" + or "mimo" in model + or base_url_host_matches(self.base_url, "api.xiaomimimo.com") + or base_url_host_matches(self.base_url, "xiaomimimo.com") + ) + def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None: """Copy provider-facing reasoning fields onto an API replay message.""" if source_msg.get("role") != "assistant": @@ -10257,6 +10405,9 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token f"{approx_tokens:,}" if approx_tokens else "unknown", self.model, focus_topic, ) + self._emit_status( + "🗜️ Compacting context — summarizing earlier conversation so I can continue..." + ) # Notify external memory provider before compression discards context if self._memory_manager: @@ -10987,14 +11138,10 @@ def _run_tool(index, tool_call, function_name, function_args): # rather than a raw Python dict. The Anthropic adapter already # accepts content lists; vision-capable OpenAI-compatible servers # (mlx-vlm, GPT-4o, …) accept image_url in tool messages natively. - # Text-only servers that reject images are handled by the adaptive - # _vision_supported recovery in the API retry loop. + # Text-only servers get a string-safe fallback here so a rejected + # image tool result never poisons canonical session history. # String results pass through unchanged. - _tool_content = ( - function_result["content"] - if _is_multimodal_tool_result(function_result) - else function_result - ) + _tool_content = self._tool_result_content_for_active_model(name, function_result) tool_msg = { "role": "tool", "name": name, @@ -11409,11 +11556,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. - _tool_content = ( - function_result["content"] - if _is_multimodal_tool_result(function_result) - else function_result - ) + _tool_content = self._tool_result_content_for_active_model(function_name, function_result) tool_msg = { "role": "tool", "name": function_name, @@ -12115,6 +12258,20 @@ def run_conversation( except Exception: pass + # Optional opt-in runtime: if api_mode == codex_app_server, hand the + # turn to the codex app-server subprocess (terminal/file ops/patching + # all run inside Codex). Default Hermes path is bypassed entirely. + # See agent/transports/codex_app_server_session.py for the adapter + # and references/codex-app-server-runtime.md for the rationale. + if self.api_mode == "codex_app_server": + return self._run_codex_app_server_turn( + user_message=user_message, + original_user_message=original_user_message, + messages=messages, + effective_task_id=effective_task_id, + should_review_memory=_should_review_memory, + ) + while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) or self._budget_grace_call: # Reset per-turn checkpoint dedup so each iteration can take one snapshot self._checkpoint_mgr.new_turn() @@ -13412,6 +13569,11 @@ def _stop_spinner(): # we don't false-trip on other URL validation # errors. (issue #23570) "image_url'. expected", + # DeepSeek's OpenAI-compatible API reports text-only + # request-body variants as: + # "unknown variant `image_url`, expected `text`". + "unknown variant `image_url`, expected `text`", + "unknown variant image_url, expected text", ) _err_lower = _err_body.lower() _looks_like_image_rejection = any( @@ -15554,6 +15716,153 @@ def chat(self, message: str, stream_callback: Optional[callable] = None) -> str: result = self.run_conversation(message, stream_callback=stream_callback) return result["final_response"] + def _run_codex_app_server_turn( + self, + *, + user_message: str, + original_user_message: Any, + messages: List[Dict[str, Any]], + effective_task_id: str, + should_review_memory: bool = False, + ) -> Dict[str, Any]: + """Codex app-server runtime path. Hands the entire turn to a `codex + app-server` subprocess and projects its events back into Hermes' + messages list so memory/skill review keep working. + + Called from run_conversation() when self.api_mode == "codex_app_server". + Returns the same dict shape as the chat_completions path. + """ + from agent.transports.codex_app_server_session import CodexAppServerSession + + # Lazy session: one CodexAppServerSession per AIAgent instance. + # Spawned on first turn, reused across turns, closed at AIAgent + # shutdown (see _cleanup hook). + if not hasattr(self, "_codex_session") or self._codex_session is None: + cwd = getattr(self, "session_cwd", None) or os.getcwd() + # Approval callback: defer to Hermes' standard prompt flow if a + # CLI thread has installed one. Gateway / cron contexts get the + # codex-side fail-closed default. + try: + from tools.terminal_tool import _get_approval_callback + approval_callback = _get_approval_callback() + except Exception: + approval_callback = None + self._codex_session = CodexAppServerSession( + cwd=cwd, + approval_callback=approval_callback, + ) + + # NOTE: the user message is ALREADY appended to messages by the + # standard run_conversation() flow (line ~11823) before the early + # return reaches us. Do NOT append again — that would duplicate. + + try: + turn = self._codex_session.run_turn(user_input=user_message) + except Exception as exc: + logger.exception("codex app-server turn failed") + # Crash → unconditionally drop the session so the next turn + # respawns from scratch instead of reusing a dead client. + try: + self._codex_session.close() + except Exception: + pass + self._codex_session = None + return { + "final_response": ( + f"Codex app-server turn failed: {exc}. " + f"Fall back to default runtime with `/codex-runtime auto`." + ), + "messages": messages, + "api_calls": 0, + "completed": False, + "partial": True, + "error": str(exc), + } + + # If the turn signalled the underlying client is wedged (deadline + # blown, post-tool watchdog tripped, OAuth refresh died, subprocess + # exited), retire the session so the next turn respawns codex + # rather than riding the broken process. Mirrors openclaw beta.8's + # "retire timed-out app-server clients" fix. + if getattr(turn, "should_retire", False): + logger.warning( + "codex app-server session retired (turn error: %s)", + turn.error, + ) + try: + self._codex_session.close() + except Exception: + pass + self._codex_session = None + + # Splice projected messages into the conversation. The projector emits + # standard {role, content, tool_calls, tool_call_id} entries, which + # is exactly what curator.py / sessions DB expect. + if turn.projected_messages: + messages.extend(turn.projected_messages) + + # Counter ticks for the self-improvement loop. + # _turns_since_memory and _user_turn_count are ALREADY incremented + # in the run_conversation() pre-loop block (lines ~11793-11817) so we + # do NOT touch them here — that would double-count. + # Only _iters_since_skill needs explicit increment, since the + # chat_completions loop bumps it per tool iteration (line ~12110) + # and that loop is bypassed on this path. + self._iters_since_skill = ( + getattr(self, "_iters_since_skill", 0) + turn.tool_iterations + ) + + # Now check the skill nudge AFTER iters were incremented — same + # pattern the chat_completions path uses (line ~15432). + should_review_skills = False + if ( + self._skill_nudge_interval > 0 + and self._iters_since_skill >= self._skill_nudge_interval + and "skill_manage" in self.valid_tool_names + ): + should_review_skills = True + self._iters_since_skill = 0 + + # External memory provider sync (mirrors line ~15439). Skipped on + # interrupt/error to avoid feeding partial transcripts to memory. + if not turn.interrupted and turn.error is None: + try: + self._sync_external_memory_for_turn( + original_user_message=original_user_message, + final_response=turn.final_text, + interrupted=False, + ) + except Exception: + logger.debug("external memory sync raised", exc_info=True) + + # Background review fork — same cadence + signature as the default + # path (line ~15449). Only fires when a trigger actually tripped AND + # we have a real final response. + if ( + turn.final_text + and not turn.interrupted + and (should_review_memory or should_review_skills) + ): + try: + self._spawn_background_review( + messages_snapshot=list(messages), + review_memory=should_review_memory, + review_skills=should_review_skills, + ) + except Exception: + logger.debug("background review spawn raised", exc_info=True) + + return { + "final_response": turn.final_text, + "messages": messages, + "api_calls": 1, # one app-server "turn" maps to one logical API call + "completed": not turn.interrupted and turn.error is None, + "partial": turn.interrupted or turn.error is not None, + "error": turn.error, + "codex_thread_id": turn.thread_id, + "codex_turn_id": turn.turn_id, + } + def main( query: str = None, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e2fe765174c7..36cdf76ec70b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -813,6 +813,14 @@ function Install-Dependencies { # needs `make` to build from sdist) and the # install fails. # --extra all = just the [all] extra's contents (curated). + # + # UV_PROJECT_ENVIRONMENT pins the sync target to our venv\. + # Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync` + # and creates a sibling .venv\ inside the repo — leaving venv\ + # empty and producing the broken state where `hermes.exe` exists + # in the wrong directory and imports fail with ModuleNotFoundError. + # (Mirrors the same flag in scripts/install.sh::install_deps.) + $env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv" & $UvCmd sync --extra all --locked if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" @@ -902,6 +910,31 @@ except Exception: throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." } + # Baseline-import gate. Even if a tier reported success above, the + # actual deps may have landed somewhere other than $InstallDir\venv\ + # (e.g. uv 0.5+ syncing into a sibling .venv\ when UV_PROJECT_ENVIRONMENT + # isn't set, leaving venv\ empty and hermes.exe broken with + # `ModuleNotFoundError: No module named 'dotenv'` on first run). + # We probe via the venv's own python so a misdirected sync is caught + # here, not 30 seconds later when the user runs `hermes`. + if (-not $NoVenv) { + $venvPython = "$InstallDir\venv\Scripts\python.exe" + if (-not (Test-Path $venvPython)) { + throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" + } + & $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + $sibling = "$InstallDir\.venv" + $hint = if (Test-Path $sibling) { + "Detected sibling .venv\ at $sibling — uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" + } else { + "Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" + } + throw "Baseline imports failed in $InstallDir\venv (dotenv/openai/rich/prompt_toolkit). The install completed but dependencies are not in the venv. $hint" + } + Write-Success "Baseline imports verified in venv" + } + # Verify the dashboard deps specifically — they're the most common thing # users hit and lazy-import errors from `hermes dashboard` are confusing. # If tier 1 failed (the common case), [web] was still picked up by tiers diff --git a/scripts/install.sh b/scripts/install.sh index 72cc81637da4..cf24912cc519 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -64,10 +64,12 @@ NODE_VERSION="22" # data still at /root/.hermes (HERMES_HOME). Matches Claude Code / Codex CLI # and keeps Docker bind-mounted /root/ volumes lean. ROOT_FHS_LAYOUT=false +DETECTED_BROWSER_EXECUTABLE="" # Options USE_VENV=true RUN_SETUP=true +SKIP_BROWSER=false BRANCH="main" # Detect non-interactive mode (e.g. curl | bash) @@ -90,6 +92,10 @@ while [[ $# -gt 0 ]]; do RUN_SETUP=false shift ;; + --skip-browser|--no-playwright) + SKIP_BROWSER=true + shift + ;; --branch) BRANCH="$2" shift 2 @@ -111,6 +117,7 @@ while [[ $# -gt 0 ]]; do echo "Options:" echo " --no-venv Don't create virtual environment" echo " --skip-setup Skip interactive setup wizard" + echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)" echo " --branch NAME Git branch to install (default: main)" echo " --dir PATH Installation directory" echo " default (non-root): ~/.hermes/hermes-agent" @@ -1280,6 +1287,10 @@ setup_path() { # We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars # can't make this launcher import modules from another checkout. mkdir -p "$command_link_dir" + # Older installs created this path as a symlink to $HERMES_BIN. Without + # the rm, `cat >` follows the symlink and overwrites the venv pip entry + # point with this shim — making `exec "$HERMES_BIN"` self-recurse. (#21454) + rm -f "$command_link_dir/hermes" cat > "$command_link_dir/hermes" </dev/null 2>&1; then + command -v "$AGENT_BROWSER_EXECUTABLE_PATH" + return 0 + fi + fi + + local candidate + for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + + return 1 +} + +run_browser_install_with_timeout() { + local timeout_seconds="$1" + shift + + if command -v timeout >/dev/null 2>&1; then + timeout "$timeout_seconds" "$@" + else + "$@" + fi +} + +configure_browser_env_from_system_browser() { + local env_file="$HERMES_HOME/.env" + local browser_path="${DETECTED_BROWSER_EXECUTABLE:-}" + + if [ -z "$browser_path" ]; then + browser_path="$(find_system_browser 2>/dev/null || true)" + fi + + if [ -z "$browser_path" ] || [ ! -f "$env_file" ]; then + return 0 + fi + + if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then + log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured" + return 0 + fi + + { + echo "" + echo "# Hermes Agent browser tools — use the system Chrome/Chromium binary." + echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" + } >> "$env_file" + log_success "Configured browser tools to use $browser_path" +} + install_node_deps() { if [ "$HAS_NODE" = false ]; then log_info "Skipping Node.js dependencies (Node not installed)" @@ -1494,58 +1572,90 @@ install_node_deps() { # Playwright's --with-deps only supports apt-based systems natively. # For Arch/Manjaro we install the system libs via pacman first. # Other systems must install Chromium dependencies manually. + if [ "$SKIP_BROWSER" = true ]; then + log_info "Skipping Playwright/Chromium install (--skip-browser)" + log_info "Browser tools will be unavailable until you run manually:" + log_info " cd $INSTALL_DIR && npx playwright install chromium" + log_info "On apt-based systems, an admin also needs to run:" + log_info " sudo npx playwright install-deps chromium" + else log_info "Installing browser engine (Playwright Chromium)..." - case "$DISTRO" in - ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) - log_info "Playwright may request sudo to install browser system dependencies (shared libraries)." - log_info "This is standard Playwright setup — Hermes itself does not require root access." - cd "$INSTALL_DIR" && npx playwright install --with-deps chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — browser tools will not work." - log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium" - } - ;; - arch|manjaro) - if command -v pacman &> /dev/null; then - log_info "Arch/Manjaro detected — installing Chromium system dependencies via pacman..." - if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true - elif [ "$(id -u)" -eq 0 ]; then - pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" + if [ -n "$DETECTED_BROWSER_EXECUTABLE" ]; then + log_success "Found system Chrome/Chromium at $DETECTED_BROWSER_EXECUTABLE" + log_info "Skipping Playwright browser download; Hermes will use the system browser." + else + case "$DISTRO" in + ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) + # Use --with-deps only when sudo is available non-interactively + # (root, or a user with passwordless sudo). Non-sudo users + # — typical for systemd service accounts and unprivileged + # operator users — would otherwise get blocked on an + # interactive sudo prompt that they can't satisfy. Fall back + # to the browser-only install in that case, and print the + # exact command the admin needs to run separately. + if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then + log_info "Installing Playwright Chromium with system dependencies..." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install --with-deps chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium" + } else - log_warn "Cannot install browser deps without sudo. Run manually:" - log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + log_warn "No sudo available — skipping system-library install (--with-deps)." + log_info "Ask an administrator to run, one time, as root:" + log_info " sudo npx playwright install-deps chromium" + log_info " (from $INSTALL_DIR, after Node.js deps are installed)" + log_info "Installing Chromium binary into this user's Playwright cache..." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install chromium" + } fi - fi - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — browser tools will not work." - } - ;; - fedora|rhel|centos|rocky|alma) - log_warn "Playwright does not support automatic dependency installation on RPM-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - opensuse*|sles) - log_warn "Playwright does not support automatic dependency installation on zypper-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2" - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - *) - log_warn "Playwright does not support automatic dependency installation on $DISTRO." - log_info "Install Chromium/browser system dependencies for your distribution, then run:" - log_info " cd $INSTALL_DIR && npx playwright install chromium" - log_info "Browser tools will not work until dependencies are installed." - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || true - ;; - esac + ;; + arch|manjaro|cachyos|endeavouros|garuda) + if command -v pacman &> /dev/null; then + log_info "Arch-family distro detected — installing Chromium system dependencies via pacman..." + if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then + sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \ + nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + elif [ "$(id -u)" -eq 0 ]; then + pacman -S --noconfirm --needed \ + nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + else + log_warn "Cannot install browser deps without sudo. Run manually:" + log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + fi + fi + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + } + ;; + fedora|rhel|centos|rocky|alma) + log_warn "Playwright does not support automatic dependency installation on RPM-based systems." + log_info "Install Chromium system dependencies manually before using browser tools:" + log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — install dependencies above and retry." + } + ;; + opensuse*|sles) + log_warn "Playwright does not support automatic dependency installation on zypper-based systems." + log_info "Install Chromium system dependencies manually before using browser tools:" + log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2" + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — install dependencies above and retry." + } + ;; + *) + log_warn "Playwright does not support automatic dependency installation on $DISTRO." + log_info "Install Chromium/browser system dependencies for your distribution, then run:" + log_info " cd $INSTALL_DIR && npx playwright install chromium" + log_info "Browser tools will not work until dependencies are installed." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || true + ;; + esac + fi + fi log_success "Browser engine setup complete" fi diff --git a/scripts/release.py b/scripts/release.py index 4ed5aadc32de..d981b8b595d9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -41,27 +41,45 @@ AUTHOR_MAP = { # teknium (multiple emails) "teknium1@gmail.com": "teknium1", + "30366221+WorldWriter@users.noreply.github.com": "WorldWriter", + "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", + "anadi.jaggia@gmail.com": "Jaggia", + "32201324+simpolism@users.noreply.github.com": "simpolism", + "simpolism@gmail.com": "simpolism", + "jake@nousresearch.com": "simpolism", + "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", "oleksii.lisikh@gmail.com": "olisikh", "leone.parise@gmail.com": "leoneparise", + "mr@shu.io": "mrshu", "buraysandro9@gmail.com": "ygd58", + "yanglongwei06@gmail.com": "Alex-yang00", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", "421774554@qq.com": "wuli666", "harish.kukreja@gmail.com": "counterposition", "1046611633@qq.com": "zhengyn0001", + "1095245867@qq.com": "littlewwwhite", "db@project-aeon.com": "db-aeon", "ahmed@abadr.net": "ahmedbadr3", "cleo@edaphic.xyz": "curiouscleo", "hirokazu.ogawa@kwansei.ac.jp": "hrkzogw", "datapod.k@gmail.com": "dandacompany", "treydong.zh@gmail.com": "TreyDong", + "phil.thomas@gametime.co": "explainanalyze", "kyanam.preetham@gmail.com": "pkyanam", + "zhizhong.xu@shopee.com": "1000Delta", + "30397170+1000Delta@users.noreply.github.com": "1000Delta", + "szymonclawd@mac.home": "szymonclawd", + "257759490+szymonclawd@users.noreply.github.com": "szymonclawd", + "zhanganzhe@tenclass.com": "luoyuctl", + "51604064+luoyuctl@users.noreply.github.com": "luoyuctl", "127238744+teknium1@users.noreply.github.com": "teknium1", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", + "97489706+purzbeats@users.noreply.github.com": "purzbeats", "hugosequier@gmail.com": "Hugo-SEQUIER", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", @@ -79,6 +97,7 @@ "62420081+kjames2001@users.noreply.github.com": "kjames2001", "132184373+wilsen0@users.noreply.github.com": "wilsen0", "ra2157218@gmail.com": "Abd0r", + "oswaldb22@users.noreply.github.com": "oswaldb22", "abdielv@proton.me": "AJV20", "mason@growagainorchids.com": "masonjames", "ytchen0719@gmail.com": "liquidchen", @@ -133,6 +152,7 @@ "sandrohub013@gmail.com": "SandroHub013", "maciekczech@users.noreply.github.com": "maciekczech", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", + "cine.dreamer.one@gmail.com": "LeonSGP43", "zjtan1@gmail.com": "zeejaytan", "asslaenn5@gmail.com": "Aslaaen", "trae.anderson17@icloud.com": "Tkander1715", @@ -221,6 +241,7 @@ "hitesh@gmail.com": "htsh", "pty819@outlook.com": "pty819", "pty819@users.noreply.github.com": "pty819", + "14341805+pty819@users.noreply.github.com": "pty819", "517024110@qq.com": "chennest", # Curator fixes (Apr 30 2026) "yuxiangl490@gmail.com": "y0shua1ee", @@ -598,6 +619,7 @@ "iacobs@m0n5t3r.info": "m0n5t3r", "jiayuw794@gmail.com": "JiayuuWang", "jonny@nousresearch.com": "jquesnelle", + "jake@nousresearch.com": "simpolism", "juan.ovalle@mistral.ai": "jjovalle99", "julien.talbot@ergonomia.re": "Julientalbot", "kagura.chen28@gmail.com": "kagura-agent", @@ -704,6 +726,7 @@ "tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc", "harryplusplus@gmail.com": "harryplusplus", "anthhub@163.com": "anthhub", + "vmphuongit@gmail.com": "phuongvm", "allard.quek@singtel.com": "AllardQuek", "shenuu@gmail.com": "shenuu", "xiayh17@gmail.com": "xiayh0107", @@ -746,6 +769,8 @@ "chayton@sina.com": "ycbai", "longsizhuo@gmail.com": "longsizhuo", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", + "phil.thomas@gametime.co": "explainanalyze", "276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123", "22549957+li0near@users.noreply.github.com": "li0near", "guoyu801@gmail.com": "li0near", @@ -844,6 +869,8 @@ "dpaluy@users.noreply.github.com": "dpaluy", "psikonetik@gmail.com": "el-analista", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", + "phil.thomas@gametime.co": "explainanalyze", "hex-clawd@users.noreply.github.com": "hex-clawd", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", "barteq@hacknotes.local": "barteqpl", @@ -1004,6 +1031,26 @@ "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) "anton.kuenzi@gmail.com": "ZeterMordio", # PR #11754 salvage (zsh completion compdef + _arguments syntax) + "23yntong@stu.edu.cn": "iuyup", # PR #6155 salvage (shell=True hardening) + "86501179+1RB@users.noreply.github.com": "1RB", # PR #25462 salvage (discord forwarded messages) + "44045943+ayushere@users.noreply.github.com": "ayushere", # PR #25342 salvage (memory teardown leak) + "15791290+domtriola@users.noreply.github.com": "domtriola", # PR #25424 salvage (docs tirith link) + "284216128+ephron-ren@users.noreply.github.com": "ephron-ren", # PR #25358 salvage (MiMo reasoning echo-back) + "96843562+freqyfreqy@users.noreply.github.com": "freqyfreqy", # PR #25423 salvage (docs LSP worktree -> repo) + "54306477+fu576@users.noreply.github.com": "fu576", # PR #25369 salvage (api_mode not inherited cross-provider) + "258095375+kfa-ai@users.noreply.github.com": "kfa-ai", # PR #25398 salvage (whatsapp quoted reply metadata) + "99181308+magic524@users.noreply.github.com": "magic524", # PR #25361 salvage (QQBot reconnect loop) + "9150277+PaTTeeL@users.noreply.github.com": "PaTTeeL", # PR #25359 salvage (custom_providers in compression length) + "1700913+pearjelly@users.noreply.github.com": "pearjelly", # PR #25388 salvage (feishu ws connect override sync) + "100820567+raymaylee@users.noreply.github.com": "raymaylee", # PR #25394 salvage (context compaction status) + "122434621+Tianyu199509@users.noreply.github.com": "Tianyu199509", # PR #25421 salvage (gateway PID Windows) + "58224596+HxT9@users.noreply.github.com": "HxT9", # PR #25760 salvage (web sync-assets cross-platform) + "120411712+evgyur@users.noreply.github.com": "evgyur", # PR #25651 salvage (docs media session context) + "36507055+AsoTora@users.noreply.github.com": "AsoTora", # PR #25624 salvage (MCP auth no-retry) + "98992931+oxngon@users.noreply.github.com": "oxngon", # PR #25603 salvage (forward image attachments to bg tasks) + "37467487+yifengingit@users.noreply.github.com": "yifengingit", # PR #25589 salvage (AUTOINCREMENT id ordering) + "89525629+vanthinh6886@users.noreply.github.com": "vanthinh6886", # PR #25562 salvage (.env 0600 perms) + "16034932+Arkmusn@users.noreply.github.com": "Arkmusn", # PR #25559 salvage (approvals.timeout from config) } diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 9ab6118da1b5..9ff64471e566 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -300,7 +300,10 @@ async function startSocket() { const messageContent = getMessageContent(msg); const contextInfo = getContextInfo(messageContent); const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean))); - const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || contextInfo?.remoteJid || ''); + const quotedMessageId = contextInfo?.stanzaId || null; + const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null; + const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null; + const hasQuotedMessage = !!contextInfo?.quotedMessage; // Extract message body let body = ''; @@ -412,7 +415,10 @@ async function startSocket() { mediaType, mediaUrls, mentionedIds, + quotedMessageId, quotedParticipant, + quotedRemoteJid, + hasQuotedMessage, botIds, timestamp: msg.messageTimestamp, }; diff --git a/skills/creative/comfyui/SKILL.md b/skills/creative/comfyui/SKILL.md index 4fbeb6035722..e5a8a7c07452 100644 --- a/skills/creative/comfyui/SKILL.md +++ b/skills/creative/comfyui/SKILL.md @@ -1,8 +1,8 @@ --- name: comfyui description: "Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST/WebSocket API for execution." -version: 5.0.0 -author: [kshitijk4poor, alt-glitch] +version: 5.1.0 +author: [kshitijk4poor, alt-glitch, purzbeats] license: MIT platforms: [macos, linux, windows] compatibility: "Requires ComfyUI (local, Comfy Desktop, or Comfy Cloud) and comfy-cli (auto-installed via pipx/uvx by the setup script)." @@ -40,6 +40,12 @@ for workflow execution. - `official-cli.md` — every `comfy ...` command, with flags - `rest-api.md` — REST + WebSocket endpoints (local + cloud), payload schemas - `workflow-format.md` — API-format JSON, common node types, param mapping +- `template-integrity.md` — converting `comfyui-workflow-templates` from + editor format to API format: Reroute bypass, dotted dynamic-input keys + (`values.a`, `resize_type.width`), Cloud quirks (302 redirect, 1 concurrent + free-tier job, 1080p VRAM ceiling), Discord-compatible ffmpeg stitch. + Authored by [@purzbeats](https://github.com/purzbeats). Load this whenever + you're starting from an official template. **Scripts (`scripts/`):** diff --git a/skills/creative/comfyui/references/template-integrity.md b/skills/creative/comfyui/references/template-integrity.md new file mode 100644 index 000000000000..050e3e6b5cf4 --- /dev/null +++ b/skills/creative/comfyui/references/template-integrity.md @@ -0,0 +1,243 @@ +# ComfyUI Workflow-Template Integrity + +> **Authored by [@purzbeats](https://github.com/purzbeats)** — adapted from +> [purzbeats/hermes-agent-comfyui-helper](https://github.com/purzbeats/hermes-agent-comfyui-helper). +> Use this reference when converting workflows from the official +> `comfyui-workflow-templates` package (editor format) into API format for +> submission via `/api/prompt`. The conversion has subtle gotchas that cause +> hard-to-diagnose validation errors if you don't follow these rules. + +## Background + +The official ComfyUI template package (`comfyui-workflow-templates`, currently +v0.9.69) is installed inside the ComfyUI venv at a path like: + +``` +/.venv/lib/python3.*/site-packages/comfyui_workflow_templates_*/templates/ +``` + +The exact path depends on how ComfyUI was installed (comfy-cli default, +Comfy Desktop, manual venv, etc.). Find it once with: + +```bash +comfy --workspace run-python -c "import comfyui_workflow_templates, pathlib; print(pathlib.Path(comfyui_workflow_templates.__file__).parent / 'templates')" +``` + +Templates ship in **editor format** — `nodes` / `links` arrays inside +`data['definitions']['subgraphs'][0]`. They must be converted to **API +format** (a `node_id -> {class_type, inputs}` mapping) before submission. + +--- + +## RULE #1: Use templates AS CLOSE TO ORIGINAL AS POSSIBLE + +- **Never strip, simplify, or "minimize" nodes** from a template. +- Full template architecture (dual-pass pipelines, LoRA chains, distilled + sigmas, conditioning paths) is intentional — removing any part breaks quality. +- If an image-dependent path exists but the task is text-to-video, **leave + it wired with the bypass toggle enabled** — don't remove the nodes. +- Only change: prompt text, seed, and dimensions (when explicitly requested). + +## RULE #2: Server validation errors are the source of truth + +When a workflow submission fails, the server response looks like: + +```json +{ + "node_errors": { + "238": { + "errors": [{ + "message": "Required input is missing", + "details": "width", + "extra_info": { "input_name": "resize_type.width" } + }] + } + } +} +``` + +**The `extra_info.input_name` field tells you EXACTLY what JSON key the server +wants. Use it literally.** If it says `"values.a"` or `"resize_type.width"`, +those are the actual key names in the JSON object. Do not "simplify" them to +flat names based on assumptions about what the field "should" be called. + +## RULE #3: Don't rebuild from scratch — patch the failing nodes + +Every regeneration from the template reintroduces the same bugs. Instead: + +1. Submit the workflow once. +2. Read the server error details for exact key names. +3. Use targeted patch/fix calls against the workflow file on disk. +4. Resubmit and check if errors resolved. + +--- + +## Reroute nodes: bypass, don't delete + +Most servers (local, Cloud) don't have a `Reroute` node type. When converting +a template: + +1. Find what feeds into the Reroute by looking at links where + `target_id` = the Reroute node ID. +2. Replace all inputs referencing the Reroute with + `[source_node_id, source_slot]`. +3. Delete the Reroute node from the API mapping. + +**Real example — LTX 2.3 t2v template:** + +- Reroute node 255 receives VAE from `CheckpointLoaderSimple 236` slot 2. +- Three nodes reference Reroute 255 for their VAE input: + `LTXVImgToVideoInplace` (230), `LTXVLatentUpsampler` (253), + `VAEDecodeTiled` (251). +- Fix: replace all occurrences of `vae: ["255", 0]` with `vae: ["236", 2]`. +- `CheckpointLoaderSimple` slot 2 = VAE (not slot 0 = MODEL). + +| | | +|---|---| +| ❌ Wrong | `vae: ["236", 0]` → `MODELV mismatch input_type(VAE)` | +| ✅ Correct | `vae: ["236", 2]` | + +--- + +## Dynamic template nodes: dotted key names are correct + +### ComfyMathExpression (COMFY_AUTOGROW_V3) + +```json +{ + "class_type": "ComfyMathExpression", + "inputs": { + "expression": "a/2", + "values.a": ["257", 0] + } +} +``` + +- `values` is a `COMFY_AUTOGROW_V3` template. +- Input names in links are `values.a`, `values.b`, etc. +- **Keep the dotted format as JSON keys.** +- Do NOT convert to `{"values": {"a": ...}}` or flatten to just `"a"`. + +### ResizeImageMaskNode (COMFY_DYNAMICCOMBO_V3) + +```json +{ + "class_type": "ResizeImageMaskNode", + "inputs": { + "input": ["276", 0], + "scale_method": "lanczos", + "resize_type": "scale dimensions", + "resize_type.width": 1920, + "resize_type.height": 1088, + "resize_type.crop": "center" + } +} +``` + +- `resize_type` is a `COMFY_DYNAMICCOMBO_V3`. +- Mode-specific fields: `resize_type.width`, `resize_type.height`, `resize_type.crop`. +- `scale_method` options: `"nearest-exact"`, `"bilinear"`, `"area"`, `"bicubic"`, `"lanczos"`. +- **Keep the dotted format as JSON keys.** +- Do NOT flatten `resize_type.width` to just `"width"`. + +--- + +## Conversion recipe + +1. Load template from the installed package path. +2. Parse `data['definitions']['subgraphs'][0]`. +3. For each node (skip Reroute): + - Resolve linked inputs from `sg['links']` dict. + - Map `widgets_values` to input field names. + - Keep all dotted key names as-is from the template. +4. Bypass Reroute: trace source, replace references. +5. Change only: prompt text, seed values, and user-requested parameters. +6. Add `SaveVideo` terminal node if template uses only `CreateVideo`. +7. Submit → read errors → patch specific nodes → resubmit. + +## What to NEVER change in a template + +| Element | Why | +|---------|-----| +| Node topology | Graph is designed for the specific model | +| Sigmas values | Tuned for the model/sampler combination | +| LoRA/distilled paths | Required for quality, even if they look unused | +| Model parameters (cfg, steps, shifts) | Model-specific | +| Conditioning chains (zero-out, crop guides) | Required for correct conditioning | +| Pass-through wiring | Don't remove nodes, bypass them | + +--- + +## Cloud compatibility (verified May 2025) + +The full LTX 2.3 T2V template (`video_ltx2_3_t2v.json`) runs **without +modification** on Comfy Cloud. + +**Confirmed working on Cloud (all custom nodes available):** +`ComfyMathExpression`, `ResizeImageMaskNode`, `ResizeImagesByLongerEdge`, +`PrimitiveInt`, `PrimitiveStringMultiline`, `PrimitiveBoolean`, `SaveVideo`, +`LTXVCropGuides`, `LTXVImgToVideoInplace`, `LTXVConcatAVLatent`, +`LTXVSeparateAVLatent`, `LTXVLatentUpsampler`, `LTXVAudioVAELoader`, +`LTXVAudioVAEDecode`, `LTXVEmptyLatentAudio`, `LTXVPreprocess`, +`LTXVConditioning`, `ManualSigmas`, `LTXAVTextEncoderLoader`, plus all core +nodes. + +**Cloud vs Local for LTX 2.3 (768x512):** + +- Cloud: ~39s per video (4x faster). +- Local (RTX 5090): ~160s per video. +- `example.png` placeholder works on Cloud for bypassed image-dependent paths. +- Submission format is **identical** between local and Cloud: + `{"prompt": wf, "extra_data": {}}` to `/api/prompt`. +- Free tier = 1 concurrent job. + +**Cloud submission pitfalls:** + +- `/api/object_info/` returns 404 on free tier — can't query node + schemas remotely, but the workflow runs fine anyway. Always probe + `object_info` locally before building workflows. +- Cloud is ~4x faster — prefer Cloud for batch runs unless local is needed + for debugging. +- Cloud `/api/view` returns **302 redirect to signed GCS URL** — use + `curl -s -L` to follow and download. Python `urllib` fails with 401 + (forwards auth headers to GCS CDN). +- `COMFY_CLOUD_API_KEY` is only in the terminal/bash env, not in the Python + sandbox. Use subprocess or terminal scripts for Cloud API calls. +- Cloud free tier processes jobs **sequentially** (1 at a time). Submit all, + then poll history. +- LTX 2.3 at **1920x1080 OOMs locally** (even RTX 5090) — upscaler pass + exceeds VRAM. Prefer Cloud for 1080p; use 1280x720 locally (~90s/video). + +--- + +## FFmpeg stitch settings (Discord-compatible) + +Generated ComfyUI videos often use `yuv444p` pixel format which does NOT work +on Discord. Re-encode with: + +```bash +ffmpeg -y -i input.mp4 \ + -c:v libx264 -profile:v main -preset medium -crf 13 -pix_fmt yuv420p \ + -c:a aac -b:a 192k \ + output_discord.mp4 +``` + +Key settings: + +- `-pix_fmt yuv420p` — **required for Discord**, ComfyUI outputs `yuv444p` by default. +- `-crf 13` — high quality without massive file size (default 23 is too lossy). +- `-profile:v main` — widely compatible. + +For multi-video crossfade stitching, chain `xfade` (video) and `acrossfade` +(audio): + +```bash +ffmpeg -y -i a.mp4 -i b.mp4 -i c.mp4 \ + -filter_complex "[0:v][1:v]xfade=transition=fade:duration=1:offset=3.04[v1];[v1][2:v]xfade=transition=fade:duration=1:offset=6.08[vout];[0:a][1:a]acrossfade=duration=1:c1=tri:c2=tri[a1];[a1][2:a]acrossfade=duration=1:c1=tri:c2=tri[aout]" \ + -map "[vout]" -map "[aout]" \ + -c:v libx264 -profile:v main -crf 13 -pix_fmt yuv420p \ + -c:a aac -b:a 192k \ + output.mp4 +``` + +Offset for xfade #N = `(N+1) × duration - N × overlap`. diff --git a/tests/acp/test_permissions.py b/tests/acp/test_permissions.py index 57e2bd4e5b99..8bbdeeb392ac 100644 --- a/tests/acp/test_permissions.py +++ b/tests/acp/test_permissions.py @@ -1,89 +1,168 @@ -"""Tests for acp_adapter.permissions — ACP approval bridging.""" +"""Tests for acp_adapter.permissions.""" import asyncio +import inspect from concurrent.futures import Future -from unittest.mock import MagicMock, patch - -import pytest +from unittest.mock import AsyncMock, MagicMock, patch from acp.schema import ( AllowedOutcome, DeniedOutcome, RequestPermissionResponse, ) + from acp_adapter.permissions import make_approval_callback +from tools.approval import prompt_dangerous_approval def _make_response(outcome): - """Helper to build a RequestPermissionResponse with the given outcome.""" return RequestPermissionResponse(outcome=outcome) -def _setup_callback(outcome, timeout=60.0): - """ - Create a callback wired to a mock request_permission coroutine - that resolves to the given outcome. - - Returns: - (callback, mock_request_permission_fn) - """ +def _invoke_callback( + outcome, + *, + allow_permanent=True, + timeout=60.0, + use_prompt_path=False, +): loop = MagicMock(spec=asyncio.AbstractEventLoop) - mock_rp = MagicMock(name="request_permission") - - response = _make_response(outcome) - - # Patch asyncio.run_coroutine_threadsafe so it returns a future - # that immediately yields the response. + request_permission = AsyncMock(name="request_permission") future = MagicMock(spec=Future) - future.result.return_value = response + future.result.return_value = _make_response(outcome) + + scheduled = {} + + def _schedule(coro, passed_loop): + scheduled["coro"] = coro + scheduled["loop"] = passed_loop + return future + + with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=timeout) + if use_prompt_path: + result = prompt_dangerous_approval( + "rm -rf /", + "dangerous command", + allow_permanent=allow_permanent, + approval_callback=cb, + ) + else: + result = cb( + "rm -rf /", + "dangerous command", + allow_permanent=allow_permanent, + ) + + scheduled["coro"].close() + _, kwargs = request_permission.call_args + return result, kwargs, scheduled, future, loop + + +class TestApprovalBridge: + def test_bridge_schedules_request_on_the_given_loop(self): + result, kwargs, scheduled, _, loop = _invoke_callback( + AllowedOutcome(option_id="allow_once", outcome="selected"), + ) + + tool_call = kwargs["tool_call"] + option_ids = [option.option_id for option in kwargs["options"]] - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future): - cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=timeout) - result = cb("rm -rf /", "dangerous command") - - return result - - -class TestApprovalMapping: - def test_approval_allow_once_maps_correctly(self): - outcome = AllowedOutcome(option_id="allow_once", outcome="selected") - result = _setup_callback(outcome) assert result == "once" + assert scheduled["loop"] is loop + assert inspect.iscoroutine(scheduled["coro"]) + assert kwargs["session_id"] == "s1" + assert tool_call.session_update == "tool_call_update" + assert tool_call.tool_call_id.startswith("perm-check-") + assert tool_call.kind == "execute" + assert tool_call.status == "pending" + assert tool_call.title == "dangerous command" + assert tool_call.raw_input == { + "command": "rm -rf /", + "description": "dangerous command", + } + assert option_ids == ["allow_once", "allow_session", "allow_always", "deny"] + + def test_tool_call_ids_are_unique(self): + _, first_kwargs, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="allow_once", outcome="selected"), + ) + _, second_kwargs, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="allow_once", outcome="selected"), + ) + + assert first_kwargs["tool_call"].tool_call_id != second_kwargs["tool_call"].tool_call_id + + def test_prompt_path_keeps_session_option_when_permanent_disabled(self): + result, kwargs, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="allow_session", outcome="selected"), + allow_permanent=False, + use_prompt_path=True, + ) + + option_ids = [option.option_id for option in kwargs["options"]] + + assert result == "session" + assert option_ids == ["allow_once", "allow_session", "deny"] + + def test_allow_always_maps_correctly(self): + result, _, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="allow_always", outcome="selected"), + use_prompt_path=True, + ) - def test_approval_allow_always_maps_correctly(self): - outcome = AllowedOutcome(option_id="allow_always", outcome="selected") - result = _setup_callback(outcome) assert result == "always" - def test_approval_deny_maps_correctly(self): - outcome = DeniedOutcome(outcome="cancelled") - result = _setup_callback(outcome) - assert result == "deny" + def test_denied_and_unknown_outcomes_deny(self): + denied_result, _, _, _, _ = _invoke_callback(DeniedOutcome(outcome="cancelled")) + unknown_result, _, _, _, _ = _invoke_callback( + AllowedOutcome(option_id="unexpected", outcome="selected"), + ) - def test_approval_timeout_returns_deny(self): - """When the future times out, the callback should return 'deny'.""" - loop = MagicMock(spec=asyncio.AbstractEventLoop) - mock_rp = MagicMock(name="request_permission") + assert denied_result == "deny" + assert unknown_result == "deny" + def test_timeout_returns_deny_and_cancels_future(self): + loop = MagicMock(spec=asyncio.AbstractEventLoop) + request_permission = AsyncMock(name="request_permission") future = MagicMock(spec=Future) future.result.side_effect = TimeoutError("timed out") - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future): - cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=0.01) - result = cb("rm -rf /", "dangerous") + scheduled = {} + + def _schedule(coro, passed_loop): + scheduled["coro"] = coro + scheduled["loop"] = passed_loop + return future + + with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=0.01) + result = cb("rm -rf /", "dangerous command") + + scheduled["coro"].close() assert result == "deny" + assert scheduled["loop"] is loop + assert future.cancel.call_count == 1 - def test_approval_none_response_returns_deny(self): - """When request_permission resolves to None, the callback should return 'deny'.""" + def test_none_response_returns_deny(self): + """When request_permission resolves to None, the callback returns 'deny'.""" loop = MagicMock(spec=asyncio.AbstractEventLoop) - mock_rp = MagicMock(name="request_permission") - + request_permission = AsyncMock(name="request_permission") future = MagicMock(spec=Future) future.result.return_value = None - with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future): - cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=1.0) + scheduled = {} + + def _schedule(coro, passed_loop): + scheduled["coro"] = coro + scheduled["loop"] = passed_loop + return future + + with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule): + cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=1.0) result = cb("echo hi", "demo") + scheduled["coro"].close() + assert result == "deny" diff --git a/tests/agent/lsp/test_delta_key.py b/tests/agent/lsp/test_delta_key.py new file mode 100644 index 000000000000..d20eef1ee727 --- /dev/null +++ b/tests/agent/lsp/test_delta_key.py @@ -0,0 +1,262 @@ +"""Tests for cross-edit LSP delta filtering. + +The delta-filter contract spans three pieces: + + 1. ``agent.lsp.manager._diag_key`` — strict equality key including + the diagnostic's position range. Two diagnostics with the same + content but different lines are NOT equal under this key (they + are genuinely different diagnostics). + 2. ``agent.lsp.range_shift.build_line_shift`` — derives a function + mapping pre-edit line numbers to post-edit line numbers from a + pre/post text pair. + 3. ``agent.lsp.manager.LSPService.get_diagnostics_sync(line_shift=…)`` + — applies the shift to baseline diagnostics before computing the + set-difference, so pre-existing errors at shifted lines hash + equal to their post-edit counterparts and get filtered out. + +These tests exercise the contract at the unit level; the E2E case +(real LSP server, real shift) is covered in test_service.py. +""" +from __future__ import annotations + +from agent.lsp.client import _diagnostic_key +from agent.lsp.manager import _diag_key +from agent.lsp.range_shift import ( + build_line_shift, + shift_baseline, + shift_diagnostic_range, +) + + +def _diag(*, line: int, message: str = "Undefined variable", + severity: int = 1, code: str = "reportUndefinedVariable", + source: str = "Pyright", end_line: int | None = None) -> dict: + if end_line is None: + end_line = line + return { + "severity": severity, + "code": code, + "source": source, + "message": message, + "range": { + "start": {"line": line, "character": 0}, + "end": {"line": end_line, "character": 10}, + }, + } + + +# ---------------------------------------------------------------------- +# _diag_key: strict equality (with range) +# ---------------------------------------------------------------------- + +def test_diag_key_treats_shifted_diagnostics_as_distinct(): + """Two diagnostics with the same message but at different lines hash + differently — they are genuinely different diagnostics. The shift + map is what makes them equal AFTER remapping; the key itself stays + strict.""" + a = _diag(line=100) + b = _diag(line=200) + assert _diag_key(a) != _diag_key(b) + + +def test_diag_key_matches_client_key_for_shifted_baseline(): + """When a baseline diagnostic is remapped through a shift, its + _diag_key must match the corresponding post-edit diagnostic's key + at the same coordinates. This is the contract the delta filter + relies on.""" + pre = _diag(line=200) + # Edit deletes 14 lines above line 200, so the same error now + # appears at line 186 post-edit. + shift = lambda L: L - 14 if L >= 14 else L + shifted = shift_diagnostic_range(pre, shift) + assert shifted is not None + post = _diag(line=186) + assert _diag_key(shifted) == _diag_key(post) + + +def test_diag_key_distinguishes_message(): + a = _diag(line=100, message="foo") + b = _diag(line=100, message="bar") + assert _diag_key(a) != _diag_key(b) + + +def test_diag_key_distinguishes_severity(): + a = _diag(line=100, severity=1) + b = _diag(line=100, severity=2) + assert _diag_key(a) != _diag_key(b) + + +def test_diag_key_distinguishes_source(): + a = _diag(line=100, source="Pyright") + b = _diag(line=100, source="Ruff") + assert _diag_key(a) != _diag_key(b) + + +def test_diag_key_matches_client_key_byte_for_byte(): + """The manager-side and client-side keys must agree on diagnostic + identity — they're used by two layers that need to round-trip the + same diagnostics through dedup and delta filtering.""" + d = _diag(line=42) + assert _diag_key(d) == _diagnostic_key(d) + + +# ---------------------------------------------------------------------- +# build_line_shift +# ---------------------------------------------------------------------- + +def test_shift_identity_for_identical_content(): + shift = build_line_shift("a\nb\nc\n", "a\nb\nc\n") + assert shift(0) == 0 + assert shift(1) == 1 + assert shift(2) == 2 + + +def test_shift_pure_deletion_above_line(): + """Delete 2 lines at the top; everything below shifts up by 2.""" + pre = "line0\nline1\nline2\nline3\nline4\n" + post = "line2\nline3\nline4\n" # deleted lines 0-1 + shift = build_line_shift(pre, post) + # Pre lines 0,1 → deleted → None + assert shift(0) is None + assert shift(1) is None + # Pre line 2 → post line 0 + assert shift(2) == 0 + # Pre line 4 → post line 2 + assert shift(4) == 2 + + +def test_shift_pure_insertion_above_line(): + """Insert 3 lines at the top; everything below shifts down by 3.""" + pre = "line0\nline1\nline2\n" + post = "new0\nnew1\nnew2\nline0\nline1\nline2\n" + shift = build_line_shift(pre, post) + # Pre lines unchanged in identity, shifted by 3 + assert shift(0) == 3 + assert shift(1) == 4 + assert shift(2) == 5 + + +def test_shift_replacement_in_middle(): + """Replace 2 lines in the middle with 1 line. Lines above + unchanged; lines below shift up by 1.""" + pre = "a\nb\nc\nd\ne\n" + post = "a\nb\nX\ne\n" # replaced lines 2,3 (c,d) with X + shift = build_line_shift(pre, post) + assert shift(0) == 0 # a → a + assert shift(1) == 1 # b → b + assert shift(2) is None # c → deleted + assert shift(3) is None # d → deleted + assert shift(4) == 3 # e → post line 3 + + +def test_shift_handles_empty_pre(): + """First write of a file: pre is empty, post has content. Nothing + to shift, so the function should be well-defined for empty pre.""" + shift = build_line_shift("", "hello\nworld\n") + # Any pre line falls past the end of an empty pre — anchor at end of post + assert shift(0) == 1 + + +def test_shift_handles_empty_post(): + """File deleted to empty. Every pre line returns None.""" + shift = build_line_shift("line0\nline1\n", "") + assert shift(0) is None + assert shift(1) is None + + +# ---------------------------------------------------------------------- +# shift_diagnostic_range +# ---------------------------------------------------------------------- + +def test_shift_diag_remaps_start_and_end(): + pre = "a\nb\nc\nd\n" + post = "X\na\nb\nc\nd\n" # one line inserted at top + shift = build_line_shift(pre, post) + d = _diag(line=2, end_line=2) + remapped = shift_diagnostic_range(d, shift) + assert remapped is not None + assert remapped["range"]["start"]["line"] == 3 + assert remapped["range"]["end"]["line"] == 3 + + +def test_shift_diag_drops_diagnostic_in_deleted_region(): + pre = "a\nb\nc\nd\n" + post = "a\nd\n" # deleted lines 1,2 (b,c) + shift = build_line_shift(pre, post) + d = _diag(line=1) + assert shift_diagnostic_range(d, shift) is None + + +def test_shift_diag_does_not_mutate_original(): + pre = "a\nb\n" + post = "X\na\nb\n" + shift = build_line_shift(pre, post) + d = _diag(line=0) + original_line = d["range"]["start"]["line"] + _ = shift_diagnostic_range(d, shift) + assert d["range"]["start"]["line"] == original_line + + +def test_shift_baseline_drops_deleted_and_remaps_rest(): + pre = "a\nb\nc\nd\ne\n" + post = "a\ne\n" # deleted b,c,d + shift = build_line_shift(pre, post) + baseline = [ + _diag(line=0, message="err on a"), + _diag(line=1, message="err on b"), # → deleted + _diag(line=2, message="err on c"), # → deleted + _diag(line=4, message="err on e"), + ] + out = shift_baseline(baseline, shift) + assert [d["message"] for d in out] == ["err on a", "err on e"] + assert out[0]["range"]["start"]["line"] == 0 + assert out[1]["range"]["start"]["line"] == 1 + + +# ---------------------------------------------------------------------- +# End-to-end: simulate the delta-filter pipeline +# ---------------------------------------------------------------------- + +def test_pipeline_filters_shifted_baseline_under_strict_key(): + """The exact scenario the bug fix is for: an edit deletes lines, + every diagnostic below shifts, and the delta filter (strict key + + shifted baseline) correctly identifies them as pre-existing.""" + pre = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n" + # Delete lines 2,3,4 — pre-existing errors at lines 7,8 should + # appear at lines 4,5 post-edit and be filtered out. + post = "line0\nline1\nline5\nline6\nline7\nline8\nline9\n" + shift = build_line_shift(pre, post) + + baseline = [_diag(line=7, message="X"), _diag(line=8, message="Y")] + post_diags = [_diag(line=4, message="X"), _diag(line=5, message="Y")] + + shifted_baseline = shift_baseline(baseline, shift) + seen = {_diag_key(d) for d in shifted_baseline} + new_diags = [d for d in post_diags if _diag_key(d) not in seen] + + # Both errors were pre-existing — filtered out. + assert new_diags == [] + + +def test_pipeline_preserves_new_instance_at_different_line(): + """The case content-only keys would miss: the model introduces a + SECOND instance of the same error class at a new location. The + new instance must surface.""" + pre = "good\ngood\ngood\n" + post = "good\nbad\ngood\nbad\n" # added 2 new error lines + shift = build_line_shift(pre, post) + + baseline = [_diag(line=0, message="bad style")] # pre-existing + post_diags = [ + _diag(line=0, message="bad style"), # pre-existing + _diag(line=1, message="bad style"), # NEW — different line + _diag(line=3, message="bad style"), # NEW — different line + ] + + shifted_baseline = shift_baseline(baseline, shift) + seen = {_diag_key(d) for d in shifted_baseline} + new_diags = [d for d in post_diags if _diag_key(d) not in seen] + + # Two genuinely new instances must be surfaced. + assert len(new_diags) == 2 + assert {d["range"]["start"]["line"] for d in new_diags} == {1, 3} diff --git a/tests/agent/lsp/test_service.py b/tests/agent/lsp/test_service.py index 6eed8f7fd993..952a8519adcd 100644 --- a/tests/agent/lsp/test_service.py +++ b/tests/agent/lsp/test_service.py @@ -130,6 +130,35 @@ def test_service_e2e_delta_filter(mock_pyright): svc.shutdown() +def test_service_e2e_delta_filter_with_line_shift(mock_pyright): + """End-to-end: an edit that shifts the diagnostic's line still + filters correctly when ``line_shift`` is supplied. + + The mock LSP server emits a fixed error at line 0; for this test + we don't need to actually shift the server's output — we just + need to prove that supplying a line_shift through the API works + and doesn't break the existing delta path. The unit tests in + test_delta_key.py cover the shift semantics in detail. + """ + repo = mock_pyright + f = repo / "x.py" + f.write_text("print('hi')\n") + + svc = LSPService( + enabled=True, + wait_mode="document", + wait_timeout=3.0, + install_strategy="manual", + ) + try: + svc.snapshot_baseline(str(f)) + # Identity shift — should behave exactly like no shift. + new_diags = svc.get_diagnostics_sync(str(f), line_shift=lambda L: L) + assert new_diags == [] + finally: + svc.shutdown() + + def test_service_status_includes_clients(mock_pyright): repo = mock_pyright f = repo / "x.py" diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index 6c51288461e0..04c0913f2897 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -12,12 +12,24 @@ import json import os import time -from types import SimpleNamespace +from contextlib import contextmanager +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, patch, PropertyMock import pytest +@contextmanager +def _mock_botocore_session(*, return_value=None, side_effect=None): + """Patch botocore.session even when botocore is not installed.""" + botocore_mod = ModuleType("botocore") + session_mod = ModuleType("botocore.session") + session_mod.get_session = MagicMock(return_value=return_value, side_effect=side_effect) + botocore_mod.session = session_mod + with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}): + yield session_mod.get_session + + # --------------------------------------------------------------------------- # AWS credential detection # --------------------------------------------------------------------------- @@ -120,7 +132,7 @@ def test_defaults_to_us_east_1(self): from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = None - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): assert resolve_bedrock_region({}) == "us-east-1" def test_falls_back_to_botocore_profile_region(self): @@ -128,13 +140,13 @@ def test_falls_back_to_botocore_profile_region(self): from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): assert resolve_bedrock_region({}) == "eu-central-1" def test_botocore_failure_falls_back_to_us_east_1(self): from agent.bedrock_adapter import resolve_bedrock_region from unittest.mock import patch - with patch("botocore.session.get_session", side_effect=Exception("no botocore")): + with _mock_botocore_session(side_effect=Exception("no botocore")): assert resolve_bedrock_region({}) == "us-east-1" diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index 954075ab7228..a5ab3563381f 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -253,20 +253,24 @@ def test_context_overflow_patterns(self): # --------------------------------------------------------------------------- class TestPackaging: - """Verify bedrock optional dependency is declared.""" + """Verify Bedrock remains a declared lazy optional dependency.""" - def test_bedrock_extra_exists(self): - import configparser + @staticmethod + def _optional_dependencies(): + import tomllib from pathlib import Path - # Read pyproject.toml to verify [bedrock] extra - toml_path = Path(__file__).parent.parent.parent / "pyproject.toml" - content = toml_path.read_text() - assert 'bedrock = ["boto3' in content - def test_bedrock_in_all_extra(self): - from pathlib import Path content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() - assert '"hermes-agent[bedrock]"' in content + return tomllib.loads(content)["project"]["optional-dependencies"] + + def test_bedrock_extra_exists(self): + extras = self._optional_dependencies() + assert "bedrock" in extras + assert any(dep.startswith("boto3==") for dep in extras["bedrock"]) + + def test_bedrock_is_not_eager_installed_by_all_extra(self): + extras = self._optional_dependencies() + assert "hermes-agent[bedrock]" not in extras["all"] # --------------------------------------------------------------------------- diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 97a7c7b3d0ff..559cf2237a25 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -991,9 +991,12 @@ def test_summary_role_avoids_consecutive_user_when_head_ends_with_user(self): mock_client.chat.completions.create.return_value = mock_response with patch("agent.context_compressor.get_model_context_length", return_value=100000): - c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3, protect_last_n=2) + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) # Last head message (index 2) is "user" → summary should be "assistant" + # NOTE: protect_first_n=2 preserves 2 non-system messages in addition to + # the system prompt (always implicitly protected), yielding head [system, + # user, user] with last head = user. msgs = [ {"role": "system", "content": "system prompt"}, {"role": "user", "content": "msg 1"}, @@ -1059,11 +1062,13 @@ def test_double_collision_merges_summary_into_tail(self): mock_response.choices[0].message.content = "summary text" with patch("agent.context_compressor.get_model_context_length", return_value=100000): - c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3, protect_last_n=3) + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3) # Head: [system, user, assistant] → last head = assistant # Tail: [user, assistant, user] → first tail = user # summary_role="user" collides with tail, "assistant" collides with head → merge + # NOTE: protect_first_n=2 preserves 2 non-system messages in addition to + # the system prompt (always implicitly protected). msgs = [ {"role": "system", "content": "system prompt"}, {"role": "user", "content": "msg 1"}, @@ -1097,7 +1102,7 @@ def test_double_collision_merges_summary_into_list_tail_content(self): mock_response.choices[0].message.content = "summary text" with patch("agent.context_compressor.get_model_context_length", return_value=100000): - c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3, protect_last_n=3) + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3) msgs = [ {"role": "system", "content": "system prompt"}, @@ -1133,13 +1138,15 @@ def test_double_collision_user_head_assistant_tail(self): mock_response.choices[0].message.content = "summary text" with patch("agent.context_compressor.get_model_context_length", return_value=100000): - c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=2) # Head: [system, user] → last head = user # Tail: [assistant, user, assistant] → first tail = assistant # summary_role="assistant" collides with tail, "user" collides with head → merge + # NOTE: protect_first_n=1 preserves 1 non-system message in addition to + # the system prompt (always implicitly protected). # With min_tail=3, tail = last 3 messages (indices 5-7). - # Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6. + # Need 8 messages: _min_for_compress = head(2) + 3 + 1 = 6, must have > 6. msgs = [ {"role": "system", "content": "system prompt"}, {"role": "user", "content": "msg 1"}, @@ -1292,6 +1299,92 @@ def test_default_protect_last_n_is_20(self): c = ContextCompressor(model="test", quiet_mode=True) assert c.protect_last_n == 20 + def test_default_protect_first_n_is_3(self): + """Default protect_first_n is 3 (system + 3 extra non-system messages = + 4 protected messages total when a system prompt is present). With the + new semantics, the constructor default is 3 — the system prompt is + always implicitly protected ON TOP OF protect_first_n non-system + messages. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor(model="test", quiet_mode=True) + assert c.protect_first_n == 3 + + def test_protect_first_n_override(self): + """protect_first_n=0 should be honoured — for users who rely on rolling + compaction and want NOTHING pinned at head except the system prompt + (always implicitly protected).""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=0) + assert c.protect_first_n == 0 + + def test_protect_first_n_0_preserves_only_system_prompt(self): + """End-to-end: when protect_first_n=0, compression should treat only + the system prompt as head. All user/assistant messages between the + system prompt and the protected tail become summarization candidates. + + This is the cleanest configuration for long-running rolling-compaction + sessions — no user/assistant turn gets pinned verbatim forever just + because it happened to be early in the session.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=0, + protect_last_n=2, + ) + msgs = ( + [{"role": "system", "content": "System prompt"}] + + [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} + for i in range(8)] + ) + result = c.compress(msgs) + # System prompt (msg[0]) survives as head + assert result[0]["role"] == "system" + assert result[0]["content"].startswith("System prompt") + # The first user/assistant exchange (msg 0, msg 1) should NOT be pinned + # as head verbatim — those would have been summarized or absorbed. + # Under default protect_first_n=3, result[1..3] would be the literal + # "msg 0" / "msg 1" / "msg 2"; with protect_first_n=0 they aren't. + assert result[1].get("content") != "msg 0" + # Last 2 messages are tail-protected under protect_last_n=2 + assert result[-1]["content"] == msgs[-1]["content"] + + def test_protect_first_n_semantics_stable_without_system_prompt(self): + """Regression: gateway /compress handler strips the system prompt + before calling compress(). protect_first_n must mean the same thing + in both paths — "N non-system head messages" — so configuring + protect_first_n=0 preserves NOTHING at the head regardless of whether + the system prompt is in the messages list. + + Bug this covers: under the old semantics, protect_first_n counted + literally from messages[0]. In the gateway path (no system prompt) + that meant protect_first_n=1 would pin the first user turn of the + session forever — a user-reported complaint that a week-old + resolved question kept getting reinserted into every compaction + summary.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=0, + protect_last_n=2, + ) + # No system prompt — this is what the gateway passes to compress(). + msgs = [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} + for i in range(10) + ] + head_size = c._protect_head_size(msgs) + # With no system prompt and protect_first_n=0 → head is empty. + # The first user message is NOT pinned as head. + assert head_size == 0 + + # And with protect_first_n=3 on the same no-system-prompt list → + # head size is 3 (the three earliest non-system messages). + c.protect_first_n = 3 + assert c._protect_head_size(msgs) == 3 + class TestTokenBudgetTailProtection: """Tests for token-budget-based tail protection (PR #6240). diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index d9a273758347..d797b661f01e 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -27,10 +27,12 @@ def _messages_with_handoff(summary_body: str): return [ {"role": "system", "content": "system prompt"}, {"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"}, + {"role": "assistant", "content": "handoff acknowledged after resume"}, {"role": "user", "content": "new user turn after resume"}, {"role": "assistant", "content": "new assistant work after resume"}, {"role": "user", "content": "more new work after resume"}, {"role": "assistant", "content": "latest tail response"}, + {"role": "user", "content": "final active request stays in protected tail"}, ] diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py index dc2b1b15311c..480f562aa647 100644 --- a/tests/agent/test_gemini_cloudcode.py +++ b/tests/agent/test_gemini_cloudcode.py @@ -913,6 +913,35 @@ def test_finish_reason_switches_to_tool_calls_when_any_seen(self): assert chunks[-1].choices[0].finish_reason == "tool_calls" +class TestMakeStreamChunk: + def test_reasoning_only_chunk_has_content_none(self): + from agent.gemini_cloudcode_adapter import _make_stream_chunk + + chunk = _make_stream_chunk(model="m", reasoning="think") + delta = chunk.choices[0].delta + assert delta.content is None + assert delta.reasoning == "think" + + def test_content_only_chunk_has_reasoning_none(self): + from agent.gemini_cloudcode_adapter import _make_stream_chunk + + chunk = _make_stream_chunk(model="m", content="hello") + delta = chunk.choices[0].delta + assert delta.content == "hello" + assert delta.reasoning is None + assert delta.tool_calls is None + + def test_finish_only_chunk_has_all_fields_none(self): + from agent.gemini_cloudcode_adapter import _make_stream_chunk + + chunk = _make_stream_chunk(model="m", finish_reason="stop") + delta = chunk.choices[0].delta + assert delta.content is None + assert delta.reasoning is None + assert delta.tool_calls is None + assert chunk.choices[0].finish_reason == "stop" + + class TestGeminiCloudCodeClient: def test_client_exposes_openai_interface(self): from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient diff --git a/tests/agent/test_video_gen_registry.py b/tests/agent/test_video_gen_registry.py new file mode 100644 index 000000000000..a6439ec92fcf --- /dev/null +++ b/tests/agent/test_video_gen_registry.py @@ -0,0 +1,114 @@ +"""Tests for agent/video_gen_registry.py — provider registration & active lookup.""" + +from __future__ import annotations + +import pytest + +from agent import video_gen_registry +from agent.video_gen_provider import VideoGenProvider + + +class _FakeProvider(VideoGenProvider): + def __init__(self, name: str, available: bool = True): + self._name = name + self._available = available + + @property + def name(self) -> str: + return self._name + + def is_available(self) -> bool: + return self._available + + def generate(self, prompt, **kw): + return {"success": True, "video": f"{self._name}://{prompt}"} + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +class TestRegisterProvider: + def test_register_and_lookup(self): + provider = _FakeProvider("fake") + video_gen_registry.register_provider(provider) + assert video_gen_registry.get_provider("fake") is provider + + def test_rejects_non_provider(self): + with pytest.raises(TypeError): + video_gen_registry.register_provider("not a provider") # type: ignore[arg-type] + + def test_rejects_empty_name(self): + class Empty(VideoGenProvider): + @property + def name(self) -> str: + return "" + + def generate(self, prompt, **kw): + return {} + + with pytest.raises(ValueError): + video_gen_registry.register_provider(Empty()) + + def test_reregister_overwrites(self): + a = _FakeProvider("same") + b = _FakeProvider("same") + video_gen_registry.register_provider(a) + video_gen_registry.register_provider(b) + assert video_gen_registry.get_provider("same") is b + + def test_list_is_sorted(self): + video_gen_registry.register_provider(_FakeProvider("zeta")) + video_gen_registry.register_provider(_FakeProvider("alpha")) + names = [p.name for p in video_gen_registry.list_providers()] + assert names == ["alpha", "zeta"] + + +class TestGetActiveProvider: + def test_single_provider_autoresolves(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + video_gen_registry.register_provider(_FakeProvider("solo")) + active = video_gen_registry.get_active_provider() + assert active is not None and active.name == "solo" + + def test_no_provider_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + assert video_gen_registry.get_active_provider() is None + + def test_multi_without_config_returns_none(self, tmp_path, monkeypatch): + """Unlike image_gen (which falls back to 'fal'), video_gen has no + legacy default — when there are multiple providers and no config, + the registry returns None and the tool surfaces a helpful error. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + video_gen_registry.register_provider(_FakeProvider("xai")) + video_gen_registry.register_provider(_FakeProvider("fal")) + assert video_gen_registry.get_active_provider() is None + + def test_config_selects_provider(self, tmp_path, monkeypatch): + import yaml + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"video_gen": {"provider": "fal"}}) + ) + video_gen_registry.register_provider(_FakeProvider("xai")) + video_gen_registry.register_provider(_FakeProvider("fal")) + active = video_gen_registry.get_active_provider() + assert active is not None and active.name == "fal" + + def test_unknown_config_falls_back(self, tmp_path, monkeypatch): + """If video_gen.provider names a provider that isn't registered, + the single-provider fallback still applies.""" + import yaml + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"video_gen": {"provider": "ghost"}}) + ) + video_gen_registry.register_provider(_FakeProvider("only")) + active = video_gen_registry.get_active_provider() + assert active is not None and active.name == "only" diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py new file mode 100644 index 000000000000..d12ac2272542 --- /dev/null +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -0,0 +1,243 @@ +"""Tests for the optional codex app-server runtime gate. + +These are unit tests for the api_mode rewriter and the wire-level transport +module. They do NOT require the `codex` CLI to be installed — that's +covered by a separate live test gated on `codex --version`. +""" + +from __future__ import annotations + +import pytest + +from hermes_cli.runtime_provider import ( + _VALID_API_MODES, + _maybe_apply_codex_app_server_runtime, +) + + +class TestApiModeRegistration: + """The new api_mode must be registered or downstream parsing rejects it.""" + + def test_codex_app_server_is_a_valid_api_mode(self) -> None: + assert "codex_app_server" in _VALID_API_MODES + + def test_existing_api_modes_still_present(self) -> None: + # Regression guard: don't accidentally delete other api_modes when + # touching this set. + for mode in ( + "chat_completions", + "codex_responses", + "anthropic_messages", + "bedrock_converse", + ): + assert mode in _VALID_API_MODES + + +class TestMaybeApplyCodexAppServerRuntime: + """The opt-in helper that rewrites api_mode → codex_app_server.""" + + @pytest.mark.parametrize( + "model_cfg", + [ + None, + {}, + {"openai_runtime": ""}, + {"openai_runtime": "auto"}, + {"openai_runtime": "AUTO"}, + {"other_key": "codex_app_server"}, # wrong key + ], + ) + def test_default_off_for_openai(self, model_cfg) -> None: + """Default behavior is preserved when the flag is unset/auto.""" + got = _maybe_apply_codex_app_server_runtime( + provider="openai", api_mode="chat_completions", model_cfg=model_cfg + ) + assert got == "chat_completions" + + def test_opt_in_rewrites_openai(self) -> None: + got = _maybe_apply_codex_app_server_runtime( + provider="openai", + api_mode="chat_completions", + model_cfg={"openai_runtime": "codex_app_server"}, + ) + assert got == "codex_app_server" + + def test_opt_in_rewrites_openai_codex(self) -> None: + got = _maybe_apply_codex_app_server_runtime( + provider="openai-codex", + api_mode="codex_responses", + model_cfg={"openai_runtime": "codex_app_server"}, + ) + assert got == "codex_app_server" + + def test_case_insensitive(self) -> None: + got = _maybe_apply_codex_app_server_runtime( + provider="openai", + api_mode="chat_completions", + model_cfg={"openai_runtime": "Codex_App_Server"}, + ) + assert got == "codex_app_server" + + @pytest.mark.parametrize( + "provider", + [ + "anthropic", + "openrouter", + "xai", + "qwen-oauth", + "google-gemini-cli", + "opencode-zen", + "bedrock", + "", + ], + ) + def test_other_providers_never_rerouted(self, provider) -> None: + """Non-OpenAI providers MUST NOT be rerouted even with the flag set — + codex's app-server can only run OpenAI/Codex auth flows.""" + got = _maybe_apply_codex_app_server_runtime( + provider=provider, + api_mode="anthropic_messages", + model_cfg={"openai_runtime": "codex_app_server"}, + ) + assert got == "anthropic_messages", ( + f"provider={provider!r} should not be rerouted to codex_app_server" + ) + + +class TestCodexAppServerModule: + """Module-surface tests for the JSON-RPC speaker. Don't require codex CLI.""" + + def test_module_imports(self) -> None: + from agent.transports import codex_app_server + + assert codex_app_server.MIN_CODEX_VERSION >= (0, 1, 0) + assert callable(codex_app_server.parse_codex_version) + assert callable(codex_app_server.check_codex_binary) + + def test_parse_codex_version_valid(self) -> None: + from agent.transports.codex_app_server import parse_codex_version + + assert parse_codex_version("codex-cli 0.130.0") == (0, 130, 0) + assert parse_codex_version("codex-cli 1.2.3 (extra metadata)") == (1, 2, 3) + assert parse_codex_version("codex 99.0.1\n") == (99, 0, 1) + + def test_parse_codex_version_invalid(self) -> None: + from agent.transports.codex_app_server import parse_codex_version + + assert parse_codex_version("nope") is None + assert parse_codex_version("") is None + assert parse_codex_version(None) is None # type: ignore[arg-type] + + def test_check_binary_handles_missing_executable(self) -> None: + from agent.transports.codex_app_server import check_codex_binary + + ok, msg = check_codex_binary(codex_bin="/nonexistent/codex/binary/path") + assert ok is False + assert "not found" in msg.lower() or "no such" in msg.lower() + + def test_codex_error_class_is_runtimeerror(self) -> None: + from agent.transports.codex_app_server import CodexAppServerError + + err = CodexAppServerError(code=-32600, message="boom") + assert isinstance(err, RuntimeError) + assert "boom" in str(err) + assert "-32600" in str(err) + + +class TestSpawnEnvIsolation: + """The codex spawn must NOT rewrite HOME — codex's shell tool spawns + subprocesses (gh, git, npm, aws, gcloud, ...) that need to find their + config in the real user $HOME. CODEX_HOME isolates codex's own state, + HOME stays unchanged. + + OpenClaw hit this footgun (openclaw/openclaw#81562) — they were + rewriting HOME to a synthetic per-agent dir alongside CODEX_HOME, + and then `gh auth status` / git config / etc. all broke inside codex + shell calls. We avoid the same bug by only overlaying CODEX_HOME and + RUST_LOG on top of os.environ.copy(). + """ + + def test_spawn_env_preserves_HOME(self, monkeypatch): + """The spawn env must contain the parent process's HOME unchanged. + Verifies via a subprocess-monkey-patch.""" + import subprocess + from agent.transports import codex_app_server as cas + + captured = {} + + class FakePopen: + def __init__(self, cmd, *args, **kwargs): + captured["env"] = kwargs.get("env", {}).copy() + # Provide minimal Popen surface so __init__ doesn't crash + # on attribute access during construction. + self.stdin = None + self.stdout = None + self.stderr = None + self.pid = 1 + self.returncode = None + + def poll(self): + return None + + def terminate(self): + pass + + def wait(self, timeout=None): + return 0 + + def kill(self): + pass + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setenv("HOME", "/users/alice") + + client = cas.CodexAppServerClient(codex_bin="codex") + client._closed = True # so close() is a no-op + + # The spawn env must have HOME=/users/alice unchanged + assert captured["env"].get("HOME") == "/users/alice", ( + f"HOME got rewritten in codex spawn env: " + f"{captured['env'].get('HOME')!r}. Codex's shell tool's " + "subprocesses (gh, git, aws, npm) need the user's real HOME." + ) + + def test_spawn_env_sets_CODEX_HOME_when_provided(self, monkeypatch): + """CODEX_HOME isolation must still work — that's the whole point + of the codex_home arg.""" + import subprocess + from agent.transports import codex_app_server as cas + + captured = {} + + class FakePopen: + def __init__(self, cmd, *args, **kwargs): + captured["env"] = kwargs.get("env", {}).copy() + self.stdin = None + self.stdout = None + self.stderr = None + self.pid = 1 + self.returncode = None + + def poll(self): + return None + + def terminate(self): + pass + + def wait(self, timeout=None): + return 0 + + def kill(self): + pass + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setenv("HOME", "/users/alice") + + client = cas.CodexAppServerClient( + codex_bin="codex", codex_home="/tmp/profile/codex" + ) + client._closed = True + + assert captured["env"].get("CODEX_HOME") == "/tmp/profile/codex" + # And HOME still passes through unchanged + assert captured["env"].get("HOME") == "/users/alice" diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py new file mode 100644 index 000000000000..f51996dd067b --- /dev/null +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -0,0 +1,976 @@ +"""Tests for CodexAppServerSession — drive turns through a mock client. + +The session adapter has the most complex behavior of the three new modules: +notification draining, server-request handling (approvals), interrupt, +deadline timeouts. These tests pin all of that without spawning real codex. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Optional + +import pytest + +from agent.transports.codex_app_server_session import ( + CodexAppServerSession, + TurnResult, + _ServerRequestRouting, + _approval_choice_to_codex_decision, +) + + +class FakeClient: + """Stand-in for CodexAppServerClient that records calls and lets the test + drive the notification / server-request streams synchronously.""" + + def __init__(self, *, codex_bin: str = "codex", codex_home=None) -> None: + self.codex_bin = codex_bin + self.codex_home = codex_home + self.requests: list[tuple[str, dict]] = [] + self.notifications_responses: list[dict] = [] + self.responses: list[tuple[Any, dict]] = [] + self.error_responses: list[tuple[Any, int, str]] = [] + self._initialized = False + self._closed = False + self._notifications: list[dict] = [] + self._server_requests: list[dict] = [] + self._request_handler = None # Optional[Callable[[str, dict], dict]] + + # API matching CodexAppServerClient + def initialize(self, **kwargs): + self._initialized = True + return {"userAgent": "fake/0.0.0", "codexHome": "/tmp", + "platformOs": "linux", "platformFamily": "unix"} + + def request(self, method: str, params: Optional[dict] = None, timeout: float = 30.0): + self.requests.append((method, params or {})) + if self._request_handler is not None: + return self._request_handler(method, params or {}) + # Sensible defaults for protocol methods used by the session + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}, + "activePermissionProfile": {"id": "workspace-write"}} + if method == "turn/start": + return {"turn": {"id": "turn-fake-001"}} + if method == "turn/interrupt": + return {} + return {} + + def notify(self, method: str, params=None): + pass + + def respond(self, request_id, result): + self.responses.append((request_id, result)) + + def respond_error(self, request_id, code, message, data=None): + self.error_responses.append((request_id, code, message)) + + def take_notification(self, timeout: float = 0.0): + if self._notifications: + return self._notifications.pop(0) + # Honor a tiny sleep so the loop doesn't hot-spin; the real client + # blocks on a queue. For tests we want determinism. + if timeout > 0: + time.sleep(min(timeout, 0.001)) + return None + + def take_server_request(self, timeout: float = 0.0): + if self._server_requests: + return self._server_requests.pop(0) + return None + + def close(self): + self._closed = True + + def is_alive(self) -> bool: + # Fake is "alive" until close() is called; tests that want a dead + # subprocess can patch this attribute or call close() directly. + return not self._closed + + def stderr_tail(self, n: int = 20): + return list(getattr(self, "_stderr_tail", []))[-n:] + + # Test helpers + def queue_notification(self, method: str, **params): + self._notifications.append({"method": method, "params": params}) + + def queue_server_request(self, method: str, request_id: Any = "srv-1", **params): + self._server_requests.append({"id": request_id, "method": method, "params": params}) + + def set_stderr_tail(self, lines): + """Test helper: seed stderr_tail() output for OAuth-refresh classifier tests.""" + self._stderr_tail = list(lines) + + +def make_session(client: FakeClient, **kwargs) -> CodexAppServerSession: + return CodexAppServerSession( + cwd="/tmp", + client_factory=lambda **kw: client, + **kwargs, + ) + + +# ---- choice mapping ---- + +class TestApprovalChoiceMapping: + @pytest.mark.parametrize("choice,expected", [ + ("once", "accept"), + ("session", "acceptForSession"), + ("always", "acceptForSession"), + ("deny", "decline"), + ("anything-else", "decline"), + ]) + def test_mapping(self, choice, expected): + assert _approval_choice_to_codex_decision(choice) == expected + + +# ---- lifecycle ---- + +class TestLifecycle: + def test_ensure_started_is_idempotent(self): + client = FakeClient() + s = make_session(client) + tid_a = s.ensure_started() + tid_b = s.ensure_started() + assert tid_a == tid_b == "thread-fake-001" + # thread/start should be called exactly once + method_calls = [m for (m, _) in client.requests if m == "thread/start"] + assert len(method_calls) == 1 + + def test_thread_start_passes_cwd_only(self): + """thread/start carries cwd. We intentionally do NOT pass `permissions` + on this codex version (experimentalApi-gated + requires matching + config.toml [permissions] table). Letting codex use its default + (read-only unless user configures otherwise) is the documented path.""" + client = FakeClient() + s = make_session(client, permission_profile="workspace-write") + s.ensure_started() + method, params = next(r for r in client.requests if r[0] == "thread/start") + assert params["cwd"] == "/tmp" + assert "permissions" not in params # see session.ensure_started() comment + + def test_close_idempotent(self): + client = FakeClient() + s = make_session(client) + s.ensure_started() + s.close() + s.close() + assert client._closed is True + + +# ---- turn loop ---- + +class TestRunTurn: + def test_simple_text_turn_returns_final_message(self): + client = FakeClient() + client.queue_notification("turn/started", threadId="t", turn={"id": "tu1"}) + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "hello world"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", + threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.final_text == "hello world" + assert r.interrupted is False + assert r.error is None + assert any(m["role"] == "assistant" and m.get("content") == "hello world" + for m in r.projected_messages) + # turn_id propagated for downstream session-DB linkage + assert r.turn_id == "turn-fake-001" + + def test_tool_iteration_counter_ticks(self): + client = FakeClient() + # Two completed exec items + one final agent message + for i, item_id in enumerate(("ex1", "ex2"), start=1): + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": item_id, + "command": f"cmd{i}", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "ok", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "done"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("do stuff", turn_timeout=2.0) + assert r.tool_iterations == 2 + # Each tool item produces (assistant, tool) — 2*2 + final assistant = 5 msgs + assert len(r.projected_messages) == 5 + + def test_turn_start_failure_returns_error(self): + client = FakeClient() + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32600, message="bad input") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "bad input" in r.error + assert r.final_text == "" + + def test_turn_start_failure_attaches_redacted_stderr_tail(self): + """When codex stderr has content (non-OAuth), the tail gets attached + to the user-facing error so config/provider problems are debuggable + instead of just 'Internal error'. Secrets in stderr are redacted + via agent.redact(force=True).""" + client = FakeClient() + client.set_stderr_tail([ + "ERROR: provider auth failed", + "Authorization: Bearer sk-live-deadbeefdeadbeef", + "url=https://api.example.com/v1?token=querysecret12345", + ]) + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32603, message="Internal error") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "turn/start failed" in r.error + assert "Internal error" in r.error + # Stderr tail attached + assert "codex stderr" in r.error + assert "provider auth failed" in r.error + # Secrets redacted + assert "sk-live-deadbeefdeadbeef" not in r.error + assert "querysecret12345" not in r.error + # Non-OAuth → should NOT retire (subprocess JSON-RPC is still healthy). + assert r.should_retire is False + + def test_turn_start_timeout_attaches_redacted_stderr_tail(self): + """A non-OAuth TimeoutError on turn/start surfaces with codex stderr + context attached and marks the session for retirement.""" + client = FakeClient() + client.set_stderr_tail([ + "WARN: provider request stalled", + "Authorization: Bearer sk-stalled-secret-abc123", + ]) + + def stall(method, params): + if method == "turn/start": + raise TimeoutError("codex method 'turn/start' timed out after 10s") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = stall + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "turn/start timed out" in r.error + assert "provider request stalled" in r.error + assert "sk-stalled-secret-abc123" not in r.error + assert r.should_retire is True + + def test_startup_failure_returns_error_with_stderr(self): + """Codex thread/start failures during ensure_started() used to bubble + up as uncaught exceptions. Now they return a TurnResult.error so + AIAgent surfaces a clean diagnostic instead of crashing the turn.""" + client = FakeClient() + client.set_stderr_tail([ + "FATAL: model_provider 'azure_foundry' not configured", + ]) + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "thread/start": + raise CodexAppServerError(code=-32603, message="Internal error") + return {} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "startup failed" in r.error + assert "model_provider 'azure_foundry' not configured" in r.error + assert r.should_retire is True + assert r.final_text == "" + + def test_interrupt_during_turn_issues_turn_interrupt(self): + client = FakeClient() + # Don't queue turn/completed — the loop has to interrupt out + client.queue_notification( + "item/completed", + item={"type": "commandExecution", "id": "x", "command": "sleep 60", + "cwd": "/", "status": "inProgress", + "aggregatedOutput": None, "exitCode": None, + "commandActions": []}, + threadId="t", turnId="tu1", + ) + s = make_session(client) + s.ensure_started() + # Trip the interrupt before run_turn even consumes the notification. + # The loop will see interrupt set on its first iteration and bail. + s.request_interrupt() + r = s.run_turn("loop forever", turn_timeout=2.0) + assert r.interrupted is True + # turn/interrupt was requested with the right turnId + assert any( + method == "turn/interrupt" and params.get("turnId") == "turn-fake-001" + for (method, params) in client.requests + ) + + def test_deadline_exceeded_records_error(self): + client = FakeClient() + # No notifications and no completion → must hit deadline + s = make_session(client) + r = s.run_turn("never finishes", turn_timeout=0.05, + notification_poll_timeout=0.01) + assert r.interrupted is True + assert r.error and "timed out" in r.error + + def test_failed_turn_records_error_from_turn_completed(self): + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "failed", + "error": {"message": "model error"}}, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0) + assert r.error and "model error" in r.error + + +# ---- approval bridge ---- + +class TestServerRequestRouting: + def test_exec_approval_with_callback_approves_once(self): + client = FakeClient() + client.queue_server_request( + "item/commandExecution/requestApproval", request_id="req-1", + command="ls /tmp", cwd="/tmp", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + captured: dict = {} + + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + captured["description"] = description + return "once" + + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + assert captured["command"] == "ls /tmp" + # The session must have responded to the server request with "accept" + assert ("req-1", {"decision": "accept"}) in client.responses + + def test_exec_approval_no_callback_denies(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="req-1", + command="rm -rf /", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) # no approval_callback wired + s.run_turn("hi", turn_timeout=1.0) + assert ("req-1", {"decision": "decline"}) in client.responses + + def test_apply_patch_approval_session_maps_to_session_decision(self): + client = FakeClient() + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-1", + turnId="t1", + threadId="th", + startedAtMs=1234567890, + reason="create new file with hello() function", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + def cb(command, description, *, allow_permanent=True): + return "session" + + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + assert ("req-2", {"decision": "acceptForSession"}) in client.responses + + def test_unknown_server_request_replied_with_error(self): + client = FakeClient() + client.queue_server_request("totally/unknown", request_id="req-3") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert any( + rid == "req-3" and code == -32601 + for (rid, code, _msg) in client.error_responses + ) + + def test_mcp_elicitation_for_hermes_tools_auto_accepts(self): + """When codex elicits on behalf of hermes-tools (our own callback), + accept automatically — the user already opted in by enabling the + runtime.""" + client = FakeClient() + client.queue_server_request( + "mcpServer/elicitation/request", request_id="elic-1", + threadId="t", turnId="tu1", + serverName="hermes-tools", + mode="form", + message="confirm", + requestedSchema={"type": "object", "properties": {}}, + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert ("elic-1", {"action": "accept", "content": None, "_meta": None}) in client.responses + + def test_mcp_elicitation_for_other_servers_declines(self): + """For third-party MCP servers we decline by default so users + explicitly opt in through codex's own UI.""" + client = FakeClient() + client.queue_server_request( + "mcpServer/elicitation/request", request_id="elic-2", + threadId="t", turnId="tu1", + serverName="some-third-party", + mode="url", + message="please log in", + url="https://example.com/oauth", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert ("elic-2", {"action": "decline", "content": None, "_meta": None}) in client.responses + + def test_routing_auto_approve_bypass(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="r1", + command="ls", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + # No callback, but routing says auto-approve. Should approve. + s = make_session(client, request_routing=_ServerRequestRouting( + auto_approve_exec=True)) + s.run_turn("hi", turn_timeout=1.0) + assert ("r1", {"decision": "accept"}) in client.responses + + def test_callback_raises_falls_back_to_decline(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="r1", + command="ls", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + def boom(*a, **kw): + raise RuntimeError("ui crashed") + + s = make_session(client, approval_callback=boom) + s.run_turn("hi", turn_timeout=1.0) + # Fail-closed: deny on callback exception + assert ("r1", {"decision": "decline"}) in client.responses + + +# ---- enriched approval prompts ---- + +class TestApprovalPromptEnrichment: + """Quirk #4: apply_patch prompt should show what's changing. + Quirk #10: exec prompt should never show empty cwd.""" + + def test_exec_falls_back_to_session_cwd(self): + """When codex omits cwd from the approval params, the prompt shows + the session cwd, not an empty string.""" + client = FakeClient() + client.queue_server_request( + "item/commandExecution/requestApproval", request_id="r1", + command="ls", # no cwd + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["description"] = description + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Session cwd is /tmp by default in make_session() + assert "/tmp" in captured["description"] + assert "Codex requests exec in " not in captured["description"] + + def test_apply_patch_prompt_summarizes_pending_changes(self): + """When the projector has cached the fileChange item from item/started, + the approval prompt surfaces the change summary.""" + client = FakeClient() + # item/started fires first (carries the changes), then approval request + client.queue_notification( + "item/started", + item={"type": "fileChange", "id": "fc-1", + "changes": [ + {"kind": {"type": "add"}, "path": "/tmp/new.py"}, + {"kind": {"type": "update"}, "path": "/tmp/old.py"}, + ]}, + threadId="t", turnId="tu1", + ) + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-1", turnId="tu1", threadId="t", + startedAtMs=1234567890, + reason="add and update files", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + captured["description"] = description + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Both add and update kinds should be in the summary + assert "1 add" in captured["command"] or "1 add" in captured["description"] + assert "1 update" in captured["command"] or "1 update" in captured["description"] + # And at least one of the paths + joined = captured["command"] + " " + captured["description"] + assert "/tmp/new.py" in joined or "/tmp/old.py" in joined + + def test_apply_patch_prompt_works_without_cached_summary(self): + """When approval arrives before item/started (or without changes + info), prompt falls back to whatever codex provided.""" + client = FakeClient() + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-orphan", turnId="tu1", threadId="t", + startedAtMs=1234567890, + reason="apply some changes", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Falls back to the reason + assert "apply some changes" in captured["command"] + + +# ---- openclaw beta.8 parity: retire/wedge/oauth/abort marker ---- + +class TestSessionRetirement: + """Mirrors openclaw beta.8's resilience fixes: + - retire timed-out app-server clients (should_retire on deadline) + - post-tool completion watchdog (don't burn the full deadline after a + tool result if codex goes silent) + - raw marker as terminal (don't wait for turn/completed + that never comes) + - OAuth refresh failure classification (suggest `codex login` instead + of raw RPC error strings) + - dead subprocess detection between iterations + """ + + def test_deadline_marks_session_for_retirement(self): + client = FakeClient() + s = make_session(client) + r = s.run_turn( + "never finishes", + turn_timeout=0.05, + notification_poll_timeout=0.01, + ) + assert r.interrupted is True + assert r.error and "timed out" in r.error + assert r.should_retire is True, ( + "Deadline exhaustion must signal retirement so the next turn " + "respawns codex instead of riding a wedged subprocess." + ) + + def test_completed_turn_does_not_retire(self): + client = FakeClient() + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "hi"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.should_retire is False + + def test_post_tool_quiet_watchdog_trips_and_retires(self): + client = FakeClient() + # One tool completion, then total silence — no further events, + # no turn/completed. With a tiny post_tool_quiet_timeout the + # watchdog must fire before the larger turn deadline. + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": "ex1", + "command": "echo hi", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "hi", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + s = make_session(client) + r = s.run_turn( + "tool then silence", + turn_timeout=5.0, # would be miserable to wait + notification_poll_timeout=0.02, + post_tool_quiet_timeout=0.15, + ) + assert r.interrupted is True + assert r.should_retire is True + assert r.error and "silent" in r.error + # Confirm we issued turn/interrupt to free codex compute + assert any(method == "turn/interrupt" for (method, _) in client.requests) + + def test_post_tool_watchdog_resets_on_further_activity(self): + """A tool completion followed by an agent message should NOT trip + the watchdog — further activity = codex still alive.""" + client = FakeClient() + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": "ex1", + "command": "echo hi", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "hi", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + # Non-tool activity immediately after — resets watchdog. + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "tool finished"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn( + "tool then talk", turn_timeout=2.0, + notification_poll_timeout=0.01, + post_tool_quiet_timeout=0.05, + ) + # Tool ran, then text reset the watchdog, then turn/completed. + # Should NOT be a retirement case. + assert r.tool_iterations == 1 + assert r.final_text == "tool finished" + assert r.should_retire is False + assert r.interrupted is False + + def test_turn_aborted_marker_in_text_is_terminal(self): + """If codex emits `` in agent text and never sends + turn/completed, we still exit promptly instead of burning the + deadline.""" + client = FakeClient() + client.queue_notification( + "item/completed", + item={ + "type": "agentMessage", "id": "m1", + "text": "partial output... ", + }, + threadId="t", turnId="tu1", + ) + # Deliberately NO turn/completed notification queued. + s = make_session(client) + r = s.run_turn( + "abort mid-turn", turn_timeout=2.0, + notification_poll_timeout=0.01, + ) + assert r.interrupted is True + assert r.error and "turn_aborted" in r.error + # Should have exited fast — not waited for the full 2s deadline. + # (Can't measure wall clock reliably in CI; presence of the marker + # error string instead of a "timed out" message is the proxy.) + assert "timed out" not in r.error + + def test_turn_aborted_self_closing_marker_also_terminal(self): + client = FakeClient() + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", + "text": ""}, + threadId="t", turnId="tu1", + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=2.0, + notification_poll_timeout=0.01) + assert r.interrupted is True + assert r.error and "turn_aborted" in r.error + + def test_oauth_refresh_failure_on_turn_start_suggests_login(self): + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError( + code=-32603, + message="auth refresh failed: invalid_grant", + ) + return {"thread": {"id": "t"}, + "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_oauth_failure_from_stderr_on_turn_start_failure(self): + """If the RPC error itself is opaque but stderr shows an auth + problem, we still classify it as a refresh failure.""" + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + client.set_stderr_tail([ + "[2026-05-14T10:00:00Z WARN codex_core::auth] token refresh failed", + "[2026-05-14T10:00:00Z ERROR codex_core] please log in again", + ]) + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32603, message="rpc broke") + return {"thread": {"id": "t"}, + "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_oauth_failure_in_turn_completed_error(self): + """A failed turn/completed whose error mentions auth/refresh + triggers the re-auth hint + retirement.""" + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={ + "id": "tu1", "status": "failed", + "error": {"message": "401 Unauthorized: please reauthenticate"}, + }, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0, + notification_poll_timeout=0.01) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_generic_turn_failure_does_not_trigger_oauth_hint(self): + """A boring model error must NOT rewrite the message into a fake + re-auth hint. Conservative classifier.""" + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={ + "id": "tu1", "status": "failed", + "error": {"message": "rate limit exceeded"}, + }, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0, + notification_poll_timeout=0.01) + assert r.error is not None + assert "codex login" not in r.error + assert "rate limit exceeded" in r.error + # Generic model failures don't retire — the session itself is fine + assert r.should_retire is False + + def test_dead_subprocess_detected_between_iterations(self): + """If codex dies (segfault, OOM, killed by its auth refresh + thread), the inter-iteration is_alive check breaks the loop + instead of waiting on a queue that will never fill.""" + client = FakeClient() + s = make_session(client) + s.ensure_started() + # Simulate subprocess death by setting _closed (FakeClient's + # is_alive returns False when closed). + client._closed = True + client.set_stderr_tail([ + "thread 'tokio-runtime-worker' panicked at 'oauth: invalid_grant'", + ]) + r = s.run_turn("x", turn_timeout=2.0, + notification_poll_timeout=0.01) + assert r.should_retire is True + # Stderr-derived auth hint takes precedence over generic message + assert r.error and "codex login" in r.error + + +# ---- thread/start cross-fill ---- + +class TestThreadStartCrossFill: + """Mirrors openclaw beta.8's tolerance for thread.id/sessionId aliasing.""" + + def test_thread_id_under_thread_key(self): + client = FakeClient() + s = make_session(client) + tid = s.ensure_started() + assert tid == "thread-fake-001" + + def test_thread_session_id_alias_under_thread_key(self): + client = FakeClient() + client._request_handler = lambda method, params: ( + {"thread": {"sessionId": "alias-1"}, + "activePermissionProfile": {"id": "x"}} + if method == "thread/start" else + {"turn": {"id": "tu1"}} if method == "turn/start" else {} + ) + s = make_session(client) + tid = s.ensure_started() + assert tid == "alias-1" + + def test_top_level_session_id_fallback(self): + client = FakeClient() + client._request_handler = lambda method, params: ( + {"sessionId": "top-1"} if method == "thread/start" else + {"turn": {"id": "tu1"}} if method == "turn/start" else {} + ) + s = make_session(client) + tid = s.ensure_started() + assert tid == "top-1" + + def test_missing_thread_id_raises(self): + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + client._request_handler = lambda method, params: ( + {"thread": {}, "activePermissionProfile": {"id": "x"}} + if method == "thread/start" else + {"turn": {"id": "tu1"}} + ) + s = make_session(client) + with pytest.raises(CodexAppServerError, match="no thread id"): + s.ensure_started() + + +class TestHasTurnAbortedMarker: + """Unit coverage for the marker matcher itself.""" + + def test_empty_string(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("") is False + assert _has_turn_aborted_marker(None) is False # type: ignore[arg-type] + + def test_plain_text_no_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("normal response with no markers") is False + + def test_open_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("blah blah") is True + + def test_self_closing_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("") is True + + +class TestClassifyOAuthFailure: + """Unit coverage for the OAuth classifier; conservative on purpose.""" + + def test_invalid_grant_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("error: invalid_grant returned by server") + assert hint is not None + assert "codex login" in hint + + def test_token_refresh_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("token refresh failed: network error") + assert hint is not None + assert "codex login" in hint + + def test_401_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("HTTP 401 Unauthorized") + assert hint is not None + + def test_generic_error_not_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + assert _classify_oauth_failure("connection reset") is None + assert _classify_oauth_failure("model returned bad json") is None + assert _classify_oauth_failure("rate limit exceeded") is None + + def test_empty_inputs(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + assert _classify_oauth_failure() is None + assert _classify_oauth_failure("") is None + assert _classify_oauth_failure("", None) is None # type: ignore[arg-type] + + def test_multi_string_search(self): + """Hint can come from any of the provided strings.""" + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure( + "rpc returned -32603", + "[stderr] token has expired, run codex login", + ) + assert hint is not None diff --git a/tests/agent/transports/test_codex_event_projector.py b/tests/agent/transports/test_codex_event_projector.py new file mode 100644 index 000000000000..04980f35c611 --- /dev/null +++ b/tests/agent/transports/test_codex_event_projector.py @@ -0,0 +1,303 @@ +"""Tests for CodexEventProjector — codex item/* events → Hermes messages list. + +Drives projection against fixture notifications captured from codex 0.130.0 +plus synthetic ones for item types we couldn't auth-test live.""" + +from __future__ import annotations + +import json + +import pytest + +from agent.transports.codex_event_projector import ( + CodexEventProjector, + ProjectionResult, + _deterministic_call_id, + _format_tool_args, +) + + +# --- Fixture: real `commandExecution` notification captured from codex 0.130.0 +COMMAND_EXEC_COMPLETED = { + "method": "item/completed", + "params": { + "item": { + "type": "commandExecution", + "id": "f8a75c66-a89e-4fd7-8bcf-2d58e664fa9e", + "command": "/bin/bash -lc 'echo hello && ls /tmp | head -3'", + "cwd": "/tmp", + "processId": None, + "source": "userShell", + "status": "completed", + "commandActions": [ + {"type": "listFiles", "command": "ls /tmp", "path": "tmp"} + ], + "aggregatedOutput": "hello\naa_lang.json\n", + "exitCode": 0, + "durationMs": 10, + }, + "threadId": "019e1a94-352b-71e1-b214-e5c67c9ec190", + "turnId": "019e1a94-3553-7940-8af3-4ca57142deb7", + "completedAtMs": 1778562381151, + }, +} + + +class TestProjectionInvariants: + """Universal invariants that must hold across all projection paths.""" + + def test_streaming_deltas_dont_materialize(self) -> None: + p = CodexEventProjector() + for delta_method in ( + "item/commandExecution/outputDelta", + "item/agentMessage/delta", + "item/reasoning/delta", + ): + r = p.project({"method": delta_method, "params": {"delta": "x"}}) + assert r.messages == [], ( + f"{delta_method} should NOT produce messages — only " + f"item/completed materializes" + ) + assert r.is_tool_iteration is False + assert r.final_text is None + + def test_turn_started_and_completed_are_silent(self) -> None: + p = CodexEventProjector() + for method in ("turn/started", "turn/completed", "thread/started"): + r = p.project({"method": method, "params": {}}) + assert r.messages == [] + + def test_unknown_method_silent(self) -> None: + p = CodexEventProjector() + r = p.project({"method": "totally/unknown", "params": {}}) + assert r.messages == [] + + +class TestCommandExecutionProjection: + """Real captured notification → assistant tool_call + tool result.""" + + def test_command_completed_produces_two_messages(self) -> None: + p = CodexEventProjector() + r = p.project(COMMAND_EXEC_COMPLETED) + assert len(r.messages) == 2 + assert r.is_tool_iteration is True + + def test_first_message_is_assistant_tool_call(self) -> None: + p = CodexEventProjector() + msgs = p.project(COMMAND_EXEC_COMPLETED).messages + assistant = msgs[0] + assert assistant["role"] == "assistant" + assert assistant["content"] is None + assert len(assistant["tool_calls"]) == 1 + tc = assistant["tool_calls"][0] + assert tc["type"] == "function" + assert tc["function"]["name"] == "exec_command" + args = json.loads(tc["function"]["arguments"]) + assert "echo hello" in args["command"] + assert args["cwd"] == "/tmp" + + def test_second_message_is_tool_result_correlating_by_id(self) -> None: + p = CodexEventProjector() + msgs = p.project(COMMAND_EXEC_COMPLETED).messages + assistant, tool = msgs + assert tool["role"] == "tool" + assert tool["tool_call_id"] == assistant["tool_calls"][0]["id"] + assert "hello" in tool["content"] + + def test_nonzero_exit_code_annotated_in_tool_result(self) -> None: + item = {**COMMAND_EXEC_COMPLETED["params"]["item"], "exitCode": 2, + "aggregatedOutput": "boom"} + notif = { + "method": "item/completed", + "params": {**COMMAND_EXEC_COMPLETED["params"], "item": item}, + } + p = CodexEventProjector() + msgs = p.project(notif).messages + assert "[exit 2]" in msgs[1]["content"] + assert "boom" in msgs[1]["content"] + + def test_deterministic_call_id_across_replay(self) -> None: + # Same item id → same call_id (prefix cache must stay valid). + p1 = CodexEventProjector() + p2 = CodexEventProjector() + a = p1.project(COMMAND_EXEC_COMPLETED).messages + b = p2.project(COMMAND_EXEC_COMPLETED).messages + assert a[0]["tool_calls"][0]["id"] == b[0]["tool_calls"][0]["id"] + + +class TestAgentMessageProjection: + """assistant text → final_text + assistant message.""" + + def test_agent_message_projects_to_assistant(self) -> None: + p = CodexEventProjector() + r = p.project({ + "method": "item/completed", + "params": {"item": {"type": "agentMessage", "id": "x", + "text": "hi there"}}, + }) + assert r.final_text == "hi there" + assert r.messages == [{"role": "assistant", "content": "hi there"}] + assert r.is_tool_iteration is False + + def test_pending_reasoning_attaches_to_next_assistant_message(self) -> None: + p = CodexEventProjector() + # First a reasoning item lands + r1 = p.project({ + "method": "item/completed", + "params": {"item": {"type": "reasoning", "id": "r1", + "summary": ["thinking..."], + "content": ["step 1", "step 2"]}}, + }) + assert r1.messages == [] # reasoning alone produces no message + # Then the assistant message + r2 = p.project({ + "method": "item/completed", + "params": {"item": {"type": "agentMessage", "id": "a1", + "text": "ok"}}, + }) + assistant = r2.messages[0] + assert "reasoning" in assistant + assert "thinking" in assistant["reasoning"] + assert "step 1" in assistant["reasoning"] + + def test_reasoning_consumed_after_attaching(self) -> None: + p = CodexEventProjector() + p.project({"method": "item/completed", "params": {"item": { + "type": "reasoning", "id": "r1", "summary": ["once"], "content": []}}}) + first = p.project({"method": "item/completed", "params": {"item": { + "type": "agentMessage", "id": "a", "text": "first"}}}).messages[0] + second = p.project({"method": "item/completed", "params": {"item": { + "type": "agentMessage", "id": "b", "text": "second"}}}).messages[0] + assert "reasoning" in first + assert "reasoning" not in second + + +class TestFileChangeProjection: + def test_file_change_summary_no_inlined_content(self) -> None: + item = { + "type": "fileChange", + "id": "fc1", + "status": "applied", + "changes": [ + {"kind": {"type": "add"}, "path": "/tmp/new.py"}, + {"kind": {"type": "update"}, "path": "/tmp/old.py"}, + ], + } + p = CodexEventProjector() + msgs = p.project({"method": "item/completed", + "params": {"item": item}}).messages + assert len(msgs) == 2 + tc = msgs[0]["tool_calls"][0] + assert tc["function"]["name"] == "apply_patch" + args = json.loads(tc["function"]["arguments"]) + assert len(args["changes"]) == 2 + assert all("kind" in c and "path" in c for c in args["changes"]) + assert "applied" in msgs[1]["content"] + + +class TestMcpToolCallProjection: + def test_mcp_tool_call_namespaced(self) -> None: + item = { + "type": "mcpToolCall", + "id": "m1", + "server": "obsidian", + "tool": "search_notes", + "status": "completed", + "arguments": {"query": "hermes"}, + "result": {"content": [{"text": "found"}]}, + "error": None, + } + msgs = CodexEventProjector().project( + {"method": "item/completed", "params": {"item": item}} + ).messages + assert msgs[0]["tool_calls"][0]["function"]["name"] == "mcp.obsidian.search_notes" + assert "found" in msgs[1]["content"] + + def test_mcp_error_surfaced(self) -> None: + item = { + "type": "mcpToolCall", "id": "m2", + "server": "x", "tool": "y", "status": "failed", + "arguments": {}, "result": None, + "error": {"code": -1, "message": "no"}, + } + msgs = CodexEventProjector().project( + {"method": "item/completed", "params": {"item": item}} + ).messages + assert "error" in msgs[1]["content"] + + +class TestUserAndOpaqueProjection: + def test_user_message_text_fragments_only(self) -> None: + item = { + "type": "userMessage", "id": "u1", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "image", "url": "http://x/y"}, + {"type": "text", "text": "world"}, + ], + } + msgs = CodexEventProjector().project( + {"method": "item/completed", "params": {"item": item}} + ).messages + assert msgs[0]["role"] == "user" + assert "hello" in msgs[0]["content"] + assert "world" in msgs[0]["content"] + + def test_opaque_item_recorded_without_fabricated_tool_calls(self) -> None: + item = {"type": "plan", "id": "p1", "text": "do the thing"} + msgs = CodexEventProjector().project( + {"method": "item/completed", "params": {"item": item}} + ).messages + assert len(msgs) == 1 + assert msgs[0]["role"] == "assistant" + assert "plan" in msgs[0]["content"].lower() + assert "tool_calls" not in msgs[0] + + +class TestHelpers: + def test_deterministic_call_id_stable(self) -> None: + assert _deterministic_call_id("exec", "abc") == _deterministic_call_id("exec", "abc") + assert _deterministic_call_id("exec", "abc") != _deterministic_call_id("exec", "xyz") + + def test_deterministic_call_id_handles_missing_id(self) -> None: + # Should not raise, should be stable for same item type + a = _deterministic_call_id("exec", "") + b = _deterministic_call_id("exec", "") + assert a == b + assert "exec" in a + + def test_format_tool_args_sorted_keys(self) -> None: + # Sorted keys = deterministic across replays = prefix cache stays valid + a = _format_tool_args({"b": 1, "a": 2}) + b = _format_tool_args({"a": 2, "b": 1}) + assert a == b + + +class TestRoleAlternationInvariant: + """The project must never emit two assistant messages back-to-back from + one item — that breaks Hermes' message alternation invariant.""" + + @pytest.mark.parametrize( + "item", + [ + {"type": "commandExecution", "id": "c1", "command": "x", + "cwd": "/", "status": "completed", "aggregatedOutput": "", + "exitCode": 0, "commandActions": []}, + {"type": "fileChange", "id": "f1", "status": "applied", + "changes": []}, + {"type": "mcpToolCall", "id": "m1", "server": "s", "tool": "t", + "status": "completed", "arguments": {}, "result": None, + "error": None}, + {"type": "dynamicToolCall", "id": "d1", "tool": "x", + "arguments": {}, "status": "completed", + "contentItems": [], "success": True}, + ], + ) + def test_tool_items_emit_assistant_then_tool(self, item) -> None: + msgs = CodexEventProjector().project( + {"method": "item/completed", "params": {"item": item}} + ).messages + assert len(msgs) == 2 + assert msgs[0]["role"] == "assistant" + assert msgs[1]["role"] == "tool" + assert msgs[1]["tool_call_id"] == msgs[0]["tool_calls"][0]["id"] diff --git a/tests/agent/transports/test_hermes_tools_mcp_server.py b/tests/agent/transports/test_hermes_tools_mcp_server.py new file mode 100644 index 000000000000..3c11cb3f81dd --- /dev/null +++ b/tests/agent/transports/test_hermes_tools_mcp_server.py @@ -0,0 +1,135 @@ +"""Tests for the hermes-tools-as-MCP server module surface. + +We don't run a live MCP session in unit tests — that requires the codex +subprocess + client + an event loop. These tests pin the static +contract: the module imports, the EXPOSED_TOOLS list is sane, and the +build helper assembles a server when the SDK is present. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +class TestModuleSurface: + def test_module_imports_clean(self): + from agent.transports import hermes_tools_mcp_server as m + assert callable(m.main) + assert callable(m._build_server) + assert isinstance(m.EXPOSED_TOOLS, tuple) + assert len(m.EXPOSED_TOOLS) > 0 + + def test_exposed_tools_are_safe_subset(self): + """We MUST NOT expose tools codex already has, because codex' + own builtins are better-integrated with its sandbox + approvals. + Specifically: no terminal/shell, no read_file/write_file, no + patch — those are codex's built-in tools.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + forbidden = { + "terminal", "shell", "read_file", "write_file", "patch", + "search_files", "process", + } + leaked = forbidden & set(EXPOSED_TOOLS) + assert not leaked, ( + f"these tools must NOT be exposed via the codex callback " + f"because codex has built-in equivalents: {leaked}" + ) + + def test_expected_hermes_specific_tools_listed(self): + """The Hermes-specific tools should be present so users on the + codex runtime keep access to them.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + for required in ( + "web_search", + "web_extract", + "browser_navigate", + "vision_analyze", + "image_generate", + "skill_view", + ): + assert required in EXPOSED_TOOLS, f"missing {required!r}" + + def test_agent_loop_tools_not_exposed(self): + """delegate_task / memory / session_search / todo require the + running AIAgent context to dispatch, so a stateless MCP callback + can't drive them. They must NOT be in EXPOSED_TOOLS.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + for agent_loop_tool in ("delegate_task", "memory", "session_search", "todo"): + assert agent_loop_tool not in EXPOSED_TOOLS, ( + f"{agent_loop_tool!r} requires the agent loop context " + "and can't be reached through a stateless MCP callback" + ) + + def test_kanban_worker_tools_exposed(self): + """Kanban workers run as `hermes chat -q` subprocesses; if they + come up on the codex_app_server runtime, the worker can do the + actual work via codex's shell but needs the kanban tools through + the MCP callback to report back to the kernel. Without these + tools available, the worker would hang at completion time.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + # Worker handoff tools — every dispatched worker uses at least + # one of {complete, block, comment} to close out its task. + for worker_tool in ( + "kanban_complete", + "kanban_block", + "kanban_comment", + "kanban_heartbeat", + ): + assert worker_tool in EXPOSED_TOOLS, ( + f"{worker_tool!r} missing from codex callback — kanban " + "workers on codex_app_server runtime would hang" + ) + + def test_kanban_orchestrator_tools_exposed(self): + """Orchestrator agents need to dispatch new tasks, query the + board, and unblock/link tasks. Exposed so an orchestrator on + codex_app_server can do its job.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + for orch_tool in ( + "kanban_create", + "kanban_show", + "kanban_list", + "kanban_unblock", + "kanban_link", + ): + assert orch_tool in EXPOSED_TOOLS, ( + f"{orch_tool!r} missing from codex callback" + ) + + +class TestMain: + def test_main_returns_2_when_mcp_unavailable(self, monkeypatch): + """When the mcp package isn't installed, main() should exit + cleanly with code 2 and an install hint, not crash.""" + import agent.transports.hermes_tools_mcp_server as m + + def boom_build(*a, **kw): + raise ImportError("mcp not installed") + + monkeypatch.setattr(m, "_build_server", boom_build) + rc = m.main(["--verbose"]) + assert rc == 2 + + def test_main_handles_keyboard_interrupt(self, monkeypatch): + import agent.transports.hermes_tools_mcp_server as m + + class FakeServer: + def run(self): + raise KeyboardInterrupt() + + monkeypatch.setattr(m, "_build_server", lambda: FakeServer()) + rc = m.main([]) + assert rc == 0 + + def test_main_returns_1_on_runtime_error(self, monkeypatch): + import agent.transports.hermes_tools_mcp_server as m + + class CrashingServer: + def run(self): + raise RuntimeError("boom") + + monkeypatch.setattr(m, "_build_server", lambda: CrashingServer()) + rc = m.main([]) + assert rc == 1 diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 4c7197ad94a5..34f5cefe06ef 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -71,32 +71,40 @@ def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch): "invalidate", ] - def test_resize_rebuilds_scrollback_before_prompt_toolkit_redraw(self, bare_cli, monkeypatch): + def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch): + """Resize recovery must NOT erase screen or scrollback. + + The startup banner lives in normal terminal scrollback (printed + before prompt_toolkit owns the chrome). Clearing scrollback on + SIGWINCH removes it and ``_replay_output_history`` cannot + reconstruct it. The fix is to only reset the renderer cache and + let ``original_on_resize`` recalculate layout. + + Additionally, ``_status_bar_suppressed_after_resize`` must be set + so the input rules and status bar hide until the next user input, + preventing duplicated-bar artifacts on column shrink (#19280). + """ app = MagicMock() - out = app.renderer.output events = [] - out.reset_attributes.side_effect = lambda: events.append("reset_attrs") - out.erase_screen.side_effect = lambda: events.append("erase") - out.write_raw.side_effect = lambda text: events.append(("raw", text)) - out.cursor_goto.side_effect = lambda *_: events.append("home") - out.flush.side_effect = lambda: events.append("flush") app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset") - monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + app.invalidate.side_effect = lambda: events.append("invalidate") original_on_resize = lambda: events.append("original_resize") + # bare_cli skips __init__, so seed the attribute the way __init__ would. + bare_cli._status_bar_suppressed_after_resize = False bare_cli._recover_after_resize(app, original_on_resize) assert events == [ - "reset_attrs", - "erase", - ("raw", "\x1b[3J"), - "home", - "flush", "renderer_reset", - "replay", + "invalidate", "original_resize", ] - app.invalidate.assert_not_called() + # Must NOT clear the screen or scrollback — those destroy the banner. + app.renderer.output.erase_screen.assert_not_called() + app.renderer.output.write_raw.assert_not_called() + app.renderer.output.cursor_goto.assert_not_called() + # Status bar / input rules must be suppressed until the next prompt. + assert bare_cli._status_bar_suppressed_after_resize is True def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli): app = MagicMock() diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index ee5ffb390d13..8417d64e746a 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -319,6 +319,89 @@ def test_resume_without_target_lists_recent_sessions(self, capsys): assert "Checking Running Hermes Agent" in output assert "Use /resume to continue" in output + def test_sessions_command_no_args_lists_recent_sessions(self, capsys): + """/sessions with no args prints the recent-sessions table (TUI parity). + + Regression test: `sessions` was registered in the central command + registry and surfaced by /help and tab-completion, but the classic + CLI dispatcher had no elif branch for it, so the canonical name fell + through and printed `Unknown command: sessions`. + """ + cli = _make_cli() + cli.session_id = "current" + cli._session_db = MagicMock() + cli._session_db.list_sessions_rich.return_value = [ + { + "id": "20260401_201329_d85961", + "title": "Checking Running Hermes Agent", + "preview": "check running gateways for hermes agent", + "last_active": 0, + }, + ] + + # Drive it through the public dispatcher to also lock in the + # process_command wiring, not just the handler in isolation. + cli.process_command("/sessions") + output = capsys.readouterr().out + + assert "Unknown command" not in output + assert "Recent sessions" in output + assert "Checking Running Hermes Agent" in output + assert "20260401_201329_d85961" in output + + def test_sessions_list_subcommand_lists_recent_sessions(self, capsys): + """/sessions list is an explicit alias for the no-arg list view.""" + cli = _make_cli() + cli.session_id = "current" + cli._session_db = MagicMock() + cli._session_db.list_sessions_rich.return_value = [ + { + "id": "20260401_201329_d85961", + "title": "Checking Running Hermes Agent", + "preview": "check running gateways for hermes agent", + "last_active": 0, + }, + ] + + cli.process_command("/sessions list") + output = capsys.readouterr().out + + assert "Unknown command" not in output + assert "Recent sessions" in output + assert "Checking Running Hermes Agent" in output + + def test_sessions_with_target_delegates_to_resume(self): + """/sessions behaves identically to /resume . + + We intercept `_handle_resume_command` rather than the full resume + machinery (which would otherwise require simulating an entire session + switch). The contract under test is the dispatch wiring. + """ + cli = _make_cli() + with patch.object(cli, "_handle_resume_command") as mock_resume: + cli.process_command("/sessions Checking Running Hermes Agent") + + mock_resume.assert_called_once_with( + "/resume Checking Running Hermes Agent" + ) + + def test_sessions_command_is_dispatched(self): + """/sessions must hit _handle_sessions_command, not fall through. + + Direct test that the process_command elif chain routes the canonical + name to the handler. Without this wiring, /sessions printed + `Unknown command: sessions` even though it was a registered command. + """ + cli = _make_cli() + cli._session_db = None # exercise the no-db path too + + with patch.object(cli, "_handle_sessions_command") as mock_handler: + cli.process_command("/sessions") + + mock_handler.assert_called_once() + called_with = mock_handler.call_args.args[0] + assert called_with.lower().startswith("/sessions") + class TestRootLevelProviderOverride: """Root-level provider/base_url in config.yaml must NOT override model.provider.""" diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 0c9aab82addf..e8eb73251572 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -531,8 +531,8 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): # After the probe detects a single model ("llm"), the flow asks # "Use this model? [Y/n]:" — confirm with Enter, then context length, - # then display name. - answers = iter(["http://localhost:8000", "local-key", "", "", "", ""]) + # then display name. The api_mode prompt also runs before model selection. + answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers)) @@ -546,6 +546,63 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): assert saved_env["MODEL"] == "llm" +def test_model_flow_custom_persists_selected_api_mode(monkeypatch): + saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}} + captured_provider = {} + + monkeypatch.setattr( + "hermes_cli.config.get_env_value", + lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "", + ) + monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) + monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None) + monkeypatch.setattr( + "hermes_cli.models.probe_api_models", + lambda api_key, base_url: { + "models": [], + "probed_url": f"{base_url.rstrip('/')}/models", + "resolved_base_url": None, + "suggested_base_url": None, + "used_fallback": False, + }, + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg) + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg)) + monkeypatch.setattr( + "hermes_cli.main._save_custom_provider", + lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None: captured_provider.update( + { + "base_url": base_url, + "api_key": api_key, + "model": model, + "context_length": context_length, + "name": name, + "api_mode": api_mode, + } + ), + ) + + answers = iter( + [ + "https://codex.example.com/v1", + "3", + "chosen-model", + "", + "", + ] + ) + monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) + monkeypatch.setattr("getpass.getpass", lambda _prompt="": "test-key") + + hermes_main._model_flow_custom({"model": {"provider": "custom"}}) + + assert saved_cfg["model"]["provider"] == "custom" + assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1" + assert saved_cfg["model"]["api_key"] == "test-key" + assert saved_cfg["model"]["api_mode"] == "codex_responses" + assert captured_provider["api_mode"] == "codex_responses" + + def test_cmd_model_forwards_nous_login_tls_options(monkeypatch): monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None) monkeypatch.setattr( diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 16e6699aaac1..445626fac9be 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -332,6 +332,38 @@ def test_bottom_input_rule_hides_on_narrow_terminals(self): assert cli_obj._tui_input_rule_height("bottom", width=50) == 0 assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + def test_input_rules_hide_after_resize_until_next_input(self): + """When _status_bar_suppressed_after_resize is set, both rules hide. + + See _recover_after_resize — column shrink reflows already-rendered + bars into scrollback, so we hide the separators until the user + submits the next input, at which point the flag is cleared. + """ + cli_obj = _make_cli() + cli_obj._status_bar_suppressed_after_resize = True + + assert cli_obj._tui_input_rule_height("top", width=90) == 0 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 0 + + cli_obj._status_bar_suppressed_after_resize = False + assert cli_obj._tui_input_rule_height("top", width=90) == 1 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + + def test_scrollback_box_width_caps_to_resize_safe_value(self): + """Decorative scrollback boxes clamp to a width small enough that + moderate terminal shrinks don't cause reflow into scrollback.""" + from cli import HermesCLI + + # Floor at 32 — narrow terminals still get something usable. + assert HermesCLI._scrollback_box_width(20) == 32 + assert HermesCLI._scrollback_box_width(32) == 32 + # Cap at 56 — wide terminals don't get full-width boxes. + assert HermesCLI._scrollback_box_width(80) == 56 + assert HermesCLI._scrollback_box_width(120) == 56 + assert HermesCLI._scrollback_box_width(200) == 56 + # Mid-range passes through up to the cap. + assert HermesCLI._scrollback_box_width(48) == 48 + def test_agent_spacer_reclaimed_on_narrow_terminals(self): cli_obj = _make_cli() cli_obj._agent_running = True diff --git a/tests/cli/test_cprint_bg_thread.py b/tests/cli/test_cprint_bg_thread.py index bb0e59d064ef..f68e1de7c1d3 100644 --- a/tests/cli/test_cprint_bg_thread.py +++ b/tests/cli/test_cprint_bg_thread.py @@ -215,13 +215,15 @@ def find_spec(self, name, path=None, target=None): assert direct_prints == ["fallback2"] -def test_output_history_strips_ansi_and_keeps_recent_lines(): +def test_output_history_preserves_ansi_and_keeps_recent_lines(): cli._configure_output_history(True, 10) for idx in range(12): cli._record_output_history(f"\x1b[31mline-{idx}\x1b[0m") - assert list(cli._OUTPUT_HISTORY) == [f"line-{idx}" for idx in range(2, 12)] + assert list(cli._OUTPUT_HISTORY) == [ + f"\x1b[31mline-{idx}\x1b[0m" for idx in range(2, 12) + ] def test_replay_output_history_does_not_record_replayed_lines(monkeypatch): @@ -258,10 +260,35 @@ def _render_current_width(): cli._replay_output_history() assert widths_seen == ["called"] - assert printed == ["top border", "body"] + assert printed == ["top border\nbody"] assert list(cli._OUTPUT_HISTORY) == [_render_current_width] +def test_replay_output_history_batches_rendered_lines_into_one_print(monkeypatch): + cli._configure_output_history(True, 10) + cli._record_output_history("first line") + cli._record_output_history("second line") + cli._record_output_history_entry(lambda: ["third line", "fourth line"]) + printed = [] + + monkeypatch.setattr(cli, "_pt_print", lambda value: printed.append(value)) + monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text) + + cli._replay_output_history() + + assert printed == ["first line\nsecond line\nthird line\nfourth line"] + + +def test_chat_console_records_rich_ansi_for_resize_replay(monkeypatch): + cli._configure_output_history(True, 10) + monkeypatch.setattr(cli, "_pt_print", lambda *_args, **_kwargs: None) + + cli.ChatConsole().print("[bold red]Hello[/]") + + assert cli._OUTPUT_HISTORY + assert any("\x1b[" in line for line in cli._OUTPUT_HISTORY) + + def test_suspend_output_history_blocks_recording(): cli._configure_output_history(True, 10) diff --git a/tests/conftest.py b/tests/conftest.py index 5d7f197f195f..d9ae0c86ea6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -476,12 +476,14 @@ def _reset_module_state(): except Exception: pass - # --- agent.auxiliary_client — runtime main provider/model override --- - # Set per-turn by AIAgent.run_conversation; tests that import it must - # see a clean state so config.yaml fallback works as expected. + # --- agent.auxiliary_client — runtime main provider/model override and + # payment-error health cache. Both are process-global in production; + # reset them per test so one worker's fallback/402 test does not make + # later auxiliary-client tests skip otherwise-available providers. try: from agent import auxiliary_client as _aux_mod _aux_mod.clear_runtime_main() + _aux_mod._reset_aux_unhealthy_cache() except Exception: pass diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index da8a2d33641f..b6bcc28c5062 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -119,6 +119,14 @@ def __init__(self, *, title=None, description=None, color=None, **_): self.title = title self.description = description self.color = color + self.fields = [] + self.footer = None + def add_field(self, *, name=None, value=None, inline=False, **_): + self.fields.append({"name": name, "value": value, "inline": inline}) + return self + def set_footer(self, *, text=None, icon_url=None, **_): + self.footer = {"text": text, "icon_url": icon_url} + return self discord_mod.Embed = _FakeEmbed # ui.View / ui.Select / ui.Button: real classes (not MagicMock) so diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c59b27d8001b..cf197bd6f7f5 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -302,6 +302,43 @@ def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch) assert config.thread_sessions_per_user is False + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): + """discord.thread_require_mention in config.yaml should reach the runtime env var.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " thread_require_mention: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true" + + def test_thread_require_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch): + """Explicit env var should win over config.yaml (env > yaml precedence).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " thread_require_mention: false\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") # user override + + load_gateway_config() + + # Env value preserved, not clobbered by yaml. + assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true" + def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -372,6 +409,26 @@ def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkey "456": "Therapist mode", } + def test_bridges_discord_history_backfill_settings_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " history_backfill: true\n" + " history_backfill_limit: 17\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_HISTORY_BACKFILL", raising=False) + monkeypatch.delenv("DISCORD_HISTORY_BACKFILL_LIMIT", raising=False) + + load_gateway_config() + + assert os.getenv("DISCORD_HISTORY_BACKFILL") == "true" + assert os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT") == "17" + def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index aceb079b4b89..570eb997ba01 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -10,6 +10,80 @@ from gateway.config import Platform, PlatformConfig +class _FakeDingTalkModel: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class _FakeChatbotMessage(SimpleNamespace): + @classmethod + def from_dict(cls, data): + data = data or {} + return cls( + message_id=data.get("msgId") or data.get("messageId") or data.get("message_id") or "", + conversation_id=data.get("conversationId") or data.get("conversation_id") or "", + conversation_type=str(data.get("conversationType") or data.get("conversation_type") or "1"), + sender_id=data.get("senderId") or data.get("sender_id") or "", + sender_staff_id=data.get("senderStaffId") or data.get("sender_staff_id") or data.get("senderId") or "", + sender_nick=data.get("senderNick") or data.get("sender_nick") or "", + text=data.get("text") or "", + rich_text=data.get("richText") or data.get("rich_text"), + rich_text_content=data.get("richTextContent") or data.get("rich_text_content"), + session_webhook=data.get("sessionWebhook") or data.get("session_webhook") or "", + session_webhook_expired_time=data.get("sessionWebhookExpiredTime") or data.get("session_webhook_expired_time") or 0, + create_at=data.get("createAt") or data.get("create_at") or 0, + at_users=data.get("atUsers") or data.get("at_users") or [], + is_in_at_list=bool(data.get("isInAtList") or data.get("is_in_at_list")), + ) + + +@pytest.fixture(autouse=True) +def _fake_dingtalk_optional_sdks(monkeypatch): + """Keep DingTalk adapter tests hermetic when optional SDKs are absent.""" + from gateway.platforms import dingtalk as dt + + card_models = SimpleNamespace(**{ + name: _FakeDingTalkModel + for name in ( + "CreateCardRequest", + "CreateCardRequestCardData", + "CreateCardRequestImGroupOpenSpaceModel", + "CreateCardRequestImRobotOpenSpaceModel", + "CreateCardHeaders", + "DeliverCardRequest", + "DeliverCardRequestImGroupOpenDeliverModel", + "DeliverCardRequestImRobotOpenDeliverModel", + "DeliverCardHeaders", + "StreamingUpdateRequest", + "StreamingUpdateHeaders", + ) + }) + robot_models = SimpleNamespace(**{ + name: _FakeDingTalkModel + for name in ( + "RobotReplyEmotionRequestTextEmotion", + "RobotReplyEmotionRequest", + "RobotReplyEmotionHeaders", + "RobotRecallEmotionRequestTextEmotion", + "RobotRecallEmotionRequest", + "RobotRecallEmotionHeaders", + "RobotMessageFileDownloadRequest", + "RobotMessageFileDownloadHeaders", + ) + }) + + monkeypatch.setattr(dt, "ChatbotMessage", _FakeChatbotMessage, raising=False) + monkeypatch.setattr( + dt, + "AckMessage", + SimpleNamespace(STATUS_OK=200, STATUS_SYSTEM_EXCEPTION=500), + raising=False, + ) + monkeypatch.setattr(dt, "tea_util_models", SimpleNamespace(RuntimeOptions=_FakeDingTalkModel), raising=False) + monkeypatch.setattr(dt, "dingtalk_card_models", card_models, raising=False) + monkeypatch.setattr(dt, "dingtalk_robot_models", robot_models, raising=False) + + # --------------------------------------------------------------------------- # Requirements check # --------------------------------------------------------------------------- @@ -18,7 +92,8 @@ class TestDingTalkRequirements: def test_returns_false_when_sdk_missing(self, monkeypatch): - with patch.dict("sys.modules", {"dingtalk_stream": None}): + with patch.dict("sys.modules", {"dingtalk_stream": None}), \ + patch("tools.lazy_deps.ensure", side_effect=ImportError("dingtalk_stream unavailable")): monkeypatch.setattr( "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False ) diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py new file mode 100644 index 000000000000..b6e21f1f44b9 --- /dev/null +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -0,0 +1,408 @@ +"""Tests for Discord clarify button rendering and resolution. + +Mirrors test_telegram_clarify_buttons.py for the Discord ``send_clarify`` +override and the ``ClarifyChoiceView`` callbacks. Discord uses ``discord.ui.View`` +button callbacks (closures) rather than a string-prefixed callback_query +dispatcher like Telegram — the auth + resolution path is the same: + + · numeric choice → resolve_gateway_clarify(clarify_id, choice_text) + · "Other" button → mark_awaiting_text(clarify_id) so the text-intercept + captures the next user message in this session + · already-resolved or unauthorized → ephemeral "this prompt..." reply +""" + +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Repo root importable +_repo = str(Path(__file__).resolve().parents[2]) +if _repo not in sys.path: + sys.path.insert(0, _repo) + +# Triggers the shared discord mock from tests/gateway/conftest.py before +# importing the production module. +from gateway.platforms.discord import ( # noqa: E402 + ClarifyChoiceView, + DiscordAdapter, +) +from gateway.config import PlatformConfig # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(*, allowed_users=None, allowed_roles=None): + config = PlatformConfig(enabled=True, token="test-token", extra={}) + adapter = DiscordAdapter(config) + adapter._client = MagicMock() + adapter._allowed_user_ids = set(allowed_users or []) + adapter._allowed_role_ids = set(allowed_roles or []) + return adapter + + +def _clear_clarify_state(): + from tools import clarify_gateway as cm + with cm._lock: + cm._entries.clear() + cm._session_index.clear() + cm._notify_cbs.clear() + + +def _make_interaction(*, user_id="42", display_name="Tester", roles=None, + include_message=True): + """Build a mock discord.Interaction with response.edit_message / + send_message / defer all coroutine-callable.""" + user = SimpleNamespace( + id=user_id, + display_name=display_name, + roles=[SimpleNamespace(id=r) for r in (roles or [])], + ) + response = SimpleNamespace( + edit_message=AsyncMock(), + send_message=AsyncMock(), + defer=AsyncMock(), + ) + if include_message: + embed = MagicMock() + embed.color = None + embed.set_footer = MagicMock() + message = SimpleNamespace(embeds=[embed]) + else: + message = None + return SimpleNamespace(user=user, response=response, message=message) + + +# =========================================================================== +# ClarifyChoiceView construction +# =========================================================================== + +class TestClarifyChoiceViewConstruction: + """The view should build numeric buttons plus an Other button.""" + + def test_renders_n_choice_buttons_plus_other(self): + view = ClarifyChoiceView( + choices=["apple", "banana", "cherry"], + clarify_id="cidX", + allowed_user_ids={"42"}, + ) + # 3 numeric + 1 "Other" + assert len(view.children) == 4 + labels = [b.label for b in view.children] + assert labels[0].startswith("1. apple") + assert labels[1].startswith("2. banana") + assert labels[2].startswith("3. cherry") + assert "Other" in labels[3] + # custom_ids encode clarify_id + index/other + ids = [b.custom_id for b in view.children] + assert ids[0] == "clarify:cidX:0" + assert ids[1] == "clarify:cidX:1" + assert ids[2] == "clarify:cidX:2" + assert ids[3] == "clarify:cidX:other" + + def test_caps_at_24_choices_plus_other(self): + choices = [f"choice-{i}" for i in range(50)] + view = ClarifyChoiceView( + choices=choices, + clarify_id="cidY", + allowed_user_ids=set(), + ) + # Discord limit is 25 components; we cap choices at 24 + 1 Other = 25 + assert len(view.children) == 25 + assert "Other" in view.children[-1].label + + def test_truncates_long_choice_label(self): + long_choice = "x" * 200 + view = ClarifyChoiceView( + choices=[long_choice], + clarify_id="cidZ", + allowed_user_ids=set(), + ) + # 75 chars + 3 ellipsis chars in the body, plus "1. " prefix + first_label = view.children[0].label + assert first_label.startswith("1. ") + assert first_label.endswith("...") + # Final label total <= 80 (Discord cap on button labels) + assert len(first_label) <= 80 + + +# =========================================================================== +# Choice callback → resolve_gateway_clarify +# =========================================================================== + +class TestClarifyChoiceResolve: + """Clicking a numeric button should resolve the clarify entry.""" + + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_choice_resolves_with_canonical_choice_text(self): + from tools import clarify_gateway as cm + cm.register("cidA", "sk-A", "Pick", ["red", "green", "blue"]) + + view = ClarifyChoiceView( + choices=["red", "green", "blue"], + clarify_id="cidA", + allowed_user_ids={"42"}, + ) + + interaction = _make_interaction(user_id="42") + await view._resolve_choice(interaction, index=1, choice="green") + + # Resolved through clarify primitive + with cm._lock: + entry = cm._entries.get("cidA") + assert entry is not None + assert entry.response == "green" + assert entry.event.is_set() + # Buttons disabled + assert all(b.disabled for b in view.children) + # Embed updated + edit_message called + interaction.response.edit_message.assert_called_once() + + @pytest.mark.asyncio + async def test_choice_falls_back_to_label_text_when_entry_missing(self): + """If the gateway entry vanished (race / stale view), the button's + own choice text is used as the response.""" + from tools import clarify_gateway as cm + # Note: no cm.register() — entry intentionally absent + + view = ClarifyChoiceView( + choices=["alpha"], + clarify_id="cidGone", + allowed_user_ids=set(), + ) + interaction = _make_interaction() + # Doesn't raise; resolve_gateway_clarify returns False quietly + await view._resolve_choice(interaction, index=0, choice="alpha") + # Still marks the view resolved + disables buttons + assert view.resolved is True + assert all(b.disabled for b in view.children) + + @pytest.mark.asyncio + async def test_already_resolved_sends_ephemeral_reply(self): + view = ClarifyChoiceView( + choices=["a", "b"], + clarify_id="cidB", + allowed_user_ids=set(), + ) + view.resolved = True + + interaction = _make_interaction() + await view._resolve_choice(interaction, index=0, choice="a") + + interaction.response.send_message.assert_called_once() + kwargs = interaction.response.send_message.call_args.kwargs + assert kwargs.get("ephemeral") is True + # No resolve was called + interaction.response.edit_message.assert_not_called() + + @pytest.mark.asyncio + async def test_unauthorized_user_rejected(self): + from tools import clarify_gateway as cm + cm.register("cidC", "sk-C", "Pick", ["x"]) + + # Allowlist set, user not in it + view = ClarifyChoiceView( + choices=["x"], + clarify_id="cidC", + allowed_user_ids={"99999"}, # not 42 + ) + + interaction = _make_interaction(user_id="42") + await view._resolve_choice(interaction, index=0, choice="x") + + # Ephemeral rejection, no resolution, no edit + interaction.response.send_message.assert_called_once() + kwargs = interaction.response.send_message.call_args.kwargs + assert kwargs.get("ephemeral") is True + interaction.response.edit_message.assert_not_called() + with cm._lock: + entry = cm._entries.get("cidC") + assert entry is not None + assert not entry.event.is_set() + + +# =========================================================================== +# "Other" button → mark_awaiting_text +# =========================================================================== + +class TestClarifyOtherButton: + """Clicking Other should flip the entry into text-capture mode.""" + + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_other_flips_entry_to_awaiting_text(self): + from tools import clarify_gateway as cm + cm.register("cidD", "sk-D", "Pick", ["x", "y"]) + + view = ClarifyChoiceView( + choices=["x", "y"], + clarify_id="cidD", + allowed_user_ids=set(), + ) + + interaction = _make_interaction() + await view._on_other(interaction) + + # Entry awaiting_text now + pending = cm.get_pending_for_session("sk-D") + assert pending is not None + assert pending.clarify_id == "cidD" + assert pending.awaiting_text is True + # Entry still pending (not resolved) + with cm._lock: + entry = cm._entries.get("cidD") + assert entry is not None + assert not entry.event.is_set() + # View locked + buttons disabled + assert view.resolved is True + assert all(b.disabled for b in view.children) + interaction.response.edit_message.assert_called_once() + + @pytest.mark.asyncio + async def test_other_unauthorized_user_rejected(self): + from tools import clarify_gateway as cm + cm.register("cidE", "sk-E", "Pick", ["x"]) + + view = ClarifyChoiceView( + choices=["x"], + clarify_id="cidE", + allowed_user_ids={"99999"}, + ) + + interaction = _make_interaction(user_id="42") + await view._on_other(interaction) + + # Rejected; entry NOT awaiting text + interaction.response.send_message.assert_called_once() + pending = cm.get_pending_for_session("sk-E") + assert pending is None or pending.awaiting_text is False + + +# =========================================================================== +# DiscordAdapter.send_clarify integration +# =========================================================================== + +class TestDiscordSendClarify: + """Verify send_clarify renders an embed and (optionally) attaches the view.""" + + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_multi_choice_attaches_view(self): + adapter = _make_adapter(allowed_users={"42"}) + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 123456 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + result = await adapter.send_clarify( + chat_id="9001", + question="Pick a color", + choices=["red", "green", "blue"], + clarify_id="cidM", + session_key="sk-M", + ) + + assert result.success is True + assert result.message_id == "123456" + # Verify channel.send was called with embed + view kwargs + channel.send.assert_called_once() + kwargs = channel.send.call_args.kwargs + assert "embed" in kwargs + assert "view" in kwargs + assert isinstance(kwargs["view"], ClarifyChoiceView) + # 3 choice buttons + 1 Other + assert len(kwargs["view"].children) == 4 + + @pytest.mark.asyncio + async def test_open_ended_omits_view(self): + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 222 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + result = await adapter.send_clarify( + chat_id="9001", + question="What is your name?", + choices=None, + clarify_id="cidOE", + session_key="sk-OE", + ) + + assert result.success is True + channel.send.assert_called_once() + kwargs = channel.send.call_args.kwargs + # Open-ended path renders embed but no view (text-capture handles reply) + assert "embed" in kwargs + assert "view" not in kwargs + + @pytest.mark.asyncio + async def test_routes_to_thread_when_metadata_thread_id_set(self): + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 333 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=["a"], + clarify_id="cidT", + session_key="sk-T", + metadata={"thread_id": "7777"}, + ) + + # Channel lookup should resolve to thread id, not chat_id + adapter._client.get_channel.assert_called_once_with(7777) + + @pytest.mark.asyncio + async def test_not_connected_returns_failure(self): + adapter = _make_adapter() + adapter._client = None + result = await adapter.send_clarify( + chat_id="9001", + question="?", + choices=["a"], + clarify_id="cidNC", + session_key="sk-NC", + ) + assert result.success is False + assert "Not connected" in (result.error or "") + + @pytest.mark.asyncio + async def test_filters_empty_and_whitespace_choices(self): + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 444 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=["", " ", "real-choice", None], + clarify_id="cidF", + session_key="sk-F", + ) + kwargs = channel.send.call_args.kwargs + view = kwargs["view"] + # Only 1 real choice + 1 Other = 2 children + assert len(view.children) == 2 + assert "real-choice" in view.children[0].label diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 91b23bd86029..c69af3e7781c 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -62,6 +62,12 @@ def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = self.guild = SimpleNamespace(name=guild_name) self.topic = None + def history(self, *, limit, before, after=None, oldest_first=None): + async def _iter(): + return + yield + return _iter() + class FakeForumChannel: def __init__(self, channel_id: int = 1, name: str = "support-forum", guild_name: str = "Hermes Server"): @@ -81,6 +87,12 @@ def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name) self.topic = None + def history(self, *, limit, before, after=None, oldest_first=None): + async def _iter(): + return + yield + return _iter() + @pytest.fixture def adapter(monkeypatch): @@ -88,6 +100,23 @@ def adapter(monkeypatch): monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False) + # Clear DISCORD_* env vars the test file exercises so tests don't leak + # process-env state from the contributor's shell into per-test behaviour. + # Individual tests still monkeypatch.setenv() for their own scenarios. + for _var in ( + "DISCORD_REQUIRE_MENTION", + "DISCORD_THREAD_REQUIRE_MENTION", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_AUTO_THREAD", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_HISTORY_BACKFILL", + "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_ALLOW_BOTS", + ): + monkeypatch.delenv(_var, raising=False) + config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) @@ -111,6 +140,48 @@ def make_message(*, channel, content: str, mentions=None, msg_type=None): ) +def make_history_message( + *, + author, + content: str, + msg_id: int, + msg_type=None, + attachments=None, +): + return SimpleNamespace( + id=msg_id, + author=author, + content=content, + attachments=list(attachments or []), + type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default, + ) + + +class FakeHistoryChannel(FakeTextChannel): + def __init__(self, history_messages, **kwargs): + super().__init__(**kwargs) + self._history_messages = list(history_messages) + + def history(self, *, limit, before, after=None, oldest_first=None): + before_id = int(getattr(before, "id", before)) + after_id = int(getattr(after, "id", after)) if after is not None else None + if oldest_first is None: + oldest_first = after is not None + + messages = [ + message for message in self._history_messages + if int(message.id) < before_id + and (after_id is None or int(message.id) > after_id) + ] + messages.sort(key=lambda message: int(message.id), reverse=not oldest_first) + + async def _iter(): + for message in messages[:limit]: + yield message + + return _iter() + + @pytest.mark.asyncio async def test_discord_defaults_to_require_mention(adapter, monkeypatch): """Default behavior: require @mention in server channels.""" @@ -446,6 +517,37 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t assert event.source.chat_type == "group" +@pytest.mark.asyncio +async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypatch): + """Free-response channels should reply inline, never spawn a new thread. + + Without this, every message in a free-response channel would auto-create + a fresh thread (since the channel bypasses the @mention gate, every + message looks like a fresh trigger). That turns a "lightweight chat" + channel into a thread-spawning machine — see the docs at + website/docs/user-guide/messaging/discord.md which already describe + this as the intended behavior. + """ + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789") + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) # default true + + adapter._auto_create_thread = AsyncMock() + + message = make_message( + channel=FakeTextChannel(channel_id=789), + content="casual chat in free-response channel", + ) + + await adapter._handle_message(message) + + adapter._auto_create_thread.assert_not_awaited() + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "casual chat in free-response channel" + assert event.source.chat_type == "group" + + @pytest.mark.asyncio @@ -463,3 +565,322 @@ async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_default_keeps_responding_after_participation(adapter, monkeypatch): + """Default behavior: once the bot is in a thread, it auto-responds without @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + + thread = FakeThread(channel_id=456, name="follow-up") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="follow-up without mention") + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_gates_followups(adapter, monkeypatch): + """When thread_require_mention=true, even bot-participated threads need @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="ambient chatter — not for me") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_still_responds_when_mentioned(adapter, monkeypatch): + """thread_require_mention=true still lets explicit @mentions through in threads.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + bot_user = adapter._client.user + + message = make_message( + channel=thread, + content=f"<@{bot_user.id}> hey, this one's for you", + mentions=[bot_user], + ) + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypatch): + """thread_require_mention can also be set via config.extra (yaml).""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + adapter.config.extra["thread_require_mention"] = True + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + + message = make_message(channel=thread, content="ambient — should be ignored") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + + +@pytest.mark.asyncio +async def test_fetch_channel_context_stops_at_self_message_and_reverses_to_chronological_order(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + old_human = SimpleNamespace(id=57, display_name="Bob", name="Bob", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="latest human note", msg_id=4), + make_history_message(author=other_bot, content="latest bot note", msg_id=3), + make_history_message(author=adapter._client.user, content="our prior response", msg_id=2), + make_history_message(author=old_human, content="older than boundary", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == ( + "[Recent channel messages]\n" + "[Gemini [bot]] latest bot note\n" + "[Alice] latest human note" + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_context_skips_other_bots_when_allow_bots_none(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="human note", msg_id=3), + make_history_message(author=other_bot, content="bot note", msg_id=2), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == "[Recent channel messages]\n[Alice] human note" + + +@pytest.mark.asyncio +async def test_fetch_channel_context_uses_cache_to_narrow_window(adapter, monkeypatch): + """When _last_self_message_id is cached, the fetch passes after= to skip old messages.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + # Record the after= arg passed to history() + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=200)], + channel_id=777, + ) + + # Seed the cache — bot's last message in this channel was ID 100 + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 # trigger is newer than cache + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Verify cache was used: after= should be set (not None) + assert recorded_after["value"] is not None + + +@pytest.mark.asyncio +async def test_fetch_channel_context_cache_uses_latest_window_when_after_set(adapter, monkeypatch): + """Regression: discord.py defaults oldest_first=True when after= is provided. + + The hot cache path passes both after= and before=. We still want the latest + messages before the trigger, not the earliest messages after our prior + response, otherwise tool traces can crowd out the final answer. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 3 + + codex = SimpleNamespace(id=56, display_name="Codex", name="Codex", bot=True) + human = SimpleNamespace(id=57, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=codex, content="old tool trace 1", msg_id=101), + make_history_message(author=codex, content="old tool trace 2", msg_id=102), + make_history_message(author=codex, content="old tool trace 3", msg_id=103), + make_history_message(author=codex, content="final analysis", msg_id=104), + make_history_message(author=human, content="latest follow-up", msg_id=105), + ], + channel_id=777, + ) + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 200 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert "[Codex [bot]] final analysis" in result + assert "[Alice] latest follow-up" in result + assert "old tool trace 1" not in result + assert "old tool trace 2" not in result + + +@pytest.mark.asyncio +async def test_fetch_channel_context_ignores_stale_cache(adapter, monkeypatch): + """If cached ID is >= trigger ID (stale/future), fall back to cold-start scan.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=50)], + channel_id=777, + ) + + # Cache has a NEWER ID than the trigger — stale/invalid + adapter._last_self_message_id["777"] = "500" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Cache should have been ignored — after= should be None + assert recorded_after["value"] is None + + +@pytest.mark.asyncio +async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = False + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_per_user_channel_backfills_too(adapter, monkeypatch): + """Per-user sessions also benefit from backfill: Alice's session is missing + other-channel-participants' context and her own pre-mention messages.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = True + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_dm_does_not_backfill(adapter, monkeypatch): + """DMs skip backfill — every DM triggers the bot, so there's no mention gap.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + dm_channel = SimpleNamespace( + id=999, + name=None, + guild=None, + topic=None, + ) + # Make isinstance(channel, discord.DMChannel) return True + monkeypatch.setattr( + discord_platform.discord, "DMChannel", type(dm_channel), raising=False, + ) + + message = make_message( + channel=dm_channel, + content="hello in DM", + mentions=[], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_not_awaited() + if adapter.handle_message.await_args is not None: + event = adapter.handle_message.await_args.args[0] + assert event.channel_context is None + + diff --git a/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py index 908e023d883a..7e54515d6a6a 100644 --- a/tests/gateway/test_duplicate_reply_suppression.py +++ b/tests/gateway/test_duplicate_reply_suppression.py @@ -467,3 +467,59 @@ def test_old_behavior_would_have_promoted_partial(self): final_response_sent = True assert final_response_sent is True # the bug: partial promoted to final + + +class TestFinalContentDeliveredSuppression: + """When stream consumer delivered the final content but the cosmetic + final edit (cursor removal) failed, the gateway must suppress the + fallback send to prevent duplicate messages. + + Covers the scenario not handled by final_response_sent alone: + content reached the user via _send_or_edit, but the subsequent edit + that clears a typing cursor or streaming marker failed, leaving + final_response_sent=False even though the user already saw the text. + """ + + def test_content_delivered_but_final_edit_failed_suppresses(self): + """final_content_delivered=True + final_response_sent=False + must suppress (content already visible to user).""" + sc = SimpleNamespace( + already_sent=True, + final_response_sent=False, + final_content_delivered=True, + ) + response = {"final_response": "Hello!", "response_previewed": False} + + _streamed = bool(getattr(sc, "final_response_sent", False)) + _previewed = bool(response.get("response_previewed")) + _content_delivered = bool(getattr(sc, "final_content_delivered", False)) + _is_empty_sentinel = ( + not response.get("final_response") + or response.get("final_response") == "(empty)" + ) + if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered): + response["already_sent"] = True + + assert response.get("already_sent") is True + + def test_intermediate_text_only_does_not_suppress(self): + """already_sent=True from intermediate text + final_content_delivered=False + must NOT suppress (user still needs the real final answer).""" + sc = SimpleNamespace( + already_sent=True, + final_response_sent=False, + final_content_delivered=False, + ) + response = {"final_response": "Real answer", "response_previewed": False} + + _streamed = bool(getattr(sc, "final_response_sent", False)) + _previewed = bool(response.get("response_previewed")) + _content_delivered = bool(getattr(sc, "final_content_delivered", False)) + _is_empty_sentinel = ( + not response.get("final_response") + or response.get("final_response") == "(empty)" + ) + if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered): + response["already_sent"] = True + + assert "already_sent" not in response diff --git a/tests/gateway/test_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 83b70238430c..5ccc386d83e5 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/tests/gateway/test_feishu_bot_admission.py @@ -455,7 +455,36 @@ def test_admit_per_group_require_mention_overrides_global(): def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch): import asyncio - from gateway.platforms.feishu import FeishuAdapter + from gateway.platforms import feishu as feishu_mod + FeishuAdapter = feishu_mod.FeishuAdapter + + class _FakeBaseRequestBuilder: + def __init__(self): + self._request = SimpleNamespace() + + def http_method(self, value): + self._request.http_method = value + return self + + def uri(self, value): + self._request.uri = value + return self + + def token_types(self, value): + self._request.token_types = value + return self + + def build(self): + return self._request + + monkeypatch.setattr( + feishu_mod, + "BaseRequest", + SimpleNamespace(builder=lambda: _FakeBaseRequestBuilder()), + raising=False, + ) + monkeypatch.setattr(feishu_mod, "HttpMethod", SimpleNamespace(GET="GET"), raising=False) + monkeypatch.setattr(feishu_mod, "AccessTokenType", SimpleNamespace(TENANT="TENANT"), raising=False) adapter = object.__new__(FeishuAdapter) adapter._bot_open_id = "" diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index bd95fb6136f5..c329441531de 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -716,8 +716,10 @@ def test_module_importable_without_mautrix(self): "sys.meta_path.insert(0, _Blocker())\n" "for k in list(sys.modules):\n" " if k.startswith('mautrix'): del sys.modules[k]\n" + "from unittest.mock import patch\n" "from gateway.platforms.matrix import check_matrix_requirements\n" - "assert not check_matrix_requirements()\n" + "with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n" + " assert not check_matrix_requirements()\n" "print('OK')\n" )], capture_output=True, text=True, timeout=10, @@ -737,7 +739,8 @@ def test_check_requirements_with_token(self, monkeypatch): import mautrix # noqa: F401 assert check_matrix_requirements() is True except ImportError: - assert check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert check_matrix_requirements() is False def test_check_requirements_without_creds(self, monkeypatch): monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False) @@ -759,7 +762,8 @@ def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch): monkeypatch.setenv("MATRIX_ENCRYPTION", "true") from gateway.platforms import matrix as matrix_mod - with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ + patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): @@ -775,7 +779,8 @@ def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - assert matrix_mod.check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): """MATRIX_ENCRYPTION=true should pass if E2EE deps are available.""" @@ -789,7 +794,8 @@ def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - assert matrix_mod.check_matrix_requirements() is False + with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + assert matrix_mod.check_matrix_requirements() is False # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index e6bb823aa6ca..4ddc645b7b2f 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -394,3 +394,317 @@ def test_platform_label_plugin_fallback(self): assert "LabelTest" in label finally: _reg.unregister("labeltest") + + +# ── apply_yaml_config_fn (PlatformEntry field + load_gateway_config dispatch) ── + + +class TestApplyYamlConfigFnField: + """The hook field itself — defaults, custom values, signature.""" + + def test_default_is_none(self): + entry = PlatformEntry( + name="test", + label="Test", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + ) + assert entry.apply_yaml_config_fn is None + + def test_accepts_callable(self): + def _hook(yaml_cfg, platform_cfg): + return None + + entry = PlatformEntry( + name="test", + label="Test", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + apply_yaml_config_fn=_hook, + ) + assert entry.apply_yaml_config_fn is _hook + # Sanity-check the signature contract. + assert entry.apply_yaml_config_fn({"x": 1}, {"y": 2}) is None + + +class TestApplyYamlConfigFnDispatch: + """End-to-end dispatch through load_gateway_config(). + + Each test registers a temporary PlatformEntry, writes a config.yaml in + a tmp HERMES_HOME, calls load_gateway_config(), and asserts the hook + was invoked correctly. Cleanup unregisters the entry. + """ + + def _write_config(self, tmp_path, content: str): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(content, encoding="utf-8") + return hermes_home + + def _register_hook(self, name, hook_fn): + from gateway.platform_registry import platform_registry as _reg + + entry = PlatformEntry( + name=name, + label=name.title(), + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + source="plugin", + apply_yaml_config_fn=hook_fn, + ) + _reg.register(entry) + return _reg + + def test_hook_can_mutate_environ(self, tmp_path, monkeypatch): + """A hook that mutates os.environ has its env vars set after load.""" + env_var = "MYHOOKPLAT_FLAG" + monkeypatch.delenv(env_var, raising=False) + + def _hook(yaml_cfg, platform_cfg): + if "flag" in platform_cfg and not os.getenv(env_var): + os.environ[env_var] = str(platform_cfg["flag"]).lower() + return None + + reg = self._register_hook("myhookplat", _hook) + try: + home = self._write_config( + tmp_path, "myhookplat:\n flag: true\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + load_gateway_config() + + assert os.environ.get(env_var) == "true" + finally: + reg.unregister("myhookplat") + os.environ.pop(env_var, None) + + def test_hook_returned_dict_merges_into_extra(self, tmp_path, monkeypatch): + """A hook that returns a dict has it merged into PlatformConfig.extra.""" + + def _hook(yaml_cfg, platform_cfg): + return {"seeded_key": "seeded_value", "flag": platform_cfg.get("flag")} + + reg = self._register_hook("myextraplat", _hook) + try: + home = self._write_config( + tmp_path, "myextraplat:\n flag: yes\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + cfg = load_gateway_config() + + plat = Platform("myextraplat") + assert plat in cfg.platforms + extra = cfg.platforms[plat].extra + assert extra.get("seeded_key") == "seeded_value" + # flag value carried through from yaml_cfg arg. + assert extra.get("flag") is True + finally: + reg.unregister("myextraplat") + + def test_hook_receives_full_yaml_and_platform_subdict( + self, tmp_path, monkeypatch + ): + """Hook receives both the full yaml_cfg and its own platform sub-dict.""" + captured: dict = {} + + def _hook(yaml_cfg, platform_cfg): + captured["yaml_cfg"] = yaml_cfg + captured["platform_cfg"] = platform_cfg + return None + + reg = self._register_hook("mycaptureplat", _hook) + try: + home = self._write_config( + tmp_path, + "top_level_key: 1\n" + "mycaptureplat:\n" + " inner_key: deep\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + load_gateway_config() + + assert captured["yaml_cfg"].get("top_level_key") == 1 + assert captured["platform_cfg"] == {"inner_key": "deep"} + finally: + reg.unregister("mycaptureplat") + + def test_hook_exception_swallowed(self, tmp_path, monkeypatch): + """A misbehaving hook never aborts load_gateway_config().""" + + def _bad_hook(yaml_cfg, platform_cfg): + raise RuntimeError("plugin author bug") + + # Also register a well-behaved hook to ensure dispatch continues + # iterating after a bad one. + good_called = {"count": 0} + + def _good_hook(yaml_cfg, platform_cfg): + good_called["count"] += 1 + return None + + from gateway.platform_registry import platform_registry as _reg + _reg.register(PlatformEntry( + name="mybadplat", + label="MyBad", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + source="plugin", + apply_yaml_config_fn=_bad_hook, + )) + _reg.register(PlatformEntry( + name="mygoodplat", + label="MyGood", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + source="plugin", + apply_yaml_config_fn=_good_hook, + )) + try: + home = self._write_config( + tmp_path, + "mybadplat:\n k: v\n" + "mygoodplat:\n k: v\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Must not raise. + from gateway.config import load_gateway_config + load_gateway_config() + + assert good_called["count"] == 1 + finally: + _reg.unregister("mybadplat") + _reg.unregister("mygoodplat") + + def test_hook_skipped_when_platform_section_missing( + self, tmp_path, monkeypatch + ): + """Hook is NOT called when the platform's YAML section is absent.""" + called = {"count": 0} + + def _hook(yaml_cfg, platform_cfg): + called["count"] += 1 + return None + + reg = self._register_hook("myabsentplat", _hook) + try: + home = self._write_config(tmp_path, "telegram:\n k: v\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + load_gateway_config() + + assert called["count"] == 0 + finally: + reg.unregister("myabsentplat") + + def test_hook_skipped_when_platform_section_not_dict( + self, tmp_path, monkeypatch + ): + """Hook is NOT called when the platform's YAML section isn't a dict.""" + called = {"count": 0} + + def _hook(yaml_cfg, platform_cfg): + called["count"] += 1 + return None + + reg = self._register_hook("mybadshapeplat", _hook) + try: + home = self._write_config( + tmp_path, "mybadshapeplat: just-a-string\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + load_gateway_config() + + assert called["count"] == 0 + finally: + reg.unregister("mybadshapeplat") + + def test_env_var_takes_precedence_when_hook_uses_getenv_guard( + self, tmp_path, monkeypatch + ): + """The standard `not os.getenv(...)` guard preserves env > YAML.""" + env_var = "MYPRECPLAT_FLAG" + monkeypatch.setenv(env_var, "preexisting") + + def _hook(yaml_cfg, platform_cfg): + if "flag" in platform_cfg and not os.getenv(env_var): + os.environ[env_var] = str(platform_cfg["flag"]).lower() + return None + + reg = self._register_hook("myprecplat", _hook) + try: + home = self._write_config( + tmp_path, "myprecplat:\n flag: yaml-value\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config + load_gateway_config() + + # Pre-existing env var was NOT clobbered by the hook. + assert os.environ.get(env_var) == "preexisting" + finally: + reg.unregister("myprecplat") + os.environ.pop(env_var, None) + + +class TestPluginPlatformSharedKeyBridge: + """Plugin-registered platforms get the same shared-key bridging as built-ins. + + Without this, plugin authors using ``apply_yaml_config_fn`` would have to + re-implement bridging for every common key (``unauthorized_dm_behavior``, + ``notice_delivery``, ``reply_prefix``, ``require_mention``, ``dm_policy``, + ``allow_from``, etc.) — defeating the hook's whole point of letting + plugins focus on their *platform-specific* keys. + """ + + def _write_config(self, tmp_path, content: str): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(content, encoding="utf-8") + return hermes_home + + def test_shared_keys_bridged_for_plugin_platform(self, tmp_path, monkeypatch): + """A plugin platform's ``require_mention``/``dm_policy``/etc. flow into + ``PlatformConfig.extra`` without the plugin needing its own bridge.""" + from gateway.platform_registry import platform_registry as _reg + + _reg.register(PlatformEntry( + name="mysharedplat", + label="MySharedPlat", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + source="plugin", + )) + try: + home = self._write_config( + tmp_path, + "mysharedplat:\n" + " require_mention: true\n" + " dm_policy: allow\n" + " reply_prefix: \"→ \"\n" + " allow_from: [\"alice\", \"bob\"]\n", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from gateway.config import load_gateway_config, Platform + cfg = load_gateway_config() + + plat = Platform("mysharedplat") + assert plat in cfg.platforms + extra = cfg.platforms[plat].extra + assert extra.get("require_mention") is True + assert extra.get("dm_policy") == "allow" + assert extra.get("reply_prefix") == "→ " + assert extra.get("allow_from") == ["alice", "bob"] + finally: + _reg.unregister("mysharedplat") diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index a0c9fa6573cd..5d5cac54bd38 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -4,6 +4,7 @@ import json import os import sys +from types import SimpleNamespace from unittest import mock import pytest @@ -578,6 +579,7 @@ async def fake_api_request(*args, **kwargs): async def reconnect_after_delay(): await asyncio.sleep(0.3) adapter._running = True + adapter._ws = SimpleNamespace(closed=False) asyncio.get_event_loop().create_task(reconnect_after_delay()) @@ -603,6 +605,7 @@ async def test_send_succeeds_immediately_when_connected(self): """send() should not wait when already connected.""" adapter = self._make_adapter(app_id="a", client_secret="b") adapter._running = True + adapter._ws = SimpleNamespace(closed=False) adapter._http_client = mock.MagicMock() async def fake_api_request(*args, **kwargs): diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 57a8aefa5e81..b8fd45558cdc 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig +from gateway.platforms.base import MessageEvent from gateway.session import ( SessionSource, SessionStore, @@ -430,6 +431,76 @@ def test_dm_thread_shows_user_not_multi(self): assert "Multi-user thread" not in prompt +class TestSenderPrefixWithBackfill: + """Regression: sender prefix must not wrap the backfill context block. + + Tests exercise the real GatewayRunner._prepare_inbound_message_text() + method to ensure the [sender_name] prefix applies only to the trigger + message, not the channel_context backfill block. + """ + + @pytest.fixture() + def runner(self): + from gateway.run import GatewayRunner + + r = GatewayRunner.__new__(GatewayRunner) + r.config = GatewayConfig(group_sessions_per_user=False) + r.adapters = {} + r._model = "test-model" + r._base_url = "" + r._has_setup_skill = lambda: False + return r + + @pytest.fixture() + def source(self): + return SessionSource( + platform=Platform.DISCORD, + chat_id="c1", + chat_type="group", + user_name="Alice", + ) + + @pytest.mark.asyncio + async def test_plain_message_gets_prefix(self, runner, source): + """Normal message without backfill gets [sender] prefix.""" + event = MessageEvent(text="hello world", source=source) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result == "[Alice] hello world" + + @pytest.mark.asyncio + async def test_backfill_prefix_only_on_trigger(self, runner, source): + """Backfill context must NOT get the sender prefix.""" + event = MessageEvent( + text="hello world", + source=source, + channel_context="[Recent channel messages]\n[Bob] some context", + ) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result.startswith("[Recent channel messages]") + assert "[Alice] [Recent channel messages]" not in result + assert "[New message]\n[Alice] hello world" in result + + @pytest.mark.asyncio + async def test_backfill_preserves_context_block(self, runner, source): + """The backfill block should pass through unchanged — no double-prefixing.""" + context = "[Recent channel messages]\n[Bob] first\n[Charlie [bot]] second" + event = MessageEvent( + text="hey everyone", source=source, channel_context=context, + ) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result.startswith(context) + assert "[Alice] hey everyone" in result + assert "[Alice] [Bob]" not in result + assert "[Alice] [Charlie" not in result + assert "[Alice] [Recent" not in result + + class TestSessionStoreRewriteTranscript: """Regression: /retry and /undo must persist truncated history to disk.""" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 478370d8c414..bc09279eec4e 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -691,10 +691,98 @@ async def test_send_video_api_error_falls_back(self, adapter, tmp_path): adapter._app.client.chat_postMessage.assert_called_once() +# --------------------------------------------------------------------------- +# TestBangPrefixCommands +# --------------------------------------------------------------------------- + + +class TestBangPrefixCommands: + """``!cmd`` is rewritten to ``/cmd`` so commands work inside Slack threads. + + Slack natively rejects slash commands invoked from a thread reply + ("/queue is not supported in threads. Sorry!"). Typing ``!queue`` as a + plain text reply hits the message event pipeline instead, and the + adapter rewrites the leading ``!`` to ``/`` for any known gateway + command before downstream processing. + """ + + def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"): + evt = { + "text": text, + "user": "U_USER", + "channel": channel, + "channel_type": channel_type, + "ts": "1234567890.000001", + } + if thread_ts: + evt["thread_ts"] = thread_ts + return evt + + @pytest.mark.asyncio + async def test_bang_known_command_is_rewritten_to_slash(self, adapter): + """``!queue`` → ``/queue`` and tagged as COMMAND.""" + await adapter._handle_slack_message(self._make_event("!queue")) + + adapter.handle_message.assert_called_once() + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/queue") + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_bang_command_with_args_preserved(self, adapter): + """``!model gpt-5.4`` → ``/model gpt-5.4``.""" + await adapter._handle_slack_message(self._make_event("!model gpt-5.4")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/model gpt-5.4") + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_bang_works_inside_thread(self, adapter): + """The whole point: ``!stop`` inside a thread reply dispatches.""" + evt = self._make_event("!stop", thread_ts="1111111111.000001") + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/stop") + assert msg_event.message_type == MessageType.COMMAND + # thread_id is preserved on the source so the reply lands in the + # same thread. + assert msg_event.source.thread_id == "1111111111.000001" + + @pytest.mark.asyncio + async def test_bang_unknown_token_passes_through_unchanged(self, adapter): + """``!nice work`` is just a casual message — must NOT be rewritten.""" + await adapter._handle_slack_message(self._make_event("!nice work")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "!nice work" + assert msg_event.message_type != MessageType.COMMAND + + @pytest.mark.asyncio + async def test_bang_with_bot_suffix_resolves(self, adapter): + """``!stop@hermes`` matches the get_command() ``@suffix`` stripping.""" + await adapter._handle_slack_message(self._make_event("!stop@hermes")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/stop@hermes") + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_plain_slash_still_works(self, adapter): + """Sanity check — ``/queue`` (top-level channel/DM) still dispatches.""" + await adapter._handle_slack_message(self._make_event("/queue")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/queue") + assert msg_event.message_type == MessageType.COMMAND + + # --------------------------------------------------------------------------- # TestIncomingDocumentHandling # --------------------------------------------------------------------------- + class TestIncomingDocumentHandling: def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None): """Build a mock Slack message event with file attachments.""" diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index bfbc0bcdb366..f439d97250fd 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -195,6 +195,29 @@ async def test_disable_link_previews_sets_preview_kwargs(self): or kwargs.get("link_preview_options") is not None ) + @pytest.mark.asyncio + async def test_send_update_prompt_escapes_dynamic_prompt(self): + adapter = _make_adapter() + sent = {} + + async def mock_send_message(**kwargs): + sent.update(kwargs) + return SimpleNamespace(message_id=55) + + adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) + + result = await adapter.send_update_prompt( + chat_id="12345", + prompt="Fix [issue]_1 and verify *markdown*", + default="alpha_beta", + metadata={"thread_id": "999"}, + ) + + assert result.success is True + assert "MARKDOWN_V2" in repr(sent["parse_mode"]) + assert "Fix \\[issue\\]\\_1" in sent["text"] + assert "alpha\\_beta" in sent["text"] + @pytest.mark.asyncio async def test_truncates_long_command(self): adapter = _make_adapter() @@ -210,9 +233,6 @@ async def test_truncates_long_command(self): kwargs = adapter._bot.send_message.call_args[1] assert "..." in kwargs["text"] assert len(kwargs["text"]) < 5000 - - -# =========================================================================== # _handle_callback_query — approval button clicks # =========================================================================== @@ -251,6 +271,34 @@ async def test_resolves_approval_on_click(self): # State should be cleaned up assert 1 not in adapter._approval_state + @pytest.mark.asyncio + async def test_approval_callback_escapes_dynamic_user_name(self): + adapter = _make_adapter() + adapter._approval_state[3] = "agent:main:telegram:group:12345:99" + + query = AsyncMock() + query.data = "ea:once:3" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.from_user.first_name = "Alice_Bob" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + query.from_user.id = "12345" + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + with patch("tools.approval.resolve_gateway_approval", return_value=1): + await adapter._handle_callback_query(update, context) + + edit_kwargs = query.edit_message_text.call_args[1] + assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) + assert "Alice\\_Bob" in edit_kwargs["text"] + assert "Approved once" in edit_kwargs["text"] + @pytest.mark.asyncio async def test_deny_button(self): adapter = _make_adapter() diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 55fb118d8f76..90063a01a8bb 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -210,6 +210,19 @@ def test_bold_and_italic_in_same_line(self, adapter): assert "*bold*" in result assert "_italic_" in result + def test_reload_mcp_summary_escapes_dynamic_server_names(self, adapter): + content = ( + "🔄 **MCP Servers Reloaded**\n" + "♻️ Reconnected: agent_one, tool[beta]\n" + "➕ Added: alpha*prod\n" + "🔧 3 tool(s) available from 2 server(s)" + ) + result = adapter.format_message(content) + assert "*MCP Servers Reloaded*" in result + assert "agent\\_one" in result + assert "tool\\[beta\\]" in result + assert "alpha\\*prod" in result + # ========================================================================= # format_message - headers diff --git a/tests/gateway/test_telegram_model_picker.py b/tests/gateway/test_telegram_model_picker.py index e7c2cd11a4f6..3e1d4cf71e8c 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/tests/gateway/test_telegram_model_picker.py @@ -43,6 +43,109 @@ def _make_adapter(): class TestTelegramModelPicker: + @pytest.mark.asyncio + async def test_send_model_picker_escapes_dynamic_provider_label(self): + adapter = _make_adapter() + sent = {} + + async def mock_send_message(**kwargs): + sent.update(kwargs) + return SimpleNamespace(message_id=101) + + adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) + + result = await adapter.send_model_picker( + chat_id="12345", + providers=[ + {"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True} + ], + current_model="model_1", + current_provider="provider_one", + session_key="s", + on_model_selected=AsyncMock(), + metadata={"thread_id": "99999"}, + ) + + assert result.success is True + assert "MARKDOWN_V2" in repr(sent["parse_mode"]) + assert "provider\\_one" in sent["text"] + assert "`model_1`" in sent["text"] + + @pytest.mark.asyncio + async def test_back_button_escapes_dynamic_provider_label(self): + adapter = _make_adapter() + adapter._model_picker_state["12345"] = { + "providers": [{"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True}], + "current_model": "model_1", + "current_provider": "provider_one", + "session_key": "s", + "on_model_selected": AsyncMock(), + "msg_id": 42, + } + + query = AsyncMock() + query.data = "mb" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + await adapter._handle_model_picker_callback(query, "mb", "12345") + + edit_kwargs = query.edit_message_text.call_args[1] + assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) + assert "provider\\_one" in edit_kwargs["text"] + assert "`model_1`" in edit_kwargs["text"] + + @pytest.mark.asyncio + async def test_model_selected_edits_message_on_success(self): + """Regression: the mm: (model selected → switch) success path must + edit the picker message to show the confirmation and remove the + buttons. An earlier revision of this PR over-indented the + edit_message_text block so it lived inside the except branch and + only fired when the callback raised.""" + adapter = _make_adapter() + callback = AsyncMock(return_value="Switched to `gpt-5`") + adapter._model_picker_state["12345"] = { + "providers": [ + {"slug": "openai", "name": "OpenAI", "total_models": 1, "is_current": True} + ], + "current_model": "model_1", + "current_provider": "openai", + "session_key": "s", + "on_model_selected": callback, + "selected_provider": "openai", + "model_list": ["gpt-5"], + "msg_id": 42, + } + + query = AsyncMock() + query.data = "mm:0" + query.message = MagicMock() + query.message.chat_id = 12345 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + await adapter._handle_model_picker_callback(query, "mm:0", "12345") + + # The callback was invoked with the selected model + callback.assert_awaited_once() + # edit_message_text MUST be called on the success path (this is the + # regression we're guarding). + query.edit_message_text.assert_awaited() + edit_kwargs = query.edit_message_text.call_args[1] + assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) + # The dynamic result text was routed through format_message + # (backtick code blocks survive escaping). + assert "`gpt-5`" in edit_kwargs["text"] + # State is cleaned up after a successful switch. + assert "12345" not in adapter._model_picker_state + @pytest.mark.asyncio async def test_retries_without_thread_when_thread_not_found(self): adapter = _make_adapter() diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index 27c96ad4b2cc..d8a2672f4d6a 100644 --- a/tests/gateway/test_transcript_offset.py +++ b/tests/gateway/test_transcript_offset.py @@ -14,6 +14,8 @@ import pytest +from gateway.run import _preserve_queued_followup_history_offset + # --------------------------------------------------------------------------- # Helpers - replicate the filtering logic from _run_agent @@ -265,3 +267,60 @@ def test_tool_call_messages_preserved_in_filter(self): assert len(fixed_new) == 2 assert fixed_new[0]["content"] == "Now search for dogs" assert fixed_new[1]["content"] == "Dog results here." + + def test_recursive_queued_followup_keeps_outer_history_offset(self): + """Queued drain persistence must include every turn in the chain. + + ``_run_agent()`` recurses when a follow-up arrived while the current turn + was running. The recursive call naturally returns a later + ``history_offset`` because it received the previous turn as part of its + input history. If the outer caller persists transcript rows using that + later offset, it only sees the *last* queued turn as new and drops the + earlier queued turn from the transcript. + """ + history_before_chain = [ + {"role": "user", "content": "Earlier question"}, + {"role": "assistant", "content": "Earlier answer"}, + ] + first_followup_turn = [ + {"role": "user", "content": "First follow-up question"}, + {"role": "assistant", "content": "First follow-up answer"}, + ] + second_followup_turn = [ + {"role": "user", "content": "Second follow-up question"}, + {"role": "assistant", "content": "Second follow-up answer"}, + ] + + current_result = { + "history_offset": len(history_before_chain), + "messages": history_before_chain + first_followup_turn, + } + followup_result = { + "history_offset": len(history_before_chain + first_followup_turn), + "messages": ( + history_before_chain + + first_followup_turn + + second_followup_turn + ), + } + + merged = _preserve_queued_followup_history_offset( + current_result, + followup_result, + ) + assert merged["history_offset"] == len(history_before_chain) + + persisted = merged["messages"][merged["history_offset"]:] + assert persisted == first_followup_turn + second_followup_turn + + def test_recursive_queued_followup_preserves_smaller_existing_offset(self): + """Do not widen the slice if the nested result is already conservative.""" + current_result = {"history_offset": 4} + followup_result = {"history_offset": 3, "messages": []} + + merged = _preserve_queued_followup_history_offset( + current_result, + followup_result, + ) + + assert merged["history_offset"] == 3 diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 1cb4c7bf3d8e..81b1a57c0c94 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -46,6 +46,10 @@ def _make_adapter(): adapter._message_queue = asyncio.Queue() adapter._http_session = MagicMock() adapter._mention_patterns = [] + adapter._dm_policy = "open" + adapter._allow_from = set() + adapter._group_policy = "open" + adapter._group_allow_from = set() return adapter @@ -287,6 +291,41 @@ async def test_not_connected_returns_failure(self): assert "Not connected" in result.error +# --------------------------------------------------------------------------- +# bridge event metadata +# --------------------------------------------------------------------------- + +class TestBridgeEventMetadata: + """WhatsApp bridge metadata is preserved for downstream consumers.""" + + @pytest.mark.asyncio + async def test_quoted_reply_metadata_is_preserved_in_raw_message(self): + adapter = _make_adapter() + data = { + "messageId": "incoming-msg", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "senderName": "Tester", + "chatName": "Tester", + "isGroup": False, + "body": "approved", + "hasMedia": False, + "mediaUrls": [], + "quotedMessageId": "outbound-msg", + "quotedParticipant": "99999999999@s.whatsapp.net", + "quotedRemoteJid": "15551234567@s.whatsapp.net", + "hasQuotedMessage": True, + } + + event = await adapter._build_message_event(data) + + assert event is not None + assert event.raw_message["quotedMessageId"] == "outbound-msg" + assert event.raw_message["quotedParticipant"] == "99999999999@s.whatsapp.net" + assert event.raw_message["quotedRemoteJid"] == "15551234567@s.whatsapp.net" + assert event.raw_message["hasQuotedMessage"] is True + + # --------------------------------------------------------------------------- # display_config tier classification # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index afe974320c9c..206c75830b7f 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -296,3 +296,78 @@ def test_config_bridges_whatsapp_allow_from(monkeypatch, tmp_path): assert config.platforms[Platform.WHATSAPP].extra["allow_from"] == ["6281234567890@s.whatsapp.net"] assert __import__("os").environ["WHATSAPP_DM_POLICY"] == "allowlist" assert __import__("os").environ["WHATSAPP_ALLOWED_USERS"] == "6281234567890@s.whatsapp.net" + + +# --- Broadcast / status / newsletter pseudo-chats are always dropped --- + + +def test_status_broadcast_chats_are_always_dropped(): + """Felipe's gateway.log showed the agent replying to status@broadcast + (a contact's WhatsApp Story update). These pseudo-chats aren't real + conversations and the adapter must drop them regardless of dm_policy. + """ + from gateway.platforms.whatsapp import WhatsAppAdapter + + # Even on the most permissive config — open DMs, no allowlist — Stories + # and Channel posts must not reach the agent. + adapter = _make_adapter(dm_policy="open") + + # Classic Story update — what Felipe was seeing in production. + status_msg = _dm_message( + body="[video received]", + chatId="status@broadcast", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(status_msg) is False + + # Channel / Newsletter broadcast posts. + newsletter_msg = _dm_message( + body="check out our latest post", + chatId="120363999999999999@newsletter", + senderId="120363999999999999@newsletter", + ) + assert adapter._should_process_message(newsletter_msg) is False + + +def test_broadcast_filter_runs_before_allowlist(): + """A status@broadcast message from an allowlisted sender still drops — + we never want to reply to Stories, even from authorized contacts. + """ + adapter = _make_adapter( + dm_policy="allowlist", + allow_from=["34612345678@s.whatsapp.net"], + ) + + msg = _dm_message( + body="[image received]", + chatId="status@broadcast", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(msg) is False + + +def test_real_dm_still_processed_after_broadcast_filter(): + """Sanity check: the broadcast filter doesn't accidentally drop real DMs.""" + adapter = _make_adapter(dm_policy="open") + + msg = _dm_message( + body="hello", + chatId="34612345678@s.whatsapp.net", + senderId="34612345678@s.whatsapp.net", + ) + assert adapter._should_process_message(msg) is True + + +def test_is_broadcast_chat_helper_recognizes_common_jids(): + from gateway.platforms.whatsapp import WhatsAppAdapter + + assert WhatsAppAdapter._is_broadcast_chat("status@broadcast") is True + assert WhatsAppAdapter._is_broadcast_chat("STATUS@BROADCAST") is True + assert WhatsAppAdapter._is_broadcast_chat(" status@broadcast ") is True + assert WhatsAppAdapter._is_broadcast_chat("120363999999999999@newsletter") is True + assert WhatsAppAdapter._is_broadcast_chat("1234@broadcast") is True # broadcast list + # Real chats must not match. + assert WhatsAppAdapter._is_broadcast_chat("34612345678@s.whatsapp.net") is False + assert WhatsAppAdapter._is_broadcast_chat("120363001234567890@g.us") is False + assert WhatsAppAdapter._is_broadcast_chat("") is False + assert WhatsAppAdapter._is_broadcast_chat(None) is False # type: ignore[arg-type] diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 291b8b70d464..81859230ab79 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1099,6 +1099,159 @@ def test_provider_label(self): assert _PROVIDER_LABELS["huggingface"] == "Hugging Face" +# ============================================================================= +# NovitaAI provider tests (added by feat/add-novita-provider) +# ============================================================================= + +class TestNovitaProvider: + """Tests for NovitaAI — an OpenAI-compatible multi-model aggregator.""" + + def test_novita_profile_loads(self): + from providers import get_provider_profile + profile = get_provider_profile("novita") + assert profile is not None + assert profile.name == "novita" + assert profile.display_name == "NovitaAI" + assert profile.base_url == "https://api.novita.ai/openai/v1" + assert "NOVITA_API_KEY" in profile.env_vars + + def test_novita_aliases(self): + from providers import get_provider_profile + profile = get_provider_profile("novita") + assert "novita-ai" in profile.aliases + assert "novitaai" in profile.aliases + + def test_novita_alias_resolves(self): + assert resolve_provider("novita-ai") == "novita" + assert resolve_provider("novitaai") == "novita" + + def test_novita_in_provider_registry(self): + """Auto-registration from ProviderProfile should expose Novita.""" + assert "novita" in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["novita"] + assert pconfig.auth_type == "api_key" + assert pconfig.id == "novita" + assert pconfig.inference_base_url == "https://api.novita.ai/openai/v1" + assert pconfig.api_key_env_vars == ("NOVITA_API_KEY",) + assert pconfig.base_url_env_var == "NOVITA_BASE_URL" + + def test_novita_aliases_in_registry(self): + assert "novita-ai" in PROVIDER_REGISTRY + assert "novitaai" in PROVIDER_REGISTRY + + def test_main_provider_models_has_novita(self): + from hermes_cli.main import _PROVIDER_MODELS + assert "novita" in _PROVIDER_MODELS + assert len(_PROVIDER_MODELS["novita"]) >= 1 + + def test_models_py_has_novita(self): + from hermes_cli.models import _PROVIDER_MODELS + assert "novita" in _PROVIDER_MODELS + assert len(_PROVIDER_MODELS["novita"]) >= 1 + + def test_novita_model_lists_match(self): + """Model lists in main.py and models.py should be identical.""" + from hermes_cli.main import _PROVIDER_MODELS as main_models + from hermes_cli.models import _PROVIDER_MODELS as models_models + assert main_models["novita"] == models_models["novita"] + + def test_novita_models_use_org_name_format(self): + """Novita models should use org/name format.""" + from hermes_cli.models import _PROVIDER_MODELS + for model in _PROVIDER_MODELS["novita"]: + assert "/" in model, f"Novita model {model!r} missing org/ prefix" + + def test_novita_aliases_in_models_py(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("novita-ai") == "novita" + assert _PROVIDER_ALIASES.get("novitaai") == "novita" + + def test_novita_label(self): + from hermes_cli.models import _PROVIDER_LABELS + assert "novita" in _PROVIDER_LABELS + assert _PROVIDER_LABELS["novita"] == "NovitaAI" + + def test_novita_in_provider_prefixes(self): + from agent.model_metadata import _PROVIDER_PREFIXES + assert "novita" in _PROVIDER_PREFIXES + + def test_novita_url_to_provider(self): + from agent.model_metadata import _URL_TO_PROVIDER + assert _URL_TO_PROVIDER.get("api.novita.ai") == "novita" + + def test_context_size_in_context_length_keys(self): + """Novita /v1/models uses 'context_size' as the context length key.""" + from agent.model_metadata import _CONTEXT_LENGTH_KEYS + assert "context_size" in _CONTEXT_LENGTH_KEYS + + def test_novita_pricing_unit_conversion(self): + """Novita returns prices in 0.0001 USD per Mtok; divide by 10_000 * 1_000_000.""" + from agent.model_metadata import _extract_pricing + # Sample shape from real Novita /v1/models response + payload = { + "id": "deepseek/deepseek-v3-0324", + "input_token_price_per_m": 2690, # = $0.269 / Mtok + "output_token_price_per_m": 4000, # = $0.400 / Mtok + } + result = _extract_pricing(payload) + # Resulting strings represent per-token prices in dollars. + assert "prompt" in result + assert "completion" in result + assert float(result["prompt"]) == 2690 / 10_000 / 1_000_000 + assert float(result["completion"]) == 4000 / 10_000 / 1_000_000 + + def test_novita_pricing_cache(self, monkeypatch): + """_fetch_novita_pricing should cache results in _pricing_cache.""" + from hermes_cli import models as models_mod + monkeypatch.setenv("NOVITA_API_KEY", "sk-test-key") + monkeypatch.setenv("NOVITA_BASE_URL", "https://api.novita.ai/openai/v1") + models_mod._pricing_cache.pop("https://api.novita.ai/openai/v1", None) + + call_count = {"n": 0} + fake_payload = { + "data": [ + { + "id": "x/y", + "input_token_price_per_m": 1000, + "output_token_price_per_m": 2000, + } + ] + } + + class _FakeResp: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + import json as _json + return _json.dumps(fake_payload).encode() + + def fake_urlopen(req, timeout=None): + call_count["n"] += 1 + return _FakeResp() + + monkeypatch.setattr( + models_mod.urllib.request, "urlopen", fake_urlopen + ) + + # First call hits the network. + first = models_mod._fetch_novita_pricing() + assert "x/y" in first + assert call_count["n"] == 1 + + # Second call returns cached result without re-hitting the network. + second = models_mod._fetch_novita_pricing() + assert second == first + assert call_count["n"] == 1 + + # force_refresh bypasses the cache. + models_mod._fetch_novita_pricing(force_refresh=True) + assert call_count["n"] == 2 + + # ============================================================================= # MiniMax OAuth provider tests (added by feat/minimax-oauth-provider) # ============================================================================= diff --git a/tests/hermes_cli/test_auriko_provider.py b/tests/hermes_cli/test_auriko_provider.py new file mode 100644 index 000000000000..36d73f98c735 --- /dev/null +++ b/tests/hermes_cli/test_auriko_provider.py @@ -0,0 +1,253 @@ +"""Tests for Auriko provider plugin wiring.""" + +from __future__ import annotations + +import sys +import types +from unittest.mock import patch + +import pytest + +if "dotenv" not in sys.modules: + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + sys.modules["dotenv"] = fake_dotenv + +from agent.auxiliary_client import resolve_provider_client +from agent.model_metadata import ( + _PROVIDER_PREFIXES, + _URL_TO_PROVIDER, + _infer_provider_from_url, +) +from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider +from hermes_cli.config import OPTIONAL_ENV_VARS +from hermes_cli.main import _is_profile_api_key_provider +from hermes_cli.models import ( + CANONICAL_PROVIDERS, + _KNOWN_PROVIDER_NAMES, + _PROVIDER_ALIASES, + _PROVIDER_MODELS, + normalize_provider, + provider_model_ids, +) +from hermes_cli.providers import ( + determine_api_mode, + get_label, + get_provider, + normalize_provider as providers_normalize, +) +from providers import get_provider_profile +from providers.base import ProviderProfile + + +@pytest.fixture(autouse=True) +def _clear_provider_env(monkeypatch): + for key in ( + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "AURIKO_API_KEY", + "AURIKO_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + + +class TestAurikoPluginRegistration: + """Core plugin: registration, alias resolution, profile fields.""" + + def test_profile_registered(self): + p = get_provider_profile("auriko") + assert p is not None + assert p.name == "auriko" + + def test_alias_resolves(self): + p = get_provider_profile("auriko-ai") + assert p is not None + assert p.name == "auriko" + + def test_base_url(self): + p = get_provider_profile("auriko") + assert p.base_url == "https://api.auriko.ai/v1" + + def test_auth_type(self): + p = get_provider_profile("auriko") + assert p.auth_type == "api_key" + + def test_env_vars(self): + p = get_provider_profile("auriko") + assert "AURIKO_API_KEY" in p.env_vars + assert "AURIKO_BASE_URL" in p.env_vars + + def test_fallback_models_populated(self): + p = get_provider_profile("auriko") + assert len(p.fallback_models) >= 20 + assert "claude-opus-4-7" in p.fallback_models + assert "deepseek-v3.2" in p.fallback_models + + def test_default_aux_model(self): + p = get_provider_profile("auriko") + assert p.default_aux_model == "claude-haiku-4-5-20251001" + + +class TestAurikoAutoWiring: + """Verify auto-wired registrations — these should work with ONLY the plugin files.""" + + def test_auth_provider_registry(self): + assert "auriko" in PROVIDER_REGISTRY + cfg = PROVIDER_REGISTRY["auriko"] + assert cfg.auth_type == "api_key" + assert cfg.inference_base_url == "https://api.auriko.ai/v1" + + def test_auth_alias_registered(self): + assert "auriko-ai" in PROVIDER_REGISTRY + + def test_config_optional_env_vars(self): + assert "AURIKO_API_KEY" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["AURIKO_API_KEY"]["category"] == "provider" + assert OPTIONAL_ENV_VARS["AURIKO_API_KEY"]["password"] is True + assert "AURIKO_BASE_URL" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["AURIKO_BASE_URL"]["password"] is False + + def test_canonical_providers_entry(self): + slugs = [p.slug for p in CANONICAL_PROVIDERS] + assert "auriko" in slugs + + def test_url_to_provider_mapping(self): + assert _URL_TO_PROVIDER.get("api.auriko.ai") == "auriko" + + def test_resolve_provider_with_key(self, monkeypatch): + monkeypatch.setenv("AURIKO_API_KEY", "ak_live_test") + assert resolve_provider("auriko") == "auriko" + + def test_resolve_alias_with_key(self, monkeypatch): + monkeypatch.setenv("AURIKO_API_KEY", "ak_live_test") + assert resolve_provider("auriko-ai") == "auriko" + + +class TestAurikoModelCatalog: + """_PROVIDER_MODELS and _PROVIDER_ALIASES entries (manually added, not auto-wired).""" + + def test_static_models_exist(self): + assert "auriko" in _PROVIDER_MODELS + models = _PROVIDER_MODELS["auriko"] + assert "claude-opus-4-7" in models + assert "deepseek-v3.2" in models + assert "gemini-2.5-pro" in models + assert "grok-4.3" in models + assert len(models) >= 20 + + def test_alias_in_provider_aliases(self): + assert _PROVIDER_ALIASES.get("auriko-ai") == "auriko" + + def test_normalize_provider_resolves_alias(self): + assert normalize_provider("auriko-ai") == "auriko" + assert normalize_provider("auriko") == "auriko" + + def test_alias_in_known_provider_names(self): + assert "auriko" in _KNOWN_PROVIDER_NAMES + assert "auriko-ai" in _KNOWN_PROVIDER_NAMES + + +class TestAurikoProviderPrefixes: + """_PROVIDER_PREFIXES entry (manually added, not auto-wired).""" + + def test_auriko_in_prefixes(self): + assert "auriko" in _PROVIDER_PREFIXES + + def test_auriko_alias_in_prefixes(self): + assert "auriko-ai" in _PROVIDER_PREFIXES + + def test_infer_provider_from_url(self): + assert _infer_provider_from_url("https://api.auriko.ai/v1") == "auriko" + + +class TestAurikoModelFetch: + """Live model fetch vs static fallback.""" + + def test_provider_model_ids_prefers_live_fetch(self, monkeypatch): + live_models = ["claude-sonnet-4-6", "deepseek-v4-pro"] + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider_id: { + "provider": provider_id, + "api_key": "ak_live_key", + "base_url": "https://api.auriko.ai/v1", + "source": "AURIKO_API_KEY", + }, + ) + monkeypatch.setattr( + ProviderProfile, "fetch_models", lambda self, **kw: live_models, + ) + assert provider_model_ids("auriko") == live_models + + def test_provider_model_ids_falls_back_to_profile_fallback_models(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda provider_id: { + "provider": provider_id, + "api_key": "ak_live_key", + "base_url": "https://api.auriko.ai/v1", + "source": "AURIKO_API_KEY", + }, + ) + monkeypatch.setattr(ProviderProfile, "fetch_models", lambda self, **kw: None) + profile = get_provider_profile("auriko") + assert provider_model_ids("auriko") == list(profile.fallback_models) + + +class TestAurikoAuxiliary: + """Auxiliary client — default model and alias resolution.""" + + def test_resolve_provider_client(self, monkeypatch): + monkeypatch.setenv("AURIKO_API_KEY", "ak_live_test") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = object() + client, model = resolve_provider_client("auriko") + assert client is not None + assert model == "claude-haiku-4-5-20251001" + assert mock_openai.call_args.kwargs["api_key"] == "ak_live_test" + assert mock_openai.call_args.kwargs["base_url"] == "https://api.auriko.ai/v1" + + def test_resolve_via_alias(self, monkeypatch): + monkeypatch.setenv("AURIKO_API_KEY", "ak_live_test") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = object() + client, model = resolve_provider_client("auriko-ai") + assert client is not None + assert model == "claude-haiku-4-5-20251001" + + +class TestAurikoMainFlow: + """CLI dispatch — provider selection routes to api_key flow.""" + + def test_is_profile_api_key_provider(self): + assert _is_profile_api_key_provider("auriko") is True + + def test_determine_api_mode_defaults_to_chat_completions(self): + assert determine_api_mode("auriko") == "chat_completions" + + +class TestAurikoProvidersModule: + """hermes_cli/providers.py — HERMES_OVERLAYS, ALIASES, label.""" + + def test_get_provider_returns_provider_def(self): + pdef = get_provider("auriko") + assert pdef is not None + assert pdef.id == "auriko" + assert pdef.transport == "openai_chat" + assert pdef.base_url == "https://api.auriko.ai/v1" + + def test_get_provider_via_alias(self): + pdef = get_provider("auriko-ai") + assert pdef is not None + assert pdef.id == "auriko" + + def test_providers_normalize_resolves_alias(self): + assert providers_normalize("auriko-ai") == "auriko" + + def test_get_label(self): + assert get_label("auriko") == "Auriko" diff --git a/tests/hermes_cli/test_bedrock_model_picker.py b/tests/hermes_cli/test_bedrock_model_picker.py index 3b2c4d5dc7b0..70335be2186b 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/tests/hermes_cli/test_bedrock_model_picker.py @@ -17,6 +17,8 @@ """ import os +from contextlib import contextmanager +from types import ModuleType from unittest.mock import MagicMock, patch import pytest @@ -26,6 +28,19 @@ # Shared helpers / fixtures # --------------------------------------------------------------------------- + + +@contextmanager +def _mock_botocore_session(*, return_value=None): + """Patch botocore.session even when botocore is not installed.""" + botocore_mod = ModuleType("botocore") + session_mod = ModuleType("botocore.session") + session_mod.get_session = MagicMock(return_value=return_value) + botocore_mod.session = session_mod + with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}): + yield session_mod.get_session + + _EU_MODELS = [ {"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"}, {"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"}, @@ -276,7 +291,7 @@ def test_eu_region_from_botocore_profile_yields_eu_models(self): with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("botocore.session.get_session", return_value=mock_session): + _mock_botocore_session(return_value=mock_session): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -310,7 +325,7 @@ def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch): mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" - with patch("botocore.session.get_session", return_value=mock_session): + with _mock_botocore_session(return_value=mock_session): region = resolve_bedrock_region() assert region == "us-west-2", "env var should override botocore profile" diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py new file mode 100644 index 000000000000..b2e27f8c97bc --- /dev/null +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -0,0 +1,637 @@ +"""Tests for the codex MCP plugin migration helper.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_cli.codex_runtime_plugin_migration import ( + MIGRATION_MARKER, + MigrationReport, + _format_toml_value, + _strip_existing_managed_block, + _translate_one_server, + migrate, + render_codex_toml_section, +) + + +# ---- per-server translation ---- + +class TestTranslateOneServer: + def test_stdio_basic(self): + cfg, skipped = _translate_one_server("filesystem", { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"}, + }) + assert cfg == { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"}, + } + assert skipped == [] + + def test_stdio_with_cwd(self): + cfg, _ = _translate_one_server("custom", { + "command": "/usr/bin/myserver", + "cwd": "/var/lib/mcp", + }) + assert cfg["cwd"] == "/var/lib/mcp" + + def test_http_basic(self): + cfg, skipped = _translate_one_server("api", { + "url": "https://x.example/mcp", + "headers": {"Authorization": "Bearer abc"}, + }) + assert cfg == { + "url": "https://x.example/mcp", + "http_headers": {"Authorization": "Bearer abc"}, + } + assert skipped == [] + + def test_sse_falls_under_streamable_http_with_warning(self): + cfg, skipped = _translate_one_server("sse_server", { + "url": "http://localhost:8000/sse", + "transport": "sse", + }) + assert cfg["url"] == "http://localhost:8000/sse" + assert any("sse" in s.lower() for s in skipped) + + def test_timeouts_translate(self): + cfg, _ = _translate_one_server("x", { + "command": "y", + "timeout": 180, + "connect_timeout": 30, + }) + assert cfg["tool_timeout_sec"] == 180.0 + assert cfg["startup_timeout_sec"] == 30.0 + + def test_non_numeric_timeout_skipped(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "timeout": "not-a-number", + }) + assert "tool_timeout_sec" not in cfg + assert any("timeout" in s and "numeric" in s for s in skipped) + + def test_disabled_server_emits_enabled_false(self): + cfg, _ = _translate_one_server("x", { + "command": "y", + "enabled": False, + }) + assert cfg["enabled"] is False + + def test_enabled_true_omitted(self): + cfg, _ = _translate_one_server("x", {"command": "y", "enabled": True}) + assert "enabled" not in cfg # codex defaults to true + + def test_command_and_url_prefers_stdio_warns(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", "url": "http://z", + }) + assert "command" in cfg + assert "url" not in cfg + assert any("url" in s for s in skipped) + + def test_no_transport_returns_none(self): + cfg, skipped = _translate_one_server("broken", {"description": "x"}) + assert cfg is None + assert "no command or url" in skipped[0] + + def test_sampling_dropped_with_warning(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "sampling": {"enabled": True, "model": "gemini-3-flash"}, + }) + assert "sampling" not in cfg + assert any("sampling" in s for s in skipped) + + def test_unknown_keys_warned(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "totally_made_up_key": "value", + }) + assert "totally_made_up_key" not in cfg + assert any("totally_made_up_key" in s for s in skipped) + + def test_non_dict_input(self): + cfg, skipped = _translate_one_server("x", "notadict") # type: ignore[arg-type] + assert cfg is None + + +# ---- TOML rendering ---- + +class TestTomlValueFormatter: + def test_string_quoted(self): + assert _format_toml_value("hello") == '"hello"' + + def test_string_with_quotes_escaped(self): + assert _format_toml_value('a"b') == '"a\\"b"' + + def test_bool(self): + assert _format_toml_value(True) == "true" + assert _format_toml_value(False) == "false" + + def test_int(self): + assert _format_toml_value(42) == "42" + + def test_float(self): + assert _format_toml_value(180.0) == "180.0" + + def test_list_of_strings(self): + assert _format_toml_value(["a", "b"]) == '["a", "b"]' + + def test_inline_table(self): + out = _format_toml_value({"FOO": "bar"}) + assert out == '{ FOO = "bar" }' + + def test_empty_inline_table(self): + assert _format_toml_value({}) == "{}" + + def test_string_with_newline_escaped(self): + """TOML basic strings don't allow literal newlines — a path or + env var containing a newline must use \\n. Otherwise codex would + refuse to load the config.""" + out = _format_toml_value("line one\nline two") + assert "\n" not in out # no raw newline in output + assert "\\n" in out + + def test_string_with_tab_escaped(self): + out = _format_toml_value("col1\tcol2") + assert "\t" not in out + assert "\\t" in out + + def test_string_with_other_controls_escaped(self): + for raw, expected in [ + ("\r", "\\r"), + ("\f", "\\f"), + ("\b", "\\b"), + ]: + out = _format_toml_value(f"x{raw}y") + assert raw not in out, f"{raw!r} should be escaped" + assert expected in out, f"{expected!r} should be in output" + + def test_windows_path_escaped_correctly(self): + out = _format_toml_value(r"C:\Users\Alice\.codex") + # Each backslash should be doubled + assert out == r'"C:\\Users\\Alice\\.codex"' + + def test_atomic_write_no_temp_leak_on_success(self, tmp_path): + """The atomic-write path uses tempfile.mkstemp + rename. On + success the temp file should not be left behind.""" + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + expose_hermes_tools=False, + default_permission_profile=None) + # config.toml should exist + assert (tmp_path / "config.toml").exists() + # And no .config.toml.* temp files left behind + leftover = [p.name for p in tmp_path.iterdir() + if p.name.startswith(".config.toml.")] + assert leftover == [], f"temp file leaked after migration: {leftover}" + + def test_atomic_write_cleanup_on_rename_failure(self, tmp_path, monkeypatch): + """If rename fails partway through (out of disk, permissions, + crash), the temp file must be cleaned up. Otherwise repeated + failed migrations would pile up .config.toml.* files.""" + from pathlib import Path as _Path + original_replace = _Path.replace + + def failing_replace(self, target): + raise OSError("simulated disk full") + + monkeypatch.setattr(_Path, "replace", failing_replace) + report = migrate( + {"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + expose_hermes_tools=False, + default_permission_profile=None, + ) + # Error surfaced + assert any("simulated disk full" in e for e in report.errors) + # And no leaked temp file + leftover = [p.name for p in tmp_path.iterdir() + if p.name.startswith(".config.toml.")] + assert leftover == [], f"temp files leaked: {leftover}" + + def test_unsupported_type_raises(self): + with pytest.raises(ValueError): + _format_toml_value(object()) + + +class TestRenderToml: + def test_starts_with_marker(self): + out = render_codex_toml_section({}) + assert out.startswith(MIGRATION_MARKER) + + def test_empty_servers_emits_placeholder(self): + out = render_codex_toml_section({}) + assert "no MCP servers" in out + + def test_servers_sorted_alphabetically(self): + out = render_codex_toml_section({ + "zoo": {"command": "z"}, + "alpha": {"command": "a"}, + "middle": {"command": "m"}, + }) + # Find the section header positions and confirm order + a_pos = out.find("[mcp_servers.alpha]") + m_pos = out.find("[mcp_servers.middle]") + z_pos = out.find("[mcp_servers.zoo]") + assert 0 < a_pos < m_pos < z_pos + + def test_server_with_args_and_env(self): + out = render_codex_toml_section({ + "fs": { + "command": "npx", + "args": ["-y", "filesystem"], + "env": {"PATH": "/usr/bin"}, + } + }) + assert "[mcp_servers.fs]" in out + assert 'command = "npx"' in out + assert 'args = ["-y", "filesystem"]' in out + # Env emitted as inline table + assert 'env = { PATH = "/usr/bin" }' in out + + +# ---- existing-block stripping ---- + +class TestStripExistingManagedBlock: + def test_no_managed_block_unchanged(self): + text = "[other]\nfoo = 1\n" + assert _strip_existing_managed_block(text) == text + + def test_strips_managed_block_alone(self): + text = ( + f"{MIGRATION_MARKER}\n" + "\n" + "[mcp_servers.fs]\n" + 'command = "npx"\n' + ) + assert _strip_existing_managed_block(text).strip() == "" + + def test_preserves_user_content_above_managed_block(self): + text = ( + "[model]\n" + 'name = "gpt-5.5"\n' + "\n" + f"{MIGRATION_MARKER}\n" + "[mcp_servers.fs]\n" + 'command = "x"\n' + ) + out = _strip_existing_managed_block(text) + assert "[model]" in out + assert 'name = "gpt-5.5"' in out + assert "mcp_servers.fs" not in out + + def test_preserves_unrelated_section_after_managed_block(self): + text = ( + f"{MIGRATION_MARKER}\n" + "[mcp_servers.fs]\n" + 'command = "x"\n' + "\n" + "[providers]\n" + 'foo = "bar"\n' + ) + out = _strip_existing_managed_block(text) + assert "mcp_servers.fs" not in out + assert "[providers]" in out + assert 'foo = "bar"' in out + + +# ---- end-to-end migrate(, expose_hermes_tools=False) ---- + +class TestMigrate: + def test_no_servers_no_plugins_no_perms_writes_placeholder(self, tmp_path): + report = migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + assert MIGRATION_MARKER in text + assert "no MCP servers" in text or "no MCP servers, plugins, or permissions" in text + + def test_no_servers_still_writes_permissions_default(self, tmp_path): + """Even with zero MCP servers, enabling the runtime should write the + default permissions profile so users don't get prompted on every + write attempt. This is the fix for quirk #2.""" + report = migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + # Codex's schema: top-level `default_permissions` keying a built-in + # profile name (prefixed with ":"). NOT a [permissions] section + # (which is for *user-defined* profiles with structured fields). + assert 'default_permissions = ":workspace"' in text + assert report.wrote_permissions_default == ":workspace" + + def test_explicit_none_permissions_skips_block(self, tmp_path): + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, expose_hermes_tools=False) + text = (tmp_path / "config.toml").read_text() + assert "default_permissions" not in text + assert "[permissions]" not in text + assert report.wrote_permissions_default is None + + def test_plugin_discovery_writes_plugin_blocks(self, tmp_path, monkeypatch): + """Discovered curated plugins land as [plugins."@"] + blocks. This is what OpenClaw calls 'migrate native codex plugins.'""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + def fake_query(codex_home=None, timeout=8.0): + return [ + {"name": "google-calendar", "marketplace": "openai-curated", + "enabled": True}, + {"name": "github", "marketplace": "openai-curated", + "enabled": True}, + ], None + monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query) + + report = migrate({}, codex_home=tmp_path, discover_plugins=True) + text = (tmp_path / "config.toml").read_text() + assert '[plugins."github@openai-curated"]' in text + assert '[plugins."google-calendar@openai-curated"]' in text + assert "enabled = true" in text + assert "google-calendar@openai-curated" in report.migrated_plugins + assert "github@openai-curated" in report.migrated_plugins + + def test_plugin_discovery_skips_unavailable_plugins(self): + """Plugins where codex reports availability != AVAILABLE should + be skipped — they're broken/uninstallable on codex's side, so + migrating them would write config that fails at activation + time. Cf. openclaw#80815.""" + from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins + from unittest.mock import patch + + # Fake a plugin/list response where one plugin is unavailable + fake_response = { + "marketplaces": [{ + "name": "openai-curated", + "plugins": [ + {"name": "good-plugin", "installed": True, + "enabled": True, "availability": "AVAILABLE"}, + {"name": "broken-plugin", "installed": True, + "enabled": True, "availability": "UNAVAILABLE"}, + {"name": "auth-pending", "installed": True, + "enabled": True, "availability": "REQUIRES_AUTH"}, + # Plugin without availability field — pass through + # (older codex versions or marketplaces that don't + # set it should still work). + {"name": "legacy-plugin", "installed": True, + "enabled": True}, + ] + }] + } + + class FakeClient: + def __init__(self, **kw): pass + def initialize(self, **kw): pass + def request(self, method, params, timeout=None): + return fake_response + def close(self): pass + def __enter__(self): return self + def __exit__(self, *a): pass + + with patch("agent.transports.codex_app_server.CodexAppServerClient", + FakeClient): + plugins, err = _query_codex_plugins() + + assert err is None + names = [p["name"] for p in plugins] + assert "good-plugin" in names + assert "legacy-plugin" in names # no field → don't skip + assert "broken-plugin" not in names + assert "auth-pending" not in names + + def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch): + """If codex isn't installed or RPC fails, MCP migration still + completes. The error surfaces in the report but doesn't abort.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + def fake_query_fails(codex_home=None, timeout=8.0): + return [], "codex CLI not available" + monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query_fails) + + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) + assert report.written + assert report.migrated == ["x"] + assert report.plugin_query_error == "codex CLI not available" + assert report.migrated_plugins == [] + + def test_discover_plugins_false_skips_query(self, tmp_path, monkeypatch): + """Tests and restricted environments can opt out of the subprocess + spawn entirely.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + called = {"yes": False} + def boom(*a, **kw): + called["yes"] = True + return [], None + monkeypatch.setattr(crpm, "_query_codex_plugins", boom) + + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + assert called["yes"] is False + + def test_dry_run_skips_plugin_query(self, tmp_path, monkeypatch): + """Dry run should never spawn codex. Even with discover_plugins=True + the query is skipped because dry_run takes precedence.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + called = {"yes": False} + def boom(*a, **kw): + called["yes"] = True + return [], None + monkeypatch.setattr(crpm, "_query_codex_plugins", boom) + + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, dry_run=True, discover_plugins=True, expose_hermes_tools=False) + assert called["yes"] is False + + def test_re_run_replaces_plugin_block(self, tmp_path, monkeypatch): + """Plugin blocks are managed and re-runs should replace them + cleanly — same idempotency contract as MCP servers.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + # First run: only github + monkeypatch.setattr(crpm, "_query_codex_plugins", + lambda codex_home=None, timeout=8.0: ( + [{"name": "github", "marketplace": "openai-curated", "enabled": True}], + None, + )) + migrate({}, codex_home=tmp_path, discover_plugins=True, + default_permission_profile=None, expose_hermes_tools=False) + first = (tmp_path / "config.toml").read_text() + assert "github@openai-curated" in first + + # Second run: only canva (github went away) + monkeypatch.setattr(crpm, "_query_codex_plugins", + lambda codex_home=None, timeout=8.0: ( + [{"name": "canva", "marketplace": "openai-curated", "enabled": True}], + None, + )) + migrate({}, codex_home=tmp_path, discover_plugins=True, + default_permission_profile=None, expose_hermes_tools=False) + second = (tmp_path / "config.toml").read_text() + assert "github@openai-curated" not in second + assert "canva@openai-curated" in second + + def test_expose_hermes_tools_writes_callback_mcp_entry(self, tmp_path): + """When expose_hermes_tools=True (production default), an + [mcp_servers.hermes-tools] entry is written so codex calls back + into Hermes for browser/web/delegate_task/vision/memory tools. + + This is the fix for 'all other tools that codex doesn't provide + should be useable by hermes' — quirk #7.""" + report = migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, + expose_hermes_tools=True) + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.hermes-tools]" in text + assert "hermes_tools_mcp_server" in text + # Must include startup + tool timeouts so codex doesn't give up + assert "startup_timeout_sec" in text + assert "tool_timeout_sec" in text + # And the entry is reported + assert "hermes-tools" in report.migrated + + def test_expose_hermes_tools_disabled_skips_entry(self, tmp_path): + """expose_hermes_tools=False suppresses the callback registration.""" + migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, + expose_hermes_tools=False) + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.hermes-tools]" not in text + assert "hermes_tools_mcp_server" not in text + + def test_dry_run_doesnt_write(self, tmp_path): + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, dry_run=True, expose_hermes_tools=False) + assert report.dry_run is True + assert not (tmp_path / "config.toml").exists() + assert "x" in report.migrated + + def test_full_migration_round_trip(self, tmp_path): + hermes_cfg = { + "mcp_servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + }, + "github": { + "url": "https://api.github.com/mcp", + "headers": {"Authorization": "Bearer x"}, + }, + } + } + report = migrate(hermes_cfg, codex_home=tmp_path, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.filesystem]" in text + assert "[mcp_servers.github]" in text + assert 'command = "npx"' in text + assert 'url = "https://api.github.com/mcp"' in text + + def test_idempotent_re_run_replaces_managed_block(self, tmp_path): + # First migration + migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False) + first_text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.a]" in first_text + # Second migration with different servers + migrate({"mcp_servers": {"b": {"command": "y"}}}, codex_home=tmp_path, expose_hermes_tools=False) + second_text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.a]" not in second_text + assert "[mcp_servers.b]" in second_text + + def test_preserves_user_codex_config_above_marker(self, tmp_path): + target = tmp_path / "config.toml" + target.write_text( + "[model]\n" + 'profile = "default"\n' + "\n" + "[providers.openai]\n" + 'api_key = "sk-test"\n' + ) + migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False) + new_text = target.read_text() + # User's codex config preserved + assert "[model]" in new_text + assert 'profile = "default"' in new_text + assert "[providers.openai]" in new_text + # And new MCP block appended + assert "[mcp_servers.a]" in new_text + assert MIGRATION_MARKER in new_text + + def test_preserves_user_mcp_server_outside_managed_block(self, tmp_path): + """Quirk #6: when a user adds their own MCP server entry directly + to ~/.codex/config.toml outside Hermes' managed block, re-running + migration must preserve it. Tested both above and below the + managed block.""" + target = tmp_path / "config.toml" + target.write_text( + "[mcp_servers.user-above]\n" + 'command = "/usr/bin/above-server"\n' + 'args = ["--above"]\n' + ) + # First migrate — adds managed block below user content + migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}}, + codex_home=tmp_path, discover_plugins=False, + expose_hermes_tools=False) + text = target.read_text() + assert "user-above" in text, "user MCP server above managed block got nuked" + assert 'command = "/usr/bin/above-server"' in text + + # Append another user entry below the managed block + target.write_text( + text + "\n[mcp_servers.user-below]\ncommand = \"below-server\"\n" + ) + # Re-migrate — both should survive + migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}}, + codex_home=tmp_path, discover_plugins=False, + expose_hermes_tools=False) + final = target.read_text() + assert "user-above" in final + assert "user-below" in final + # And our managed block is still there with the new content + assert "[mcp_servers.hermes-mcp]" in final + + def test_skipped_keys_reported(self, tmp_path): + report = migrate({ + "mcp_servers": { + "x": { + "command": "y", + "sampling": {"enabled": True}, # codex has no equivalent + } + } + }, codex_home=tmp_path, expose_hermes_tools=False) + assert "x" in report.skipped_keys_per_server + assert any("sampling" in s for s in report.skipped_keys_per_server["x"]) + + def test_invalid_mcp_servers_value(self, tmp_path): + report = migrate({"mcp_servers": "notadict"}, codex_home=tmp_path, expose_hermes_tools=False) + assert any("not a dict" in e for e in report.errors) + + def test_server_without_transport_skipped_with_error(self, tmp_path): + report = migrate({ + "mcp_servers": {"broken": {"description": "no command/url"}} + }, codex_home=tmp_path, expose_hermes_tools=False) + assert "broken" not in report.migrated + assert any("broken" in e for e in report.errors) + + def test_summary_reports_migration_count(self, tmp_path): + report = migrate({ + "mcp_servers": {"a": {"command": "x"}, "b": {"command": "y"}} + }, codex_home=tmp_path, expose_hermes_tools=False) + summary = report.summary() + assert "Migrated 2 MCP server(s)" in summary + assert "- a" in summary + assert "- b" in summary diff --git a/tests/hermes_cli/test_codex_runtime_switch.py b/tests/hermes_cli/test_codex_runtime_switch.py new file mode 100644 index 000000000000..9a01543776ed --- /dev/null +++ b/tests/hermes_cli/test_codex_runtime_switch.py @@ -0,0 +1,231 @@ +"""Tests for the /codex-runtime slash-command shared logic. + +These cover the pure-Python state machine; CLI and gateway handlers are +tested separately because they involve config persistence and prompt +formatting that's surface-specific.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from hermes_cli import codex_runtime_switch as crs + + +class TestParseArgs: + @pytest.mark.parametrize("arg,expected", [ + ("", None), + (" ", None), + ("auto", "auto"), + ("codex_app_server", "codex_app_server"), + ("on", "codex_app_server"), + ("off", "auto"), + ("codex", "codex_app_server"), + ("default", "auto"), + ("hermes", "auto"), + ("ENABLE", "codex_app_server"), # case-insensitive + ("DiSaBlE", "auto"), + ]) + def test_valid_args(self, arg, expected): + value, errors = crs.parse_args(arg) + assert errors == [] + assert value == expected + + def test_invalid_arg_returns_error(self): + value, errors = crs.parse_args("turbo") + assert value is None + assert errors and "Unknown runtime" in errors[0] + + +class TestGetCurrentRuntime: + def test_default_when_unset(self): + assert crs.get_current_runtime({}) == "auto" + assert crs.get_current_runtime({"model": {}}) == "auto" + assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto" + + def test_unrecognized_falls_back_to_auto(self): + assert crs.get_current_runtime( + {"model": {"openai_runtime": "garbage"}} + ) == "auto" + + def test_explicit_codex(self): + assert crs.get_current_runtime( + {"model": {"openai_runtime": "codex_app_server"}} + ) == "codex_app_server" + + def test_handles_non_dict_config(self): + assert crs.get_current_runtime(None) == "auto" # type: ignore[arg-type] + assert crs.get_current_runtime("notadict") == "auto" # type: ignore[arg-type] + assert crs.get_current_runtime({"model": "notadict"}) == "auto" + + +class TestSetRuntime: + def test_creates_model_section_if_missing(self): + cfg = {} + old = crs.set_runtime(cfg, "codex_app_server") + assert old == "auto" + assert cfg["model"]["openai_runtime"] == "codex_app_server" + + def test_returns_previous_value(self): + cfg = {"model": {"openai_runtime": "codex_app_server"}} + old = crs.set_runtime(cfg, "auto") + assert old == "codex_app_server" + assert cfg["model"]["openai_runtime"] == "auto" + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + crs.set_runtime({}, "garbage") + + +class TestApply: + def test_read_only_call_reports_state(self): + cfg = {"model": {"openai_runtime": "codex_app_server"}} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")): + r = crs.apply(cfg, None) + assert r.success + assert r.new_value == "codex_app_server" + assert r.old_value == "codex_app_server" + assert "codex_app_server" in r.message + assert "0.130.0" in r.message + + def test_no_change_when_already_set(self): + cfg = {"model": {"openai_runtime": "auto"}} + r = crs.apply(cfg, "auto") + assert r.success + assert r.message == "openai_runtime already set to auto" + + def test_enable_blocked_when_codex_missing(self): + cfg = {} + with patch.object(crs, "check_codex_binary_ok", + return_value=(False, "codex not found")): + r = crs.apply(cfg, "codex_app_server") + assert r.success is False + assert "Cannot enable" in r.message + assert "npm i -g @openai/codex" in r.message + # Config NOT mutated on failure + assert cfg.get("model", {}).get("openai_runtime") in (None, "") + + def test_enable_succeeds_when_codex_present(self): + cfg = {} + persisted = {} + + def persist(c): + persisted.update(c) + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")): + r = crs.apply(cfg, "codex_app_server", persist_callback=persist) + assert r.success + assert r.new_value == "codex_app_server" + assert r.old_value == "auto" + assert r.requires_new_session is True + assert "via MCP" in r.message # hermes-tools callback message + assert cfg["model"]["openai_runtime"] == "codex_app_server" + assert persisted["model"]["openai_runtime"] == "codex_app_server" + + def test_disable_does_not_check_binary(self): + cfg = {"model": {"openai_runtime": "codex_app_server"}} + with patch.object(crs, "check_codex_binary_ok") as bin_check: + r = crs.apply(cfg, "auto") + assert r.success + # Binary check is irrelevant when disabling — should not be called + # with the codex_app_server enable-gate signature. + assert r.new_value == "auto" + assert r.old_value == "codex_app_server" + + def test_persist_callback_failure_reported(self): + cfg = {} + + def persist_boom(c): + raise IOError("disk full") + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")): + r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom) + assert r.success is False + assert "persist failed" in r.message + assert "disk full" in r.message + + def test_enable_triggers_mcp_migration(self): + """Enabling codex_app_server should auto-migrate Hermes mcp_servers + to ~/.codex/config.toml so the spawned subprocess sees them.""" + cfg = { + "mcp_servers": { + "filesystem": {"command": "npx", "args": ["-y", "fs-server"]}, + } + } + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig: + mig.return_value.migrated = ["filesystem", "hermes-tools"] + mig.return_value.migrated_plugins = [] + mig.return_value.plugin_query_error = None + mig.return_value.wrote_permissions_default = ":workspace" + mig.return_value.errors = [] + mig.return_value.target_path = "/fake/.codex/config.toml" + r = crs.apply(cfg, "codex_app_server") + assert r.success + assert mig.called # migration was triggered + # User MCP servers are reported (excluding internal hermes-tools) + assert "Migrated 1 MCP server" in r.message + assert "filesystem" in r.message + # Permissions default surfaces + assert "Default sandbox: :workspace" in r.message + # Hermes tool callback announcement + assert "via MCP" in r.message + + def test_disable_does_not_trigger_migration(self): + """Switching back to auto must not write to ~/.codex/.""" + cfg = { + "model": {"openai_runtime": "codex_app_server"}, + "mcp_servers": {"x": {"command": "y"}}, + } + with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig: + r = crs.apply(cfg, "auto") + assert r.success + assert not mig.called # disabling does not migrate + + def test_migration_failure_does_not_block_enable(self): + """If MCP migration raises, the runtime change still proceeds — + users can manually re-run migration later.""" + cfg = {"mcp_servers": {"x": {"command": "y"}}} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate", + side_effect=RuntimeError("disk full")): + r = crs.apply(cfg, "codex_app_server") + assert r.success # change still applied + assert r.new_value == "codex_app_server" + assert "MCP migration skipped" in r.message + assert "disk full" in r.message + + def test_binary_check_cached_within_apply(self): + """check_codex_binary_ok is invoked at most once per apply() call. + + The enable path has three sites that need the version (state report, + enable gate, success message). Without caching, a single + /codex-runtime invocation spawns `codex --version` three times. + Regression guard against a refactor that drops the cache. + """ + cfg = {} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")) as bin_check, \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate"): + r = crs.apply(cfg, "codex_app_server") + assert r.success + assert bin_check.call_count == 1, ( + f"check_codex_binary_ok was called {bin_check.call_count} time(s); " + "should be cached and called exactly once per apply()" + ) + + def test_binary_check_cached_on_read_only_call(self): + """Read-only call (new_value=None) calls the binary check exactly + once and reuses the result for the message.""" + cfg = {"model": {"openai_runtime": "codex_app_server"}} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")) as bin_check: + crs.apply(cfg, None) + assert bin_check.call_count == 1 diff --git a/tests/hermes_cli/test_env_load_cache.py b/tests/hermes_cli/test_env_load_cache.py new file mode 100644 index 000000000000..f898208c46a6 --- /dev/null +++ b/tests/hermes_cli/test_env_load_cache.py @@ -0,0 +1,193 @@ +"""Tests for the load_env() process-level cache. + +The cache exists to keep `hermes tools` → "All Platforms" fast: every +`get_env_value()` lookup used to re-read and re-sanitise the entire +.env file, racking up hundreds of ms across one menu render. The +cache is keyed on (path, mtime, size); writers (save_env_value / +remove_env_value / sanitise_env_file) call invalidate_env_cache(). +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + + +def _write_env(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + + +def test_load_env_caches_on_repeat_calls(): + """Repeated load_env() calls on the same file return the cached dict.""" + from hermes_cli.config import invalidate_env_cache, load_env + + invalidate_env_cache() + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".env", delete=False, encoding="utf-8" + ) as f: + f.write("OPENAI_API_KEY=sk-first\n") + env_path = Path(f.name) + + try: + with patch("hermes_cli.config.get_env_path", return_value=env_path): + first = load_env() + # Even if a writer outside our cache mutates the file, an + # mtime/size match means the cache still wins. We simulate that + # by writing identical bytes back — sanity check that the cache + # is keyed structurally, not on a counter. + second = load_env() + + assert first == second + assert first.get("OPENAI_API_KEY") == "sk-first" + finally: + env_path.unlink(missing_ok=True) + invalidate_env_cache() + + +def test_load_env_invalidates_on_mtime_bump(): + """Editing the file (mtime changes) invalidates the cache.""" + from hermes_cli.config import invalidate_env_cache, load_env + + invalidate_env_cache() + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".env", delete=False, encoding="utf-8" + ) as f: + f.write("OPENAI_API_KEY=sk-old\n") + env_path = Path(f.name) + + try: + with patch("hermes_cli.config.get_env_path", return_value=env_path): + first = load_env() + assert first.get("OPENAI_API_KEY") == "sk-old" + + # Rewrite file with new contents and bump mtime to make sure + # the FS records the change even on coarse-mtime filesystems. + _write_env(env_path, "OPENAI_API_KEY=sk-new\n") + future = env_path.stat().st_mtime + 5.0 + os.utime(env_path, (future, future)) + + second = load_env() + assert second.get("OPENAI_API_KEY") == "sk-new", ( + "load_env() returned stale value after file change" + ) + finally: + env_path.unlink(missing_ok=True) + invalidate_env_cache() + + +def test_invalidate_env_cache_forces_reread(): + """invalidate_env_cache() forces the next load_env() to hit the disk. + + This is the belt-and-braces knob for writers (save_env_value, etc.) + on filesystems where mtime resolution might miss a same-second write. + """ + from hermes_cli.config import invalidate_env_cache, load_env + + invalidate_env_cache() + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".env", delete=False, encoding="utf-8" + ) as f: + f.write("OPENAI_API_KEY=sk-old\n") + env_path = Path(f.name) + + try: + with patch("hermes_cli.config.get_env_path", return_value=env_path): + assert load_env().get("OPENAI_API_KEY") == "sk-old" + + # Rewrite WITHOUT bumping mtime — simulates same-second write. + mtime_before = env_path.stat().st_mtime + _write_env(env_path, "OPENAI_API_KEY=sk-new\n") + os.utime(env_path, (mtime_before, mtime_before)) + + # Without invalidation, cache hit might return stale. + invalidate_env_cache() + + assert load_env().get("OPENAI_API_KEY") == "sk-new" + finally: + env_path.unlink(missing_ok=True) + invalidate_env_cache() + + +def test_save_env_value_invalidates_cache(tmp_path, monkeypatch): + """save_env_value() invalidates the cache so subsequent reads see the update.""" + from hermes_cli import config as config_mod + from hermes_cli.config import invalidate_env_cache, load_env, save_env_value + + invalidate_env_cache() + + env_path = tmp_path / ".env" + env_path.write_text("EXISTING_KEY=old\n", encoding="utf-8") + + monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path) + monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None) + monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None) + monkeypatch.setattr(config_mod, "is_managed", lambda: False) + + try: + # Prime the cache. + first = load_env() + assert first.get("EXISTING_KEY") == "old" + + save_env_value("NEW_KEY", "shiny") + + # Same-second writes on coarse-mtime filesystems would normally + # let stale cache survive; invalidate_env_cache() inside the + # writer makes the next read see the new key. + result = load_env() + assert result.get("NEW_KEY") == "shiny" + assert result.get("EXISTING_KEY") == "old" + finally: + monkeypatch.delenv("NEW_KEY", raising=False) + invalidate_env_cache() + + +def test_remove_env_value_invalidates_cache(tmp_path, monkeypatch): + """remove_env_value() invalidates the cache so the removed key disappears.""" + from hermes_cli import config as config_mod + from hermes_cli.config import ( + invalidate_env_cache, + load_env, + remove_env_value, + save_env_value, + ) + + invalidate_env_cache() + + env_path = tmp_path / ".env" + monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path) + monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None) + monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None) + monkeypatch.setattr(config_mod, "is_managed", lambda: False) + + save_env_value("DOOMED_KEY", "value") + assert load_env().get("DOOMED_KEY") == "value" + + try: + removed = remove_env_value("DOOMED_KEY") + assert removed is True + assert "DOOMED_KEY" not in load_env() + finally: + monkeypatch.delenv("DOOMED_KEY", raising=False) + invalidate_env_cache() + + +def test_load_env_handles_missing_file(): + """A nonexistent .env returns {} and caches the empty result.""" + from hermes_cli.config import invalidate_env_cache, load_env + + invalidate_env_cache() + + nonexistent = Path(tempfile.gettempdir()) / "hermes-test-no-such-env-xyz123.env" + nonexistent.unlink(missing_ok=True) + + try: + with patch("hermes_cli.config.get_env_path", return_value=nonexistent): + assert load_env() == {} + assert load_env() == {} # cached + finally: + invalidate_env_cache() diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index b5afd716c9ed..9d8c3f48fe1d 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -514,3 +514,227 @@ def test_consecutive_parse_failures_persists_across_goalmanager_reloads( reloaded = load_goal("parse-fail-sid-4") assert reloaded is not None assert reloaded.consecutive_parse_failures == 2 + + +# ────────────────────────────────────────────────────────────────────── +# /subgoal — user-added criteria +# ────────────────────────────────────────────────────────────────────── + + +class TestGoalStateSubgoalsBackcompat: + def test_old_state_meta_row_loads_without_subgoals(self): + """A goal serialized BEFORE the subgoals field existed must + round-trip with an empty list, not crash.""" + import json + from hermes_cli.goals import GoalState + + legacy = json.dumps({ + "goal": "do a thing", + "status": "active", + "turns_used": 2, + "max_turns": 20, + "created_at": 1.0, + "last_turn_at": 2.0, + "consecutive_parse_failures": 0, + }) + state = GoalState.from_json(legacy) + assert state.goal == "do a thing" + assert state.subgoals == [] + + def test_subgoals_round_trip(self): + from hermes_cli.goals import GoalState + state = GoalState(goal="g", subgoals=["a", "b", "c"]) + rt = GoalState.from_json(state.to_json()) + assert rt.subgoals == ["a", "b", "c"] + + +class TestGoalManagerSubgoals: + def test_add_subgoal(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-add") + mgr.set("main goal") + text = mgr.add_subgoal(" use bullet points ") + assert text == "use bullet points" + assert mgr.state.subgoals == ["use bullet points"] + + def test_add_subgoal_requires_active_goal(self, hermes_home): + import pytest + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-noactive") + with pytest.raises(RuntimeError): + mgr.add_subgoal("oops") + + def test_add_empty_subgoal_rejected(self, hermes_home): + import pytest + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-empty") + mgr.set("g") + with pytest.raises(ValueError): + mgr.add_subgoal(" ") + + def test_remove_subgoal(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-remove") + mgr.set("g") + mgr.add_subgoal("first") + mgr.add_subgoal("second") + mgr.add_subgoal("third") + removed = mgr.remove_subgoal(2) + assert removed == "second" + assert mgr.state.subgoals == ["first", "third"] + + def test_remove_subgoal_out_of_range(self, hermes_home): + import pytest + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-oob") + mgr.set("g") + mgr.add_subgoal("only") + with pytest.raises(IndexError): + mgr.remove_subgoal(5) + with pytest.raises(IndexError): + mgr.remove_subgoal(0) + + def test_clear_subgoals(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-clear") + mgr.set("g") + mgr.add_subgoal("a") + mgr.add_subgoal("b") + prev = mgr.clear_subgoals() + assert prev == 2 + assert mgr.state.subgoals == [] + + def test_subgoals_persist_across_reloads(self, hermes_home): + """Subgoals stored in SessionDB survive a fresh GoalManager.""" + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sub-persist") + mgr.set("g") + mgr.add_subgoal("first") + mgr.add_subgoal("second") + + mgr2 = GoalManager(session_id="sub-persist") + assert mgr2.state.subgoals == ["first", "second"] + + +class TestContinuationPromptWithSubgoals: + def test_empty_subgoals_uses_original_template(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="cp-empty") + mgr.set("ship the feature") + prompt = mgr.next_continuation_prompt() + assert prompt is not None + assert "ship the feature" in prompt + assert "Additional criteria" not in prompt + + def test_with_subgoals_includes_them(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="cp-with") + mgr.set("ship the feature") + mgr.add_subgoal("write tests") + mgr.add_subgoal("update docs") + prompt = mgr.next_continuation_prompt() + assert prompt is not None + assert "ship the feature" in prompt + assert "Additional criteria" in prompt + assert "1. write tests" in prompt + assert "2. update docs" in prompt + + +class TestJudgeGoalWithSubgoals: + def test_judge_uses_subgoals_template_when_provided(self, hermes_home): + """judge_goal switches templates when subgoals is non-empty. + + We don't actually call the model — we patch the aux client to + capture the prompt that would be sent. + """ + from unittest.mock import patch, MagicMock + from hermes_cli import goals + + captured = {} + + class _FakeMsg: + content = '{"done": true, "reason": "all done"}' + class _FakeChoice: + message = _FakeMsg() + class _FakeResp: + choices = [_FakeChoice()] + class _FakeClient: + class chat: + class completions: + @staticmethod + def create(**kwargs): + captured.update(kwargs) + return _FakeResp() + + with patch.object(goals, "get_text_auxiliary_client", + return_value=(_FakeClient, "fake-model"), create=True), \ + patch.object(goals, "get_auxiliary_extra_body", + return_value=None, create=True), \ + patch("agent.auxiliary_client.get_text_auxiliary_client", + return_value=(_FakeClient, "fake-model")), \ + patch("agent.auxiliary_client.get_auxiliary_extra_body", + return_value=None): + verdict, reason, parse_failed = goals.judge_goal( + "ship the feature", + "ok shipped", + subgoals=["write tests", "update docs"], + ) + + # The aux client was called with a prompt that includes the subgoals. + sent_messages = captured.get("messages") or [] + user_msg = next((m["content"] for m in sent_messages if m["role"] == "user"), "") + assert "Additional criteria" in user_msg + assert "1. write tests" in user_msg + assert "2. update docs" in user_msg + assert "every additional criterion" in user_msg + assert verdict == "done" + + def test_judge_uses_original_template_when_no_subgoals(self, hermes_home): + from unittest.mock import patch + from hermes_cli import goals + + captured = {} + + class _FakeMsg: + content = '{"done": true, "reason": "ok"}' + class _FakeChoice: + message = _FakeMsg() + class _FakeResp: + choices = [_FakeChoice()] + class _FakeClient: + class chat: + class completions: + @staticmethod + def create(**kwargs): + captured.update(kwargs) + return _FakeResp() + + with patch("agent.auxiliary_client.get_text_auxiliary_client", + return_value=(_FakeClient, "fake-model")), \ + patch("agent.auxiliary_client.get_auxiliary_extra_body", + return_value=None): + goals.judge_goal("ship it", "done", subgoals=None) + + sent_messages = captured.get("messages") or [] + user_msg = next((m["content"] for m in sent_messages if m["role"] == "user"), "") + assert "Additional criteria" not in user_msg + assert "ship it" in user_msg + + +class TestStatusLineSubgoalCount: + def test_status_line_no_subgoals(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sl-empty") + mgr.set("ship it") + line = mgr.status_line() + assert "ship it" in line + assert "subgoal" not in line.lower() + + def test_status_line_with_subgoals(self, hermes_home): + from hermes_cli.goals import GoalManager + mgr = GoalManager(session_id="sl-with") + mgr.set("ship it") + mgr.add_subgoal("a") + mgr.add_subgoal("b") + line = mgr.status_line() + assert "2 subgoals" in line diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py new file mode 100644 index 000000000000..2a288b37a45e --- /dev/null +++ b/tests/hermes_cli/test_inventory.py @@ -0,0 +1,378 @@ +"""Behavior tests for hermes_cli.inventory. + +Locks the invariants the three migrated consumers (web_server.py +/api/model/options, tui_gateway model.options, tui_gateway model.save_key) +depend on: + +- load_picker_context() reproduces the inline 17-LOC config-slice exactly. +- with_overrides() is truthy-only (empty agent attrs must not clobber). +- build_models_payload() returns a stable {providers, model, provider} + shape and delegates curation to list_authenticated_providers (does not + call provider_model_ids per row). +- canonical_order keys on slug membership, not is_user_defined — section + 3 of list_authenticated_providers sets is_user_defined=True for + canonical slugs in the providers: dict, and that flag must NOT demote + them to the tail. +- picker_hints adds authenticated/auth_type/key_env/warning per row, + matching the TUI ModelPickerDialog shape. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from hermes_cli.inventory import ( + ConfigContext, + build_models_payload, + load_picker_context, +) + + +# ─── load_picker_context ─────────────────────────────────────────────── + + +def _cfg(model=None, providers=None, custom_providers=None) -> dict: + return { + "model": model if model is not None else {}, + "providers": providers if providers is not None else {}, + "custom_providers": custom_providers if custom_providers is not None else [], + } + + +def test_load_picker_context_full_dict(): + cfg = _cfg( + model={ + "default": "anthropic/claude-sonnet-4.6", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + }, + providers={"openrouter": {}}, + custom_providers=[{"name": "Ollama", "base_url": "http://localhost:11434/v1"}], + ) + with patch("hermes_cli.config.load_config", return_value=cfg): + ctx = load_picker_context() + assert ctx.current_model == "anthropic/claude-sonnet-4.6" + assert ctx.current_provider == "openrouter" + assert ctx.current_base_url == "https://openrouter.ai/api/v1" + assert "openrouter" in ctx.user_providers + # custom_providers comes from get_compatible_custom_providers, which + # merges legacy list + v12+ keyed providers — both present here means + # at least one row. + assert isinstance(ctx.custom_providers, list) + + +def test_load_picker_context_falls_back_to_name_when_default_missing(): + cfg = _cfg(model={"name": "gpt-5.4", "provider": "openai"}) + with patch("hermes_cli.config.load_config", return_value=cfg): + ctx = load_picker_context() + assert ctx.current_model == "gpt-5.4" + assert ctx.current_provider == "openai" + + +def test_load_picker_context_string_model_legacy_shape(): + """config.model can be a bare string in older configs.""" + cfg = {"model": "some-model", "providers": {}, "custom_providers": []} + with patch("hermes_cli.config.load_config", return_value=cfg): + ctx = load_picker_context() + assert ctx.current_model == "some-model" + assert ctx.current_provider == "" + assert ctx.current_base_url == "" + + +def test_load_picker_context_empty_config(): + cfg = _cfg() + with patch("hermes_cli.config.load_config", return_value=cfg): + ctx = load_picker_context() + assert ctx.current_provider == "" + assert ctx.current_model == "" + assert ctx.current_base_url == "" + assert ctx.user_providers == {} + assert ctx.custom_providers == [] + + +# ─── with_overrides ──────────────────────────────────────────────────── + + +def _empty_ctx(provider="orig", model="orig-model", base_url="orig-url"): + return ConfigContext( + current_provider=provider, + current_model=model, + current_base_url=base_url, + user_providers={}, + custom_providers=[], + ) + + +def test_with_overrides_truthy_only_strings(): + """Empty strings must NOT clobber disk config — TUI calls this with + empty getattr(agent, 'provider', '') when no agent is spawned yet.""" + ctx = _empty_ctx() + overlaid = ctx.with_overrides( + current_provider="", + current_model="", + current_base_url="", + ) + assert overlaid.current_provider == "orig" + assert overlaid.current_model == "orig-model" + assert overlaid.current_base_url == "orig-url" + + +def test_with_overrides_truthy_value_replaces(): + ctx = _empty_ctx() + overlaid = ctx.with_overrides(current_provider="anthropic") + assert overlaid.current_provider == "anthropic" + assert overlaid.current_model == "orig-model" # untouched + + +def test_with_overrides_no_args_returns_self_or_equivalent(): + ctx = _empty_ctx() + assert ctx.with_overrides() == ctx + + +# ─── build_models_payload ────────────────────────────────────────────── + + +def _list_auth_returning(rows: list[dict]): + """Patch list_authenticated_providers to return a fixed row list.""" + return patch( + "hermes_cli.model_switch.list_authenticated_providers", + return_value=rows, + ) + + +def test_build_models_payload_returns_expected_shape(): + rows = [ + {"slug": "openrouter", "name": "OpenRouter", "models": ["m1"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx(provider="openrouter", model="m1", base_url="") + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + assert set(payload.keys()) == {"providers", "model", "provider"} + assert payload["model"] == "m1" + assert payload["provider"] == "openrouter" + assert payload["providers"] == rows + + +def test_build_models_payload_does_not_call_provider_model_ids(): + """Curated lists must come from list_authenticated_providers, not + provider_model_ids — that would pull TTS/embeddings/etc. + """ + rows = [{"slug": "nous", "name": "Nous", "models": ["hermes-4-405b"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}] + ctx = _empty_ctx() + with _list_auth_returning(rows), \ + patch("hermes_cli.models.provider_model_ids") as mock_pm: + build_models_payload(ctx) + mock_pm.assert_not_called() + + +def test_include_unconfigured_appends_canonical_skeletons(): + """include_unconfigured=True adds CANONICAL_PROVIDERS rows that + list_authenticated_providers didn't emit. Skeleton rows have empty + models and source='canonical'.""" + rows = [ + {"slug": "openrouter", "name": "OpenRouter", "models": ["m1"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx(provider="openrouter") + with _list_auth_returning(rows): + payload = build_models_payload(ctx, include_unconfigured=True) + # All canonical providers other than openrouter should appear as + # skeleton rows. + from hermes_cli.models import CANONICAL_PROVIDERS + + seen_slugs = {r["slug"] for r in payload["providers"]} + for entry in CANONICAL_PROVIDERS: + assert entry.slug in seen_slugs, f"missing {entry.slug}" + # Skeletons have empty models and source='canonical'. + skeletons = [r for r in payload["providers"] + if r.get("source") == "canonical"] + assert all(r["models"] == [] for r in skeletons) + assert all(r["total_models"] == 0 for r in skeletons) + + +def test_include_unconfigured_skips_already_present_slugs(): + """If list_authenticated_providers already returned a row for a + canonical slug, include_unconfigured must NOT duplicate it.""" + rows = [ + {"slug": "openrouter", "name": "OpenRouter", "models": ["m1"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx, include_unconfigured=True) + or_rows = [r for r in payload["providers"] if r["slug"] == "openrouter"] + assert len(or_rows) == 1 + assert or_rows[0]["models"] == ["m1"] # the authenticated row, not skeleton + + +# ─── picker_hints ────────────────────────────────────────────────────── + + +def test_picker_hints_marks_authed_rows_authenticated(): + rows = [ + {"slug": "openrouter", "name": "OpenRouter", "models": ["m1"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx, picker_hints=True) + assert payload["providers"][0]["authenticated"] is True + + +def test_picker_hints_adds_warning_to_skeleton_rows(): + """Skeleton rows (unconfigured canonical providers) must carry the + setup hint the picker UI displays.""" + rows = [] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload( + ctx, include_unconfigured=True, picker_hints=True, + ) + skeleton_rows = [r for r in payload["providers"] + if r.get("source") == "canonical"] + assert skeleton_rows, "test setup: expected at least one skeleton row" + for row in skeleton_rows: + assert row["authenticated"] is False + assert "auth_type" in row + assert "warning" in row + # api_key providers get "paste X to activate" / others get the + # hermes model fallback. + assert ( + row["warning"].startswith("paste ") + or row["warning"].startswith("run `hermes model`") + ) + + +def test_picker_hints_api_key_warning_format(): + """For api_key providers with a defined env var, the warning must + point to that env var.""" + rows = [] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload( + ctx, include_unconfigured=True, picker_hints=True, + ) + # anthropic uses api_key + ANTHROPIC_API_KEY. + anthropic = next( + r for r in payload["providers"] if r["slug"] == "anthropic" + ) + assert "ANTHROPIC_API_KEY" in anthropic["warning"] + assert anthropic["warning"].startswith("paste ") + + +# ─── canonical_order ─────────────────────────────────────────────────── + + +def test_canonical_order_uses_slug_not_is_user_defined_flag(): + """Section 3 of list_authenticated_providers sets is_user_defined=True + for canonical slugs that appear in the providers: config dict. + canonical_order MUST key on slug membership, not the flag — otherwise + canonical providers configured via the keyed schema get demoted to + the tail. + """ + from hermes_cli.models import CANONICAL_PROVIDERS + + canonical_slug = CANONICAL_PROVIDERS[2].slug # any canonical + rows = [ + # A truly-custom row (correct: is_user_defined=True) + {"slug": "custom:Ollama", "name": "Ollama", "models": [], + "total_models": 0, "is_current": False, "is_user_defined": True, + "source": "user-config"}, + # A canonical row that the substrate flagged as user-defined + # because the user configured it via providers: dict. + {"slug": canonical_slug, "name": "x", "models": ["m1"], + "total_models": 1, "is_current": False, "is_user_defined": True, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx, canonical_order=True) + slugs = [r["slug"] for r in payload["providers"]] + # Canonical-slug row must come BEFORE truly-custom rows, regardless + # of is_user_defined. + canonical_idx = slugs.index(canonical_slug) + custom_idx = slugs.index("custom:Ollama") + assert canonical_idx < custom_idx, ( + f"canonical {canonical_slug} demoted to tail " + f"(canonical_idx={canonical_idx} > custom_idx={custom_idx})" + ) + + +def test_canonical_order_with_unconfigured_preserves_full_universe(): + """Combined picker call: include_unconfigured + picker_hints + + canonical_order is the production TUI shape. Verify the result + has CANONICAL_PROVIDERS in declaration order, hints applied, + custom rows trailing. + """ + from hermes_cli.models import CANONICAL_PROVIDERS + + rows = [ + {"slug": "custom:Ollama", "name": "Ollama", "models": [], + "total_models": 0, "is_current": False, "is_user_defined": True, + "source": "user-config"}, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload( + ctx, + include_unconfigured=True, + picker_hints=True, + canonical_order=True, + ) + slugs = [r["slug"] for r in payload["providers"]] + # First row: first canonical provider in declaration order. + assert slugs[0] == CANONICAL_PROVIDERS[0].slug + # Custom row trails canonical universe. + assert slugs.index("custom:Ollama") >= len(CANONICAL_PROVIDERS) + + +# ─── Integration: end-to-end through real load_picker_context ────────── + + +def test_end_to_end_with_real_context_no_credentials_leak(monkeypatch): + """Full pipeline: real load_picker_context + real + list_authenticated_providers. Verify no credential string ever + appears in the returned payload, even with picker_hints=True.""" + canary = "sk-canary-XYZ-must-not-appear" + monkeypatch.setenv("OPENROUTER_API_KEY", canary) + monkeypatch.setenv("ANTHROPIC_API_KEY", canary) + cfg = _cfg(model={"provider": "openrouter"}) + with patch("hermes_cli.config.load_config", return_value=cfg): + ctx = load_picker_context() + payload = build_models_payload( + ctx, include_unconfigured=True, picker_hints=True, + ) + import json as _json + + assert canary not in _json.dumps(payload) + + +def test_payload_shape_compatible_with_modelpickerdialog_frontend(): + """Frontend (web/src/components/ModelPickerDialog.tsx) reads: + name, slug, models, total_models, is_current, warning, authenticated. + Verify every authenticated/skeleton row exposes those keys. + """ + rows = [ + {"slug": "openrouter", "name": "OpenRouter", "models": ["m1"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload( + ctx, include_unconfigured=True, picker_hints=True, + ) + required_keys = {"name", "slug", "models", "total_models", "is_current", + "authenticated"} + for row in payload["providers"]: + missing = required_keys - row.keys() + assert not missing, f"row {row['slug']} missing keys: {missing}" diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 20f81d62d8fe..0b350ba9adba 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -177,6 +177,40 @@ def test_copilot_provider_saved_when_selected(self, config_home): assert model.get("api_mode") == "codex_responses" assert config["agent"]["reasoning_effort"] == "high" + def test_named_custom_provider_preserves_explicit_api_mode(self, config_home): + """Named custom providers should re-activate with their saved api_mode.""" + import yaml + + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Packy", + "base_url": "https://packy.example.com/v1", + "api_key": "sk-test", + "model": "gpt-5.4", + "api_mode": "codex_responses", + } + + # Patch fetch_api_models so the named custom flow returns one model; + # patch simple_term_menu to force the input() fallback; patch input to + # auto-select the first model from the fallback prompt. + from unittest.mock import MagicMock + fake_menu_module = MagicMock() + fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test") + with patch("hermes_cli.auth._save_model_choice"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("hermes_cli.models.fetch_api_models", return_value=["gpt-5.4"]), \ + patch.dict("sys.modules", {"simple_term_menu": fake_menu_module}), \ + patch("builtins.input", return_value="1"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model.get("provider") == "custom" + assert model.get("base_url") == "https://packy.example.com/v1" + assert model.get("api_mode") == "codex_responses" + def test_copilot_acp_provider_saved_when_selected(self, config_home): """_model_flow_copilot_acp should persist provider/base_url/model together.""" from hermes_cli.main import _model_flow_copilot_acp diff --git a/tests/hermes_cli/test_nous_auth_status_cache.py b/tests/hermes_cli/test_nous_auth_status_cache.py new file mode 100644 index 000000000000..5f0e733fb4c5 --- /dev/null +++ b/tests/hermes_cli/test_nous_auth_status_cache.py @@ -0,0 +1,144 @@ +"""Tests for the get_nous_auth_status() process-level cache. + +The cache avoids re-validating Nous credentials on every menu paint — +`hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs +against portal.nousresearch.com during one render. The cache is keyed +on auth.json mtime so login/logout flows invalidate naturally; tests +and other writers can also call invalidate_nous_auth_status_cache(). +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import patch + + +def _seed_auth_file(tmp_path): + """Drop a placeholder auth.json into the test HERMES_HOME. + + The exact content doesn't matter for cache-key purposes — only that + the file exists and we can mutate it to bump mtime. + """ + auth = tmp_path / "auth.json" + auth.write_text(json.dumps({"providers": {}}), encoding="utf-8") + return auth + + +def test_get_nous_auth_status_caches_consecutive_calls(tmp_path, monkeypatch): + """A second call within the TTL skips re-computing the snapshot.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _seed_auth_file(tmp_path) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + + def fake_compute(): + call_count["n"] += 1 + return {"logged_in": False, "source": "auth_store", "call": call_count["n"]} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + first = auth_mod.get_nous_auth_status() + second = auth_mod.get_nous_auth_status() + third = auth_mod.get_nous_auth_status() + + assert call_count["n"] == 1, ( + f"_compute_nous_auth_status was called {call_count['n']}× — " + "cache is not deduplicating within TTL." + ) + # Each call returns a copy so callers can't mutate the cached dict. + assert first == second == third + first["mutated"] = True + assert "mutated" not in auth_mod.get_nous_auth_status() + + auth_mod.invalidate_nous_auth_status_cache() + + +def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypatch): + """Touching auth.json (login/logout) forces a re-compute.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + auth_path = _seed_auth_file(tmp_path) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + + def fake_compute(): + call_count["n"] += 1 + return {"logged_in": False, "source": "auth_store", "call": call_count["n"]} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + auth_mod.get_nous_auth_status() + # Bump mtime forward so coarse-resolution filesystems still record + # a change. + future = auth_path.stat().st_mtime + 5.0 + os.utime(auth_path, (future, future)) + auth_mod.get_nous_auth_status() + + assert call_count["n"] == 2, ( + "auth.json mtime change should invalidate the cache, but only " + f"{call_count['n']} compute call(s) happened." + ) + + auth_mod.invalidate_nous_auth_status_cache() + + +def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch): + """Explicit invalidate forces the next call to re-compute.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _seed_auth_file(tmp_path) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + + def fake_compute(): + call_count["n"] += 1 + return {"logged_in": False, "source": "auth_store"} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + auth_mod.get_nous_auth_status() + auth_mod.invalidate_nous_auth_status_cache() + auth_mod.get_nous_auth_status() + + assert call_count["n"] == 2 + + auth_mod.invalidate_nous_auth_status_cache() + + +def test_get_nous_auth_status_caches_failure_path(tmp_path, monkeypatch): + """Logged-out snapshots are cached too — that's where the cost was. + + Teknium's case: ~31 cache misses per `hermes tools` "All Platforms" + menu paint, all returning logged_in=False after a failed refresh POST. + The whole point of the cache is to memoise that failure path too. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _seed_auth_file(tmp_path) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + + def fake_compute(): + call_count["n"] += 1 + return {"logged_in": False, "source": "auth_store", "error": "refresh failed"} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + for _ in range(10): + auth_mod.get_nous_auth_status() + + assert call_count["n"] == 1, ( + f"Logged-out snapshots must cache; got {call_count['n']} computes for 10 calls." + ) + + auth_mod.invalidate_nous_auth_status_cache() diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 959b22468329..7be43a236f2f 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -538,6 +538,95 @@ def test_first_valid_block_wins(self, monkeypatch): assert get_pre_tool_call_block_message("terminal", {}) == "first blocker" +class TestThreadToolWhitelist: + """Tests for the thread-local tool whitelist used by background review forks.""" + + def test_allowed_tool_passes_through_to_hooks(self, monkeypatch): + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, + ) + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [], + ) + set_thread_tool_whitelist({"memory", "skill_manage"}) + try: + assert get_pre_tool_call_block_message("memory", {}) is None + finally: + clear_thread_tool_whitelist() + + def test_disallowed_tool_blocked_with_message(self, monkeypatch): + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, + ) + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [], + ) + set_thread_tool_whitelist( + {"memory"}, deny_msg_fmt="denied: {tool_name}" + ) + try: + msg = get_pre_tool_call_block_message("terminal", {}) + assert msg == "denied: terminal" + finally: + clear_thread_tool_whitelist() + + def test_clear_restores_unrestricted_behavior(self, monkeypatch): + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, + ) + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [], + ) + set_thread_tool_whitelist({"memory"}) + clear_thread_tool_whitelist() + # After clearing, any tool should pass through to plugin hooks (which + # return [] here, so result is None). + assert get_pre_tool_call_block_message("terminal", {}) is None + + def test_whitelist_is_thread_local(self, monkeypatch): + """Setting a whitelist in one thread must NOT leak into another.""" + import threading + + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, + ) + + monkeypatch.setattr( + "hermes_cli.plugins.invoke_hook", + lambda hook_name, **kwargs: [], + ) + + # Main thread: install a restrictive whitelist. + set_thread_tool_whitelist({"memory"}) + try: + assert get_pre_tool_call_block_message("terminal", {}) is not None + + # Worker thread: should NOT inherit main thread's whitelist. + result = {} + + def worker(): + result["msg"] = get_pre_tool_call_block_message("terminal", {}) + + t = threading.Thread(target=worker) + t.start() + t.join() + assert result["msg"] is None, ( + "thread-local whitelist leaked across threads" + ) + finally: + clear_thread_tool_whitelist() + + # ── TestPluginContext ────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py new file mode 100644 index 000000000000..0c874facac79 --- /dev/null +++ b/tests/hermes_cli/test_proxy.py @@ -0,0 +1,512 @@ +"""Tests for the `hermes proxy` subcommand and its upstream adapters.""" + +from __future__ import annotations + +import asyncio +import json +import os +import threading +from pathlib import Path +from typing import Any, Dict +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli.proxy.adapters import ADAPTERS, get_adapter +from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential +from hermes_cli.proxy.adapters.nous_portal import NousPortalAdapter + + +# --------------------------------------------------------------------------- +# Adapter registry +# --------------------------------------------------------------------------- + + +def test_registry_lists_nous(): + assert "nous" in ADAPTERS + + +def test_get_adapter_returns_instance(): + adapter = get_adapter("nous") + assert isinstance(adapter, NousPortalAdapter) + assert isinstance(adapter, UpstreamAdapter) + + +def test_get_adapter_case_insensitive(): + assert isinstance(get_adapter("NOUS"), NousPortalAdapter) + assert isinstance(get_adapter(" Nous "), NousPortalAdapter) + + +def test_get_adapter_unknown_provider_raises(): + with pytest.raises(ValueError, match="anthropic"): + get_adapter("anthropic") # not yet implemented + + +# --------------------------------------------------------------------------- +# NousPortalAdapter +# --------------------------------------------------------------------------- + + +def _write_auth_store(hermes_home: Path, nous_state: Dict[str, Any]) -> Path: + """Write an auth.json with the given nous state into a hermetic HERMES_HOME.""" + auth_path = hermes_home / "auth.json" + auth_path.write_text(json.dumps({ + "version": 1, + "providers": {"nous": nous_state}, + })) + return auth_path + + +def test_nous_adapter_metadata(): + adapter = NousPortalAdapter() + assert adapter.name == "nous" + assert adapter.display_name == "Nous Portal" + assert "/chat/completions" in adapter.allowed_paths + assert "/embeddings" in adapter.allowed_paths + assert "/completions" in adapter.allowed_paths + assert "/models" in adapter.allowed_paths + + +def test_nous_adapter_not_authenticated_when_no_auth_file(tmp_path, monkeypatch): + # HERMES_HOME is already set by conftest, but make doubly sure + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter = NousPortalAdapter() + assert not adapter.is_authenticated() + + +def test_nous_adapter_not_authenticated_when_provider_missing(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": {}, + })) + assert not NousPortalAdapter().is_authenticated() + + +def test_nous_adapter_authenticated_with_agent_key(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "agent_key": "ov-test-key", + "agent_key_expires_at": "2099-01-01T00:00:00Z", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + }) + assert NousPortalAdapter().is_authenticated() + + +def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatch): + """If access_token+refresh_token exist but no agent_key yet, we can still mint.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + }) + assert NousPortalAdapter().is_authenticated() + + +def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + "client_id": "hermes-cli", + "portal_base_url": "https://portal.nousresearch.com", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + }) + + refreshed_state = { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + "client_id": "hermes-cli", + "portal_base_url": "https://portal.nousresearch.com", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + "agent_key": "minted-bearer", + "agent_key_expires_at": "2099-01-01T00:00:00Z", + } + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + return_value=refreshed_state, + ) as mock_refresh: + adapter = NousPortalAdapter() + cred = adapter.get_credential() + + mock_refresh.assert_called_once() + assert cred.bearer == "minted-bearer" + assert cred.base_url == "https://inference-api.nousresearch.com/v1" + assert cred.expires_at == "2099-01-01T00:00:00Z" + assert cred.token_type == "Bearer" + + # Verify state was persisted back + stored = json.loads((tmp_path / "auth.json").read_text()) + assert stored["providers"]["nous"]["agent_key"] == "minted-bearer" + + +def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + adapter = NousPortalAdapter() + with pytest.raises(RuntimeError, match="hermes login nous"): + adapter.get_credential() + + +def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + }) + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + side_effect=RuntimeError("Refresh session has been revoked"), + ): + adapter = NousPortalAdapter() + with pytest.raises(RuntimeError, match="Refresh session has been revoked"): + adapter.get_credential() + + +def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch): + """If the refresh helper succeeds but produces no agent_key, we surface a clear error.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + }) + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + return_value={"access_token": "a", "refresh_token": "r"}, + ): + adapter = NousPortalAdapter() + with pytest.raises(RuntimeError, match="did not return a usable agent_key"): + adapter.get_credential() + + +def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch): + """Two parallel get_credential() calls must serialize through the lock.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "a", "refresh_token": "r", + }) + + call_log: list = [] + in_flight = threading.Event() + overlap_detected = threading.Event() + counter = [0] + counter_lock = threading.Lock() + + def serializing_refresh(state, **kwargs): + # If another thread is already inside refresh, the lock is broken. + if in_flight.is_set(): + overlap_detected.set() + in_flight.set() + try: + call_log.append(threading.current_thread().ident) + # Simulate refresh latency so any race window is exposed. + import time + time.sleep(0.05) + with counter_lock: + counter[0] += 1 + idx = counter[0] + return { + **state, + "agent_key": f"key-{idx}", + "agent_key_expires_at": "2099-01-01T00:00:00Z", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + } + finally: + in_flight.clear() + + adapter = NousPortalAdapter() + results: list = [] + errors: list = [] + + def worker(): + try: + results.append(adapter.get_credential().bearer) + except Exception as exc: # pragma: no cover - shouldn't happen + errors.append(exc) + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + side_effect=serializing_refresh, + ): + threads = [threading.Thread(target=worker) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"workers errored: {errors}" + assert len(results) == 3 + assert len(call_log) == 3 + assert not overlap_detected.is_set(), "refresh calls overlapped — lock is broken" + assert all(r.startswith("key-") for r in results) + + +# --------------------------------------------------------------------------- +# Server: path filtering + forwarding +# +# We run the proxy AND a fake upstream as real aiohttp servers on ephemeral +# ports. Avoids pytest-aiohttp's fixtures (extra dependency for one test file). +# --------------------------------------------------------------------------- + +aiohttp = pytest.importorskip("aiohttp") +from aiohttp import web # noqa: E402 + +from hermes_cli.proxy.server import create_app # noqa: E402 + + +class FakeAdapter(UpstreamAdapter): + """A test adapter that returns a fixed credential without touching disk.""" + + def __init__(self, base_url: str, bearer: str = "test-bearer", + allowed=None, raise_on_credential=False): + self._base_url = base_url + self._bearer = bearer + self._allowed = frozenset(allowed or ["/chat/completions"]) + self._raise = raise_on_credential + self.calls = 0 + + @property + def name(self): return "fake" + + @property + def display_name(self): return "Fake Provider" + + @property + def allowed_paths(self): return self._allowed + + def is_authenticated(self): return True + + def get_credential(self): + self.calls += 1 + if self._raise: + raise RuntimeError("simulated auth failure") + return UpstreamCredential( + bearer=self._bearer, base_url=self._base_url, + expires_at="2099-01-01T00:00:00Z", + ) + + +async def _start_runner(app: "web.Application"): + """Spin up an aiohttp app on an ephemeral localhost port. Returns (runner, base_url).""" + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, host="127.0.0.1", port=0) + await site.start() + sockets = list(site._server.sockets) # type: ignore[union-attr] + port = sockets[0].getsockname()[1] + return runner, f"http://127.0.0.1:{port}" + + +def _build_fake_upstream(captured: Dict[str, Any]) -> "web.Application": + async def echo(request): + body = await request.read() + captured["requests"].append({ + "method": request.method, + "path": request.path, + "auth": request.headers.get("Authorization"), + "body": body.decode("utf-8") if body else "", + }) + return web.json_response({"echoed": True, "path": request.path}) + + async def sse(request): + resp = web.StreamResponse( + status=200, headers={"Content-Type": "text/event-stream"}, + ) + await resp.prepare(request) + for chunk in [b"data: hello\n\n", b"data: world\n\n", b"data: [DONE]\n\n"]: + await resp.write(chunk) + await resp.write_eof() + return resp + + app = web.Application() + app.router.add_route("*", "/v1/chat/completions", echo) + app.router.add_route("*", "/v1/embeddings", echo) + app.router.add_route("*", "/v1/sse", sse) + return app + + +def test_server_forwards_chat_completions(): + async def run(): + captured: Dict[str, Any] = {"requests": []} + upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured)) + adapter = FakeAdapter(f"{upstream_base}/v1", bearer="real-portal-key") + proxy_runner, proxy_base = await _start_runner(create_app(adapter)) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{proxy_base}/v1/chat/completions", + json={"model": "Hermes-4-70B", + "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer client-dummy-key"}, + ) as resp: + assert resp.status == 200 + data = await resp.json() + assert data["echoed"] is True + + assert len(captured["requests"]) == 1 + req = captured["requests"][0] + assert req["auth"] == "Bearer real-portal-key" + assert "Hermes-4-70B" in req["body"] + finally: + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + asyncio.run(run()) + + +def test_server_rejects_disallowed_path(): + async def run(): + adapter = FakeAdapter("http://unused.example/v1", allowed=["/chat/completions"]) + runner, base = await _start_runner(create_app(adapter)) + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{base}/v1/random/endpoint") as resp: + assert resp.status == 404 + body = await resp.json() + assert body["error"]["type"] == "path_not_allowed" + assert "/chat/completions" in body["error"]["message"] + finally: + await runner.cleanup() + + asyncio.run(run()) + + +def test_server_returns_401_when_adapter_fails(): + async def run(): + adapter = FakeAdapter("http://unused.example/v1", raise_on_credential=True) + runner, base = await _start_runner(create_app(adapter)) + try: + async with aiohttp.ClientSession() as session: + async with session.post(f"{base}/v1/chat/completions", json={}) as resp: + assert resp.status == 401 + body = await resp.json() + assert body["error"]["type"] == "upstream_auth_failed" + assert "simulated auth failure" in body["error"]["message"] + finally: + await runner.cleanup() + + asyncio.run(run()) + + +def test_server_health_endpoint(): + async def run(): + adapter = FakeAdapter("http://unused.example/v1") + runner, base = await _start_runner(create_app(adapter)) + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{base}/health") as resp: + assert resp.status == 200 + body = await resp.json() + assert body["status"] == "ok" + assert body["upstream"] == "Fake Provider" + assert body["authenticated"] is True + finally: + await runner.cleanup() + + asyncio.run(run()) + + +def test_server_streams_sse(): + async def run(): + captured: Dict[str, Any] = {"requests": []} + upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured)) + adapter = FakeAdapter(f"{upstream_base}/v1", allowed=["/sse"]) + proxy_runner, proxy_base = await _start_runner(create_app(adapter)) + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"{proxy_base}/v1/sse") as resp: + assert resp.status == 200 + chunks = [] + async for chunk in resp.content.iter_any(): + chunks.append(chunk) + full = b"".join(chunks) + assert b"data: hello" in full + assert b"data: [DONE]" in full + finally: + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + asyncio.run(run()) + + +def test_server_strips_client_auth_header(): + """The client's Authorization header MUST NOT reach the upstream.""" + async def run(): + captured: Dict[str, Any] = {"requests": []} + upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured)) + adapter = FakeAdapter(f"{upstream_base}/v1", bearer="ours") + proxy_runner, proxy_base = await _start_runner(create_app(adapter)) + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{proxy_base}/v1/chat/completions", + json={}, + headers={"Authorization": "Bearer SHOULD_NOT_LEAK"}, + ) as resp: + await resp.read() + assert captured["requests"][0]["auth"] == "Bearer ours" + assert "SHOULD_NOT_LEAK" not in captured["requests"][0]["auth"] + finally: + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# CLI handlers +# --------------------------------------------------------------------------- + + +def test_cmd_proxy_status_runs(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli.proxy.cli import cmd_proxy_status + + args = MagicMock() + rc = cmd_proxy_status(args) + assert rc == 0 + out = capsys.readouterr().out + assert "nous" in out + assert "Nous Portal" in out + assert "not logged in" in out + + +def test_cmd_proxy_providers_runs(capsys): + from hermes_cli.proxy.cli import cmd_proxy_list_providers + + args = MagicMock() + rc = cmd_proxy_list_providers(args) + assert rc == 0 + out = capsys.readouterr().out + assert "nous" in out + assert "Nous Portal" in out + + +def test_cmd_proxy_start_refuses_unknown_provider(capsys): + from hermes_cli.proxy.cli import cmd_proxy_start + + args = MagicMock() + args.provider = "no-such-provider" + args.host = None + args.port = None + rc = cmd_proxy_start(args) + assert rc == 2 + err = capsys.readouterr().err + assert "no-such-provider" in err + + +def test_cmd_proxy_start_refuses_when_unauthenticated(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli.proxy.cli import cmd_proxy_start + + args = MagicMock() + args.provider = "nous" + args.host = None + args.port = None + rc = cmd_proxy_start(args) + assert rc == 2 + err = capsys.readouterr().err + assert "hermes login nous" in err diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index f7b491ddf31e..0e2b2d8f70be 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -573,48 +573,6 @@ def fake_prompt(message, default="", **kwargs): assert defaults[" Vercel team ID"] == "linked-team" -def test_offer_launch_chat_relaunches_via_bin(monkeypatch): - from hermes_cli import setup as setup_mod - from hermes_cli import relaunch as relaunch_mod - - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) - monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes") - - exec_calls = [] - - def fake_execvp(path, argv): - exec_calls.append((path, argv)) - raise SystemExit(0) - - monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) - - with pytest.raises(SystemExit): - setup_mod._offer_launch_chat() - - assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])] - - -def test_offer_launch_chat_falls_back_to_module(monkeypatch): - from hermes_cli import setup as setup_mod - from hermes_cli import relaunch as relaunch_mod - - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) - monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None) - - exec_calls = [] - - def fake_execvp(path, argv): - exec_calls.append((path, argv)) - raise SystemExit(0) - - monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) - - with pytest.raises(SystemExit): - setup_mod._offer_launch_chat() - - assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])] - - def test_setup_slack_saves_home_channel(monkeypatch): """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one.""" saved = {} diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index e627b6196304..c3550e9e4cdf 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -262,7 +262,6 @@ def test_migration_offered_during_first_time_setup(self, tmp_path): patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "save_config"), patch.object(setup_mod, "_print_setup_summary"), - patch.object(setup_mod, "_offer_launch_chat"), ): setup_mod.run_setup_wizard(args) @@ -294,7 +293,6 @@ def tracking_load_config(): patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "save_config"), patch.object(setup_mod, "_print_setup_summary"), - patch.object(setup_mod, "_offer_launch_chat"), ): setup_mod.run_setup_wizard(args) @@ -327,7 +325,6 @@ def test_reloaded_config_flows_into_remaining_setup_sections(self, tmp_path): patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "save_config"), patch.object(setup_mod, "_print_setup_summary"), - patch.object(setup_mod, "_offer_launch_chat"), ): setup_mod.run_setup_wizard(args) diff --git a/tests/hermes_cli/test_setup_reconfigure.py b/tests/hermes_cli/test_setup_reconfigure.py index 9f7c97a8c1e7..6ed49e54ae4a 100644 --- a/tests/hermes_cli/test_setup_reconfigure.py +++ b/tests/hermes_cli/test_setup_reconfigure.py @@ -63,7 +63,6 @@ def _enter_existing_install_patches(stack, **extra): ("hermes_cli.setup.get_env_value", {"return_value": None}), ("hermes_cli.auth.get_active_provider", {"return_value": "openrouter"}), ("hermes_cli.setup._print_setup_summary", {}), - ("hermes_cli.setup._offer_launch_chat", {}), ("hermes_cli.setup._offer_openclaw_migration", {"return_value": False}), ]: stack.enter_context(patch(target, **kwargs)) diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6c23824b9e58..1ed7e35323b5 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -199,6 +199,37 @@ def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch): # Should inherit defaults for unspecified colors assert skin.get_color("banner_border") == "#CD7F32" # from default + def test_load_user_skin_invalid_section_types_fall_back_to_defaults(self, tmp_path, monkeypatch): + from hermes_cli.skin_engine import load_skin + + skins_dir = tmp_path / "skins" + skins_dir.mkdir() + import yaml + + (skins_dir / "broken.yaml").write_text( + yaml.dump( + { + "name": "broken", + "colors": ["not", "a", "mapping"], + "spinner": "invalid", + "branding": ["also", "invalid"], + "tool_emojis": ["invalid"], + "tool_prefix": "!", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) + + skin = load_skin("broken") + + assert skin.name == "broken" + assert skin.get_color("banner_title") == "#FFD700" + assert skin.get_branding("agent_name") == "Hermes Agent" + assert skin.spinner.get("waiting_faces", []) == [] + assert skin.tool_emojis == {} + assert skin.tool_prefix == "!" + def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch): from hermes_cli.skin_engine import list_skins skins_dir = tmp_path / "skins" diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index b284d5df199c..8a94ce4302f5 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -83,6 +83,12 @@ def test_get_platform_tools_default_telegram_includes_messaging(): assert "messaging" in enabled +def test_get_platform_tools_default_whatsapp_includes_web(): + enabled = _get_platform_tools({}, "whatsapp") + + assert "web" in enabled + + def test_get_platform_tools_homeassistant_platform_keeps_homeassistant_toolset(): enabled = _get_platform_tools({}, "homeassistant") diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 645b3b24ea4c..f7d90245a810 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -305,6 +305,7 @@ def _setup_update_mocks(monkeypatch, tmp_path): monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: []) monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5)) monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []}) + monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None) def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys): diff --git a/tests/hermes_cli/test_video_gen_picker.py b/tests/hermes_cli/test_video_gen_picker.py new file mode 100644 index 000000000000..85350947c969 --- /dev/null +++ b/tests/hermes_cli/test_video_gen_picker.py @@ -0,0 +1,148 @@ +"""Tests for plugin video_gen providers in the tools picker. + +Covers the reconfigure path that previously failed to write +``video_gen.provider`` when a user picked an xAI/etc. plugin backend +through Reconfigure tool → Video Generation. The first-time configure +path already handled it; the reconfigure path forgot to mirror it. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest + +from agent import video_gen_registry +from agent.video_gen_provider import VideoGenProvider + + +class _FakeVideoProvider(VideoGenProvider): + def __init__( + self, + name: str, + available: bool = True, + schema: Optional[Dict[str, Any]] = None, + models: Optional[List[Dict[str, Any]]] = None, + ): + self._name = name + self._available = available + self._schema = schema or { + "name": name.title(), + "badge": "test", + "tag": f"{name} test tag", + "env_vars": [{"key": f"{name.upper()}_API_KEY", "prompt": f"{name} key"}], + } + self._models = models or [ + { + "id": f"{name}-video-v1", + "display": f"{name} v1", + "speed": "~10s", + "strengths": "test", + "price": "$", + }, + ] + + @property + def name(self) -> str: + return self._name + + def is_available(self) -> bool: + return self._available + + def list_models(self): + return list(self._models) + + def default_model(self): + return self._models[0]["id"] if self._models else None + + def get_setup_schema(self): + return dict(self._schema) + + def generate(self, prompt, **kw): + return {"success": True, "video": f"{self._name}://{prompt}"} + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +class TestReconfigureWritesProvider: + """Regression tests for the video_gen reconfigure path. + + Before the fix, _reconfigure_provider() handled image_gen_plugin_name + in both the no-env-vars branch and the post-env-vars branch but + missed video_gen_plugin_name in both. Picking xAI via Reconfigure + tool → Video Generation silently no-op'd: the env var was already + set, the env-var loop ran (Enter to keep), and the function fell + through without ever writing config["video_gen"]["provider"]. + """ + + def test_reconfigure_with_env_vars_already_set_writes_provider( + self, monkeypatch, tmp_path + ): + """Env vars present and user accepts current value → still writes + video_gen.provider via the post-env-vars branch.""" + from hermes_cli import tools_config + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + video_gen_registry.register_provider(_FakeVideoProvider("xai_fake")) + + # Picker prompts replaced — no TTY in tests. + monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0) + # User presses Enter to keep the existing key. + monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "") + # Pretend the env var is already set so the reconfigure path + # hits the "Kept current" branch. + monkeypatch.setattr( + tools_config, + "get_env_value", + lambda key: "sk-fake" if key == "XAI_FAKE_API_KEY" else "", + ) + + config: dict = {} + provider_row = { + "name": "xAI", + "env_vars": [{"key": "XAI_FAKE_API_KEY", "prompt": "xAI key"}], + "video_gen_plugin_name": "xai_fake", + } + + tools_config._reconfigure_provider(provider_row, config) + + assert config["video_gen"]["provider"] == "xai_fake" + assert config["video_gen"]["model"] == "xai_fake-video-v1" + assert config["video_gen"]["use_gateway"] is False + + def test_reconfigure_with_no_env_vars_writes_provider( + self, monkeypatch, tmp_path + ): + """No env vars at all (managed-style plugin) → writes + video_gen.provider via the no-env-vars early-return branch.""" + from hermes_cli import tools_config + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + video_gen_registry.register_provider(_FakeVideoProvider( + "noenv_video", + schema={ + "name": "NoEnvVideo", + "badge": "free", + "tag": "", + "env_vars": [], + }, + )) + monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0) + + config: dict = {} + provider_row = { + "name": "NoEnvVideo", + "env_vars": [], + "video_gen_plugin_name": "noenv_video", + } + + tools_config._reconfigure_provider(provider_row, config) + + assert config["video_gen"]["provider"] == "noenv_video" + assert config["video_gen"]["model"] == "noenv_video-video-v1" + assert config["video_gen"]["use_gateway"] is False diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 95180b2dce38..b6530db9f842 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -6,6 +6,8 @@ from pathlib import Path from unittest.mock import patch, MagicMock +from hermes_cli.profiles import _get_default_hermes_home + import pytest from plugins.memory.honcho.client import ( @@ -349,18 +351,25 @@ def test_prefers_hermes_home_when_exists(self, tmp_path): result = resolve_config_path() assert result == local_cfg - def test_falls_back_to_global_when_no_local(self, tmp_path): - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - # No honcho.json in HERMES_HOME — also isolate ~/.hermes so - # the default-profile fallback doesn't hit the real filesystem. + def test_falls_back_to_default_profile_when_no_local(self, tmp_path, monkeypatch): + # Profile mode: HERMES_HOME points at ~/.hermes/profiles/, so + # _get_default_hermes_home() must resolve back to ~/.hermes — that's + # the bug the HOME-anchored helper fixes (vs. blindly using Path.home()). fake_home = tmp_path / "fakehome" fake_home.mkdir() + default_home = fake_home / ".hermes" + profile_home = default_home / "profiles" / "work" + profile_home.mkdir(parents=True) + default_cfg = default_home / "honcho.json" + default_cfg.write_text('{"apiKey": "default-key"}') - with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ - patch.object(Path, "home", return_value=fake_home): - result = resolve_config_path() - assert result == fake_home / ".honcho" / "config.json" + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + result = resolve_config_path() + + assert _get_default_hermes_home() == default_home + assert result == default_cfg def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): fake_home = tmp_path / "fakehome" @@ -383,6 +392,28 @@ def test_global_fallback_uses_home_at_call_time(self, tmp_path): assert resolve_global_config_path() == fake_home / ".honcho" / "config.json" assert resolve_config_path() == fake_home / ".honcho" / "config.json" + def test_from_global_config_uses_default_profile_fallback(self, tmp_path, monkeypatch): + # Profile mode: from_global_config() reads the default-profile honcho.json + # via the HOME-anchored helper, not Path.home() / ".hermes". + fake_home = tmp_path / "fakehome" + fake_home.mkdir() + default_home = fake_home / ".hermes" + profile_home = default_home / "profiles" / "work" + profile_home.mkdir(parents=True) + default_cfg = default_home / "honcho.json" + default_cfg.write_text(json.dumps({ + "apiKey": "default-key", + "workspace": "default-ws", + })) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + config = HonchoClientConfig.from_global_config() + + assert config.api_key == "default-key" + assert config.workspace_id == "default-ws" + def test_from_global_config_uses_local_path(self, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir() diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 127528205b29..3f609cd1d67a 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -1,4 +1,5 @@ import json +import zipfile from types import SimpleNamespace from unittest.mock import MagicMock @@ -156,6 +157,43 @@ def test_tool_add_resource_uploads_existing_local_directory_and_cleans_zip(tmp_p assert result["root_uri"] == "viking://resources/docs" +def test_tool_add_resource_directory_zip_skips_symlink_escape(tmp_path): + secret = tmp_path / "outside-secret.txt" + secret.write_text("do not upload\n", encoding="utf-8") + docs = tmp_path / "docs" + docs.mkdir() + (docs / "guide.md").write_text("# Guide\n", encoding="utf-8") + link = docs / "leak.txt" + try: + link.symlink_to(secret) + except OSError as exc: + pytest.skip(f"symlinks unavailable in test environment: {exc}") + + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + archive_entries = {} + + def inspect_upload(path): + with zipfile.ZipFile(path) as archive: + archive_entries["names"] = archive.namelist() + archive_entries["payloads"] = { + name: archive.read(name) + for name in archive.namelist() + } + return "upload_docs.zip" + + provider._client.upload_temp_file.side_effect = inspect_upload + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/docs"}, + } + + json.loads(provider._tool_add_resource({"url": str(docs)})) + + assert archive_entries["names"] == ["guide.md"] + assert b"do not upload" not in b"".join(archive_entries["payloads"].values()) + + def test_tool_add_resource_cleans_local_directory_zip_when_add_fails(tmp_path): docs = tmp_path / "docs" docs.mkdir() diff --git a/tests/plugins/video_gen/__init__.py b/tests/plugins/video_gen/__init__.py new file mode 100644 index 000000000000..07355db30aec --- /dev/null +++ b/tests/plugins/video_gen/__init__.py @@ -0,0 +1 @@ +"""Make tests/plugins/video_gen a package.""" diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py new file mode 100644 index 000000000000..fdfa9a6ec447 --- /dev/null +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -0,0 +1,314 @@ +"""Tests for the FAL video gen plugin — family routing, payload shape.""" + +from __future__ import annotations + +import pytest + +from agent import video_gen_registry + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +def test_fal_provider_registers(): + from plugins.video_gen.fal import FALVideoGenProvider, DEFAULT_MODEL + + provider = FALVideoGenProvider() + video_gen_registry.register_provider(provider) + + assert video_gen_registry.get_provider("fal") is provider + assert provider.display_name == "FAL" + # DEFAULT_MODEL is the cheap-tier default + assert provider.default_model() == DEFAULT_MODEL + assert DEFAULT_MODEL in {"pixverse-v6", "ltx-2.3"} + + +def test_fal_family_catalog(): + """Each family declares both endpoints. The catalog covers the + cheap + premium tiers Teknium listed.""" + from plugins.video_gen.fal import FAL_FAMILIES + + expected = { + # cheap + "ltx-2.3", "pixverse-v6", + # premium + "veo3.1", "seedance-2.0", "kling-v3-4k", "happy-horse", + } + assert expected.issubset(set(FAL_FAMILIES.keys())), ( + f"missing families: {expected - set(FAL_FAMILIES.keys())}" + ) + for fid, meta in FAL_FAMILIES.items(): + assert meta.get("text_endpoint"), f"{fid} missing text_endpoint" + assert meta.get("image_endpoint"), f"{fid} missing image_endpoint" + assert meta["text_endpoint"] != meta["image_endpoint"] + assert meta.get("tier") in {"cheap", "premium"}, ( + f"{fid} has invalid tier" + ) + + +def test_kling_4k_uses_start_image_url(): + """Kling v3 4K's image-to-video endpoint expects start_image_url, + not image_url. The family must declare image_param_key='start_image_url'.""" + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["kling-v3-4k"] + assert meta.get("image_param_key") == "start_image_url" + payload = _build_payload( + meta, + prompt="x", + image_url="https://example.com/i.png", + duration=5, + aspect_ratio="16:9", + resolution="720p", + negative_prompt=None, + audio=None, + seed=None, + ) + assert payload.get("start_image_url") == "https://example.com/i.png" + assert "image_url" not in payload + + +def test_fal_list_models_advertises_both_modalities(): + from plugins.video_gen.fal import FALVideoGenProvider + + models = FALVideoGenProvider().list_models() + for m in models: + assert set(m["modalities"]) == {"text", "image"}, ( + f"{m['id']} doesn't advertise both modalities — every family " + f"should have t2v + i2v" + ) + + +def test_fal_unavailable_without_key(monkeypatch): + from plugins.video_gen.fal import FALVideoGenProvider + + monkeypatch.delenv("FAL_KEY", raising=False) + assert FALVideoGenProvider().is_available() is False + + +def test_fal_generate_requires_fal_key(monkeypatch): + from plugins.video_gen.fal import FALVideoGenProvider + + monkeypatch.delenv("FAL_KEY", raising=False) + result = FALVideoGenProvider().generate("a happy dog") + assert result["success"] is False + assert result["error_type"] == "auth_required" + + +class TestFamilyRouting: + """The headline behavior: image_url presence picks the endpoint.""" + + @pytest.fixture + def with_fake_fal(self, monkeypatch): + """Stub fal_client.subscribe to capture which endpoint we hit.""" + import sys + import types + + captured = {"endpoint": None, "arguments": None} + + fake = types.ModuleType("fal_client") + def _subscribe(endpoint, arguments=None, with_logs=False): + captured["endpoint"] = endpoint + captured["arguments"] = arguments + return {"video": {"url": "https://fake/out.mp4"}} + fake.subscribe = _subscribe # type: ignore + monkeypatch.setitem(sys.modules, "fal_client", fake) + + # Reset the lazy global so it picks up our stub + from plugins.video_gen import fal as fal_plugin + fal_plugin._fal_client = None + + monkeypatch.setenv("FAL_KEY", "test") + return captured + + def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal): + from plugins.video_gen.fal import FALVideoGenProvider + + result = FALVideoGenProvider().generate( + "a dog running", + model="pixverse-v6", + ) + assert result["success"] is True + assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/text-to-video" + assert result["modality"] == "text" + assert with_fake_fal["arguments"]["prompt"] == "a dog running" + assert "image_url" not in with_fake_fal["arguments"] + + def test_image_to_video_routes_to_image_endpoint(self, with_fake_fal): + from plugins.video_gen.fal import FALVideoGenProvider + + result = FALVideoGenProvider().generate( + "animate this dog", + model="pixverse-v6", + image_url="https://example.com/dog.png", + ) + assert result["success"] is True + assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/image-to-video" + assert result["modality"] == "image" + assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png" + + def test_default_family_text_routing(self, with_fake_fal): + """No model arg → DEFAULT_MODEL → text-to-video endpoint.""" + from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL + + result = FALVideoGenProvider().generate("a dog") + assert result["success"] is True + expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"] + assert with_fake_fal["endpoint"] == expected_endpoint + + def test_default_family_image_routing(self, with_fake_fal): + from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL + + result = FALVideoGenProvider().generate( + "animate this", + image_url="https://example.com/i.png", + ) + assert result["success"] is True + expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["image_endpoint"] + assert with_fake_fal["endpoint"] == expected_endpoint + + def test_unknown_family_falls_back_to_default(self, with_fake_fal): + from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL + + result = FALVideoGenProvider().generate( + "x", + model="not-a-real-family", + ) + assert result["success"] is True + expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"] + assert with_fake_fal["endpoint"] == expected_endpoint + + def test_premium_seedance_routing(self, with_fake_fal): + """Sanity check the premium-tier seedance routes correctly.""" + from plugins.video_gen.fal import FALVideoGenProvider + + result = FALVideoGenProvider().generate( + "a dog", + model="seedance-2.0", + image_url="https://example.com/dog.png", + ) + assert result["success"] is True + assert with_fake_fal["endpoint"] == "bytedance/seedance-2.0/image-to-video" + # Seedance uses regular image_url (not start_image_url) + assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png" + + def test_kling_4k_remaps_image_param(self, with_fake_fal): + """Kling v3 4K image-to-video receives start_image_url, not image_url.""" + from plugins.video_gen.fal import FALVideoGenProvider + + result = FALVideoGenProvider().generate( + "x", + model="kling-v3-4k", + image_url="https://example.com/frame.png", + ) + assert result["success"] is True + assert with_fake_fal["endpoint"] == "fal-ai/kling-video/v3/4k/image-to-video" + assert with_fake_fal["arguments"].get("start_image_url") == "https://example.com/frame.png" + assert "image_url" not in with_fake_fal["arguments"] + + +class TestPayloadBuilder: + def test_drops_unsupported_keys(self): + """Veo enum-clamps duration, supports aspect+resolution+audio+neg.""" + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["veo3.1"] + p = _build_payload( + meta, + prompt="x", + image_url=None, + duration=12, # not in enum (4,6,8) — snap to 8 + aspect_ratio="16:9", + resolution="720p", + negative_prompt="ugly", + audio=True, + seed=42, + ) + assert p["prompt"] == "x" + assert p["duration"] == "8" # FAL queue API uses strings + assert p["aspect_ratio"] == "16:9" + assert p["resolution"] == "720p" + assert p["generate_audio"] is True + assert p["negative_prompt"] == "ugly" + assert p["seed"] == 42 + + def test_pixverse_range_clamps_correctly(self): + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["pixverse-v6"] + p = _build_payload( + meta, + prompt="x", + image_url="https://i.png", + duration=99, # over max → 15 + aspect_ratio="16:9", + resolution="540p", + negative_prompt=None, + audio=None, + seed=None, + ) + assert p["duration"] == "15" + + def test_kling_4k_clamps_below_min(self): + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["kling-v3-4k"] + p = _build_payload( + meta, + prompt="x", + image_url="https://i.png", + duration=1, # below min (3) → 3 + aspect_ratio="16:9", + resolution="720p", + negative_prompt=None, + audio=None, + seed=None, + ) + assert p["duration"] == "3" + + def test_ltx_omits_duration_aspect_resolution(self): + """LTX 2.3 doesn't declare duration/aspect/resolution enums — + the payload should NOT include those keys (let FAL default).""" + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["ltx-2.3"] + p = _build_payload( + meta, + prompt="x", + image_url=None, + duration=8, + aspect_ratio="16:9", + resolution="720p", + negative_prompt="ugly", + audio=True, + seed=None, + ) + assert "duration" not in p + assert "aspect_ratio" not in p + assert "resolution" not in p + # But audio + negative are advertised + assert p["generate_audio"] is True + assert p["negative_prompt"] == "ugly" + + def test_happy_horse_minimal_payload(self): + """Happy Horse has sparse docs — payload should be minimal.""" + from plugins.video_gen.fal import FAL_FAMILIES, _build_payload + + meta = FAL_FAMILIES["happy-horse"] + p = _build_payload( + meta, + prompt="a horse galloping", + image_url=None, + duration=8, + aspect_ratio="16:9", + resolution="720p", + negative_prompt="watermark", + audio=True, + seed=None, + ) + # Only prompt — no payload bloat for fields we can't verify + assert p == {"prompt": "a horse galloping"} diff --git a/tests/plugins/video_gen/test_xai_plugin.py b/tests/plugins/video_gen/test_xai_plugin.py new file mode 100644 index 000000000000..25695d852e57 --- /dev/null +++ b/tests/plugins/video_gen/test_xai_plugin.py @@ -0,0 +1,69 @@ +"""Smoke tests for the xAI video gen plugin — load & register surface.""" + +from __future__ import annotations + +import pytest + +from agent import video_gen_registry + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +def test_xai_provider_registers(): + from plugins.video_gen.xai import XAIVideoGenProvider + + provider = XAIVideoGenProvider() + video_gen_registry.register_provider(provider) + + assert video_gen_registry.get_provider("xai") is provider + assert provider.display_name == "xAI" + assert provider.default_model() == "grok-imagine-video" + + +def test_xai_capabilities_text_and_image_only(): + """xAI was previously advertised with edit/extend operations. The + simplified surface only exposes text-to-video and image-to-video — + confirm those are the only modalities advertised.""" + from plugins.video_gen.xai import XAIVideoGenProvider + + caps = XAIVideoGenProvider().capabilities() + assert caps["modalities"] == ["text", "image"] + # No 'operations' key in the simplified surface + assert "operations" not in caps + assert caps["max_reference_images"] == 7 + + +def test_xai_unavailable_without_key(monkeypatch): + from plugins.video_gen.xai import XAIVideoGenProvider + + monkeypatch.delenv("XAI_API_KEY", raising=False) + assert XAIVideoGenProvider().is_available() is False + + +def test_xai_generate_requires_xai_key(monkeypatch): + from plugins.video_gen.xai import XAIVideoGenProvider + + monkeypatch.delenv("XAI_API_KEY", raising=False) + result = XAIVideoGenProvider().generate("a happy dog") + assert result["success"] is False + assert result["error_type"] == "auth_required" + + +def test_xai_no_operation_kwarg(): + """The ABC's generate() signature no longer accepts 'operation'. + Passing it through **kwargs should be ignored (forward-compat).""" + from plugins.video_gen.xai import XAIVideoGenProvider + + # We're not actually hitting the network — just verify the call + # doesn't TypeError on the unexpected kwarg. + # Will fail with auth_required (no XAI_API_KEY), but should NOT + # fail with TypeError. + result = XAIVideoGenProvider().generate("x", operation="generate") + assert result["success"] is False + # auth_required, NOT some signature error + assert result["error_type"] in ("auth_required", "api_error") diff --git a/tests/plugins/video_gen/test_xai_plugin_integration.py b/tests/plugins/video_gen/test_xai_plugin_integration.py new file mode 100644 index 000000000000..31d44f15be43 --- /dev/null +++ b/tests/plugins/video_gen/test_xai_plugin_integration.py @@ -0,0 +1,191 @@ +"""Integration tests for the xAI video gen plugin's simplified surface. + +xAI exposes only text-to-video and image-to-video through the unified +``video_generate`` tool. We assert the endpoint hit and the payload shape +because routing is the part most likely to break silently. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Dict, List, Optional + +import pytest + +from agent import video_gen_registry + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +class _FakeResponse: + def __init__(self, status: int = 200, payload: Optional[Dict[str, Any]] = None): + self.status_code = status + self._payload = payload or {} + self.text = json.dumps(self._payload) + + def raise_for_status(self): + if self.status_code >= 400: + import httpx + raise httpx.HTTPStatusError("err", request=None, response=self) # type: ignore + + def json(self): + return self._payload + + +class _FakeAsyncClient: + def __init__(self): + self.posts: List[Dict[str, Any]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def post(self, url, headers=None, json=None, timeout=None): + self.posts.append({"url": url, "json": json}) + return _FakeResponse(200, {"request_id": "req-123"}) + + async def get(self, url, headers=None, timeout=None): + return _FakeResponse(200, { + "status": "done", + "video": {"url": "https://xai-cdn/out.mp4", "duration": 8}, + "model": "grok-imagine-video", + }) + + +@pytest.fixture +def xai_provider(monkeypatch): + monkeypatch.setenv("XAI_API_KEY", "test-key") + + import plugins.video_gen.xai as xai_plugin + + captured: Dict[str, _FakeAsyncClient] = {} + + def _client_factory(): + captured["client"] = _FakeAsyncClient() + return captured["client"] + + monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", _client_factory) + + async def _no_sleep(*a, **k): + return None + + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + + provider = xai_plugin.XAIVideoGenProvider() + return provider, captured + + +def _last_post(captured) -> Dict[str, Any]: + return captured["client"].posts[-1] + + +class TestXAIEndpoint: + """xAI uses one endpoint — ``/videos/generations`` — for both modes.""" + + def test_text_to_video_hits_generations(self, xai_provider): + provider, captured = xai_provider + result = provider.generate("a dog on a skateboard") + assert result["success"] is True + assert _last_post(captured)["url"].endswith("/videos/generations") + assert result["modality"] == "text" + + def test_image_to_video_hits_generations(self, xai_provider): + provider, captured = xai_provider + result = provider.generate( + "animate this", + image_url="https://example.com/cat.png", + ) + assert result["success"] is True + assert _last_post(captured)["url"].endswith("/videos/generations") + assert result["modality"] == "image" + + +class TestXAIPayload: + def test_text_payload_has_no_image_field(self, xai_provider): + provider, captured = xai_provider + provider.generate("a dog at sunset") + payload = _last_post(captured)["json"] + assert payload["prompt"] == "a dog at sunset" + assert "image" not in payload + assert "reference_images" not in payload + + def test_image_payload_has_image_field(self, xai_provider): + provider, captured = xai_provider + provider.generate("animate this", image_url="https://example.com/cat.png") + payload = _last_post(captured)["json"] + assert payload["image"] == {"url": "https://example.com/cat.png"} + + def test_reference_images_payload(self, xai_provider): + provider, captured = xai_provider + provider.generate( + "keep this character", + reference_image_urls=[ + "https://example.com/a.png", + "https://example.com/b.png", + ], + ) + payload = _last_post(captured)["json"] + assert payload["reference_images"] == [ + {"url": "https://example.com/a.png"}, + {"url": "https://example.com/b.png"}, + ] + + +class TestXAIValidation: + def test_missing_prompt_rejects(self, xai_provider): + provider, captured = xai_provider + result = provider.generate("") + assert result["success"] is False + assert result["error_type"] == "missing_prompt" + # Never hit the network + assert "client" not in captured or not captured["client"].posts + + def test_image_plus_refs_rejects(self, xai_provider): + provider, captured = xai_provider + result = provider.generate( + "x", + image_url="https://example.com/i.png", + reference_image_urls=["https://example.com/r.png"], + ) + assert result["success"] is False + assert result["error_type"] == "conflicting_inputs" + assert "client" not in captured or not captured["client"].posts + + def test_too_many_references_rejects(self, xai_provider): + provider, captured = xai_provider + result = provider.generate( + "x", + reference_image_urls=[f"https://example.com/r{i}.png" for i in range(8)], + ) + assert result["success"] is False + assert result["error_type"] == "too_many_references" + + +class TestXAIClamping: + def test_duration_clamped_to_15(self, xai_provider): + provider, captured = xai_provider + provider.generate("x", duration=30) + assert _last_post(captured)["json"]["duration"] == 15 + + def test_duration_clamped_when_refs_present(self, xai_provider): + provider, captured = xai_provider + provider.generate( + "x", + duration=15, + reference_image_urls=["https://example.com/r.png"], + ) + # refs present caps to 10 + assert _last_post(captured)["json"]["duration"] == 10 + + def test_invalid_aspect_ratio_soft_clamps(self, xai_provider): + provider, captured = xai_provider + provider.generate("x", aspect_ratio="21:9") + assert _last_post(captured)["json"]["aspect_ratio"] == "16:9" diff --git a/tests/plugins/web/__init__.py b/tests/plugins/web/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py new file mode 100644 index 000000000000..6ea154dee1ea --- /dev/null +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -0,0 +1,475 @@ +"""Plugin-side tests for the web search provider migration (PR #25182). + +Covers: + +- All seven bundled plugins (brave-free, ddgs, searxng, exa, parallel, + tavily, firecrawl) instantiate and self-report the expected + capabilities + ABC-derived defaults. +- Each plugin's ``is_available()`` correctly reflects env-var presence. +- The web_search_registry resolves an active provider in the documented + scenarios (explicit config wins ignoring availability, fallback walks + legacy preference filtered by availability, unknown name falls back). +- Plugin response shapes match the legacy bit-for-bit contract. + +Per the dev skill: these tests use *real* imports from the plugin +modules — no mocking of provider classes themselves — so the test +catches drift in the ABC interface, the registry, and the plugin +glue layer simultaneously. +""" +from __future__ import annotations + +import asyncio +import inspect +import os +import sys +from typing import Any, Dict, List + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip every web-provider env var so is_available() returns False.""" + for k in ( + "BRAVE_SEARCH_API_KEY", + "SEARXNG_URL", + "TAVILY_API_KEY", + "TAVILY_BASE_URL", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "PARALLEL_SEARCH_MODE", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_USER_TOKEN", + ): + monkeypatch.delenv(k, raising=False) + + +def _ensure_plugins_loaded() -> None: + """Idempotently load plugins so the registry is populated.""" + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + + +# --------------------------------------------------------------------------- +# Per-plugin discovery + capability flags +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Each test starts with a clean web-provider env.""" + _clear_web_env(monkeypatch) + + +class TestBundledPluginsRegister: + """All seven bundled web plugins discover and register correctly.""" + + def test_all_seven_plugins_present_in_registry(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import list_providers + + names = sorted(p.name for p in list_providers()) + assert names == [ + "brave-free", + "ddgs", + "exa", + "firecrawl", + "parallel", + "searxng", + "tavily", + ] + + @pytest.mark.parametrize( + "plugin_name,expected_search,expected_extract,expected_crawl", + [ + ("brave-free", True, False, False), + ("ddgs", True, False, False), + ("searxng", True, False, False), + ("exa", True, True, False), + ("parallel", True, True, False), + ("tavily", True, True, True), + # firecrawl: search + extract + crawl. Crawl was originally + # disabled in the migration (fell through to a legacy inline + # path); the follow-up commit enabled it natively. + ("firecrawl", True, True, True), + ], + ) + def test_capability_flags_match_spec( + self, + plugin_name: str, + expected_search: bool, + expected_extract: bool, + expected_crawl: bool, + ) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None, f"plugin {plugin_name!r} not registered" + assert provider.supports_search() is expected_search + assert provider.supports_extract() is expected_extract + assert provider.supports_crawl() is expected_crawl + + @pytest.mark.parametrize( + "plugin_name", + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl"], + ) + def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + assert provider.name == plugin_name + assert provider.display_name # any non-empty string + + @pytest.mark.parametrize( + "plugin_name", + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl"], + ) + def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: + """``get_setup_schema()`` returns a dict the picker can consume.""" + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + schema = provider.get_setup_schema() + assert isinstance(schema, dict) + assert "name" in schema + assert "env_vars" in schema + + +# --------------------------------------------------------------------------- +# is_available() behavior +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + """Each plugin's ``is_available()`` returns False without env config.""" + + def test_brave_free_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("brave-free") + assert p is not None + assert p.is_available() is False # no BRAVE_SEARCH_API_KEY + monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real") + assert p.is_available() is True + + def test_searxng_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("searxng") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + assert p.is_available() is True + + def test_tavily_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("TAVILY_API_KEY", "real") + assert p.is_available() is True + + def test_exa_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("EXA_API_KEY", "real") + assert p.is_available() is True + + def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("PARALLEL_API_KEY", "real") + assert p.is_available() is True + + def test_firecrawl_requires_either_key_or_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert p.is_available() is False + + # Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL lights it up. + monkeypatch.setenv("FIRECRAWL_API_KEY", "real") + assert p.is_available() is True + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002") + assert p.is_available() is True + + def test_ddgs_always_available_when_package_importable(self) -> None: + """DDGS is the always-on fallback — no API key required. + + It may report unavailable if the ``ddgs`` package itself isn't + installed in the env (legitimate — the plugin's post_setup hook + triggers pip install on first selection). We only assert that + is_available() doesn't raise. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("ddgs") + assert p is not None + # Truthy or falsy, just must not raise. + _ = bool(p.is_available()) + + +# --------------------------------------------------------------------------- +# Registry resolution semantics (Option B — conservative smart fallback) +# --------------------------------------------------------------------------- + + +class TestRegistryResolution: + """``_resolve()`` follows explicit-config + availability-filtered fallback.""" + + def test_explicit_configured_provider_returned_even_when_unavailable( + self, + ) -> None: + """Explicit ``web.search_backend`` wins regardless of is_available(). + + Without availability filtering on the explicit path, the dispatcher + would silently switch backends; with this check the dispatcher + surfaces a precise "FOO_API_KEY is not set" error instead. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve, get_provider + + # No BRAVE_SEARCH_API_KEY (fixture cleared it). + result = _resolve("brave-free", capability="search") + assert result is not None + assert result.name == "brave-free" + # Confirm it's the unavailable one — dispatcher will surface + # a typed credential-missing error to the caller. + assert result.is_available() is False + + def test_unknown_configured_name_falls_back_to_available_provider( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Typo / uninstalled plugin → walk legacy preference, pick available.""" + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + monkeypatch.setenv("EXA_API_KEY", "real") + result = _resolve("not-a-real-provider", capability="search") + # Either ddgs (no-key fallback) or exa (the only available + # premium provider) — both are valid. The point is the unknown + # name shouldn't return None when SOMETHING is available. + assert result is not None + assert result.is_available() is True + + def test_explicit_search_only_provider_for_extract_falls_back( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Asking for extract via a search-only backend → fall back. + + ``brave-free`` is search-only (``supports_extract() is False``). + When the registry resolves it for an extract capability, the + explicit-config branch rejects it as capability-incompatible + and the fallback walk picks an extract-capable provider. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + monkeypatch.setenv("EXA_API_KEY", "real") + result = _resolve("brave-free", capability="extract") + # Should land on exa (only extract-capable available provider). + assert result is not None + assert result.supports_extract() is True + assert result.is_available() is True + + def test_no_config_no_credentials_returns_none( + self, + ) -> None: + """No backend configured AND no available providers → typically None. + + ``ddgs`` is the no-credential fallback; if its ``ddgs`` Python + package is installed in the test env, ddgs will be picked. + Otherwise the resolver returns None. Either outcome is correct. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + result = _resolve(None, capability="search") + if result is not None: + # The only no-credential provider is ddgs; anything else + # means an env var leaked in. + assert result.is_available() is True + + +# --------------------------------------------------------------------------- +# Sync-vs-async extract detection +# --------------------------------------------------------------------------- + + +class TestAsyncExtractDispatch: + """The dispatcher detects async vs sync extract methods correctly.""" + + def test_parallel_extract_is_async(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is True + + def test_firecrawl_extract_is_async(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is True + + def test_exa_extract_is_sync(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is False + + def test_tavily_extract_is_sync(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is False + + +# --------------------------------------------------------------------------- +# Error response shape (preserved bit-for-bit from legacy) +# --------------------------------------------------------------------------- + + +class TestErrorResponseShapes: + """When credentials are missing, plugins return typed errors, not raises.""" + + def test_brave_free_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("brave-free") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_searxng_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("searxng") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_exa_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_tavily_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + result = asyncio.run(p.extract(["https://example.com"])) + assert isinstance(result, list) + assert len(result) == 1 + assert "error" in result[0] + assert result[0]["url"] == "https://example.com" + + def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + # firecrawl extract returns [] when the website-policy gate rejects + # the URL, or a per-URL error dict when the gate passes but the + # firecrawl client fails. Use a URL the policy allows to make sure + # we hit the credential-missing path. + result = asyncio.run(p.extract(["https://example.com"])) + assert isinstance(result, list) + if result: # if anything came back, it should be an error entry + assert "error" in result[0] + + def test_tavily_crawl_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + result = p.crawl("https://example.com") + assert isinstance(result, dict) + assert "results" in result + assert isinstance(result["results"], list) + if result["results"]: + assert "error" in result["results"][0] + + def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self) -> None: + """firecrawl crawl is async (wraps SDK in to_thread); error must be + surfaced via the per-page result shape, not raised.""" + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert inspect.iscoroutinefunction(p.crawl) + result = asyncio.run(p.crawl("https://example.com")) + assert isinstance(result, dict) + assert "results" in result + assert isinstance(result["results"], list) + # Without FIRECRAWL_API_KEY, the plugin's _get_firecrawl_client() + # raises ValueError which is caught and returned as a per-page error. + assert len(result["results"]) >= 1 + assert "error" in result["results"][0] + assert result["results"][0]["url"] == "https://example.com" diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index 9ad6713e3ec8..2fdbdfab11ae 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -39,21 +39,21 @@ def test_bundled_plugins_discovered(): assert plugins_dir.is_dir(), f"Missing {plugins_dir}" child_dirs = [c for c in plugins_dir.iterdir() if c.is_dir()] - assert len(child_dirs) >= 28, f"Expected at least 28 provider plugins, found {len(child_dirs)}" + assert len(child_dirs) >= 29, f"Expected at least 29 provider plugins, found {len(child_dirs)}" for child in child_dirs: assert (child / "__init__.py").exists(), f"{child.name} missing __init__.py" assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml" -def test_all_33_profiles_register(): - """After discovery, the registry must contain exactly 33 distinct profiles.""" +def test_all_35_profiles_register(): + """After discovery, the registry must contain exactly 35 distinct profiles.""" _clear_provider_caches() from providers import list_providers profiles = list_providers() names = sorted(p.name for p in profiles) - assert len(names) == 33, f"Expected 33 profiles, got {len(names)}: {names}" + assert len(names) == 35, f"Expected 35 profiles, got {len(names)}: {names}" # Spot-check representative providers from different categories for required in ( diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 5410f196e654..3cbd47c0e1b2 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -415,6 +415,32 @@ def test_413_cannot_compress_further(self, agent): class TestPreflightCompression: """Preflight compression should compress history before the first API call.""" + def test_compress_context_emits_lifecycle_status_before_work(self, agent): + """Direct context compression should tell gateway users why the turn paused.""" + events = [] + agent.status_callback = lambda ev, msg: events.append((ev, msg)) + + def _fake_compress(messages, current_tokens=None, focus_topic=None): + events.append(("compress", "started")) + return [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}] + + with ( + patch.object(agent.context_compressor, "compress", side_effect=_fake_compress), + patch.object(agent, "_build_system_prompt", return_value="new system prompt"), + patch("run_agent.estimate_request_tokens_rough", return_value=42), + ): + compressed, new_system_prompt = agent._compress_context( + [{"role": "user", "content": "hello"}], + "system prompt", + approx_tokens=1234, + ) + + assert compressed == [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}] + assert new_system_prompt == "new system prompt" + assert events[0][0] == "lifecycle" + assert "Compacting context" in events[0][1] + assert events[1] == ("compress", "started") + def test_preflight_compresses_oversized_history(self, agent): """When loaded history exceeds the model's context threshold, compress before API call.""" agent.compression_enabled = True diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py index 8f2a61b75045..2e79b10b346a 100644 --- a/tests/run_agent/test_background_review.py +++ b/tests/run_agent/test_background_review.py @@ -20,6 +20,9 @@ def _bare_agent() -> AIAgent: agent._memory_store = object() agent._memory_enabled = True agent._user_profile_enabled = False + agent._cached_system_prompt = "test-cached-system-prompt" + import datetime as _dt + agent.session_start = _dt.datetime(2026, 1, 1, 12, 0, 0) agent._MEMORY_REVIEW_PROMPT = "review memory" agent._SKILL_REVIEW_PROMPT = "review skills" agent._COMBINED_REVIEW_PROMPT = "review both" diff --git a/tests/run_agent/test_background_review_cache_parity.py b/tests/run_agent/test_background_review_cache_parity.py new file mode 100644 index 000000000000..ac91cf75f7a6 --- /dev/null +++ b/tests/run_agent/test_background_review_cache_parity.py @@ -0,0 +1,185 @@ +"""Tests that the background review fork inherits the parent's cached system prompt. + +Regression coverage for issue #25322 (and PR #17276's first root cause): the +background review's outbound HTTP request must carry the same system bytes as +the parent's so Anthropic/OpenRouter's exact-prefix cache key matches. + +Without this, every review rebuilds the system prompt from scratch — fresh +``_hermes_now()`` timestamp, fresh ``session_id``, and a different skills +prompt under the (former) narrow toolset — and the prefix-cache miss costs +roughly the full uncached system-prompt cost per nudge (~26% end-to-end on +Sonnet 4.5 per the contributor's measurement). +""" + +from unittest.mock import patch + + +def _make_agent_stub(agent_cls): + """Create a minimal AIAgent-like object with just enough state for _spawn_background_review.""" + agent = object.__new__(agent_cls) + agent.model = "test-model" + agent.platform = "test" + agent.provider = "openai" + agent.session_id = "sess-123" + agent.quiet_mode = True + agent._memory_store = None + agent._memory_enabled = True + agent._user_profile_enabled = False + agent._memory_nudge_interval = 5 + agent._skill_nudge_interval = 5 + agent.background_review_callback = None + agent.status_callback = None + agent._cached_system_prompt = ( + "PARENT-SYSTEM-PROMPT-BYTES — must be inherited verbatim " + "for prefix-cache parity" + ) + import datetime as _dt + agent.session_start = _dt.datetime(2026, 1, 1, 12, 0, 0) + agent._MEMORY_REVIEW_PROMPT = "review memory" + agent._SKILL_REVIEW_PROMPT = "review skills" + agent._COMBINED_REVIEW_PROMPT = "review both" + return agent + + +class _SyncThread: + """Drop-in replacement for threading.Thread that runs the target inline.""" + + def __init__(self, *, target=None, daemon=None, name=None): + self._target = target + + def start(self): + if self._target: + self._target() + + +class _ReviewAgentRecorder: + """Stand-in for the review-fork AIAgent that records the prompt assignment.""" + + def __init__(self, *args, **kwargs): + self._cached_system_prompt = None + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = None + self._user_profile_enabled = None + self._memory_nudge_interval = None + self._skill_nudge_interval = None + self.suppress_status_output = None + + def run_conversation(self, *args, **kwargs): + raise RuntimeError("stop after recording state — don't actually call the API") + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + +def test_review_fork_inherits_parent_cached_system_prompt(): + """The review fork's _cached_system_prompt must equal the parent's byte-for-byte. + + Anthropic's prefix cache keys on exact bytes; any divergence (timestamp + minute tick, fresh session_id, narrower skills_prompt) shifts the key + and forces a full re-cache. Inheriting the parent's cached prompt is + the cheap, mechanical fix. + """ + import run_agent + + agent = _make_agent_stub(run_agent.AIAgent) + + captured = {} + parent_prompt = agent._cached_system_prompt + + # Hook the assignment site: record what gets put on the review agent. + real_recorder_init = _ReviewAgentRecorder.__init__ + + def _recorder_init(self, *args, **kwargs): + real_recorder_init(self, *args, **kwargs) + # The actual production code assigns _cached_system_prompt AFTER __init__, + # so we need to capture it on attribute set. Use a property-style sentinel + # via __setattr__ on this instance. + + with patch.object(run_agent, "AIAgent", _ReviewAgentRecorder), \ + patch("threading.Thread", _SyncThread): + # Wrap the recorder's __setattr__ so we can see the _cached_system_prompt + # write that _spawn_background_review performs after construction. + orig_setattr = _ReviewAgentRecorder.__setattr__ + + def _spy_setattr(self, name, value): + if name == "_cached_system_prompt": + captured["written_prompt"] = value + orig_setattr(self, name, value) + + with patch.object(_ReviewAgentRecorder, "__setattr__", _spy_setattr): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + assert "written_prompt" in captured, ( + "_spawn_background_review never assigned _cached_system_prompt on the review agent" + ) + assert captured["written_prompt"] == parent_prompt, ( + f"Review fork's _cached_system_prompt diverged from parent's. " + f"Got {captured['written_prompt']!r}, expected {parent_prompt!r}. " + "This breaks Anthropic/OpenRouter prefix-cache parity (#25322)." + ) + + +def test_review_fork_pins_session_start_and_session_id(): + """Defensive complement to cached-system-prompt inheritance. + + Even though ``_cached_system_prompt`` inheritance short-circuits the + normal rebuild path, pinning ``session_start`` and ``session_id`` to + the parent's guarantees byte-identical output from any code path that + re-renders parts of the system prompt (compression, plugin hooks). + """ + import run_agent + + agent = _make_agent_stub(run_agent.AIAgent) + + captured = {} + + class _Recorder: + def __init__(self, *args, **kwargs): + self._cached_system_prompt = None + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = None + self._user_profile_enabled = None + self._memory_nudge_interval = None + self._skill_nudge_interval = None + self.suppress_status_output = None + self.session_start = None + self.session_id = None + + def run_conversation(self, *args, **kwargs): + captured["session_start"] = self.session_start + captured["session_id"] = self.session_id + raise RuntimeError("stop after recording") + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + with patch.object(run_agent, "AIAgent", _Recorder), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + assert captured.get("session_start") == agent.session_start, ( + "Review fork did not inherit parent's session_start — " + "system-prompt rebuild paths would diverge." + ) + assert captured.get("session_id") == agent.session_id, ( + "Review fork did not inherit parent's session_id — " + "system-prompt rebuild paths would diverge." + ) diff --git a/tests/run_agent/test_background_review_toolset_restriction.py b/tests/run_agent/test_background_review_toolset_restriction.py index d1193dc6f910..7eea665b86f1 100644 --- a/tests/run_agent/test_background_review_toolset_restriction.py +++ b/tests/run_agent/test_background_review_toolset_restriction.py @@ -1,8 +1,16 @@ -"""Tests that the background review agent is restricted to memory+skills toolsets. - -Regression coverage for issue #15204: the background skill-review agent -inherited the full default toolset, allowing it to perform non-skill side -effects (terminal, send_message, delegate_task, etc.). +"""Tests that the background review agent restricts tools at runtime, not at schema time. + +Regression coverage for issue #15204 (the background skill-review agent must +not perform non-skill side effects like terminal, send_message, delegate_task) +combined with issue #25322 / PR #17276 (the review fork must hit the parent's +Anthropic/OpenRouter prefix cache). + +Reconciling the two: the fork now inherits the parent's full ``tools`` schema +so the cache-key matches, and enforces the memory+skills restriction at +runtime via a thread-local whitelist on the existing +``get_pre_tool_call_block_message`` gate. Safety is preserved mechanically +(any non-whitelisted dispatch is blocked) without the schema-level narrowing +that caused the prefix-cache miss. """ import threading @@ -24,6 +32,9 @@ def _make_agent_stub(agent_cls): agent._skill_nudge_interval = 5 agent.background_review_callback = None agent.status_callback = None + agent._cached_system_prompt = None + import datetime as _dt + agent.session_start = _dt.datetime(2026, 1, 1, 12, 0, 0) agent._MEMORY_REVIEW_PROMPT = "review memory" agent._SKILL_REVIEW_PROMPT = "review skills" agent._COMBINED_REVIEW_PROMPT = "review both" @@ -41,15 +52,20 @@ def start(self): self._target() -def test_background_review_agent_uses_restricted_toolsets(): - """The review agent must only have access to 'memory' and 'skills' toolsets.""" +def test_background_review_does_not_narrow_toolset_schema(): + """The review fork must NOT pass enabled_toolsets to AIAgent. + + Narrowing the schema diverges the ``tools`` cache key from the parent's, + which sits above ``system`` in Anthropic's cache hierarchy and forces a + full prefix-cache miss on every review (see #25322, PR #17276). + """ import run_agent agent = _make_agent_stub(run_agent.AIAgent) captured = {} def _capture_init(self, *args, **kwargs): - captured["enabled_toolsets"] = kwargs.get("enabled_toolsets") + captured["enabled_toolsets"] = kwargs.get("enabled_toolsets", "UNSET") raise RuntimeError("stop after capturing init args") with patch.object(run_agent.AIAgent, "__init__", _capture_init), \ @@ -61,11 +77,71 @@ def _capture_init(self, *args, **kwargs): ) assert "enabled_toolsets" in captured, "AIAgent.__init__ was not called" - assert sorted(captured["enabled_toolsets"]) == ["memory", "skills"] + # The kwarg must be absent — letting AIAgent inherit the default full + # toolset so the schema bytes match the parent's. + assert captured["enabled_toolsets"] == "UNSET", ( + f"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), " + "which breaks prefix-cache parity with the parent." + ) + + +def test_background_review_installs_thread_local_whitelist(): + """The review fork must install a memory/skills-only thread-local whitelist. + + The schema-level toolset narrowing was lifted (for prefix-cache parity), + so #15204's safety contract now relies on the runtime whitelist gate to + deny terminal/send_message/delegate_task at dispatch time. Verify the + whitelist is set with exactly the memory+skills tool names. + """ + import run_agent + from hermes_cli import plugins as _plugins + + captured = {} + + def _capture_whitelist(whitelist, deny_msg_fmt=None): + captured["whitelist"] = set(whitelist) + captured["deny_msg_fmt"] = deny_msg_fmt + # Stop here — we just want to see what gets installed. + raise RuntimeError("stop after capturing whitelist") + + agent = _make_agent_stub(run_agent.AIAgent) + + def _no_init(self, *args, **kwargs): + # Don't crash AIAgent.__init__; let execution flow reach + # set_thread_tool_whitelist. + return None + + with patch.object(run_agent.AIAgent, "__init__", _no_init), \ + patch.object(_plugins, "set_thread_tool_whitelist", _capture_whitelist), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + assert "whitelist" in captured, "set_thread_tool_whitelist was not called" + whitelist = captured["whitelist"] + # memory + skills tools must be allowed + assert "memory" in whitelist + assert "skill_manage" in whitelist + assert "skill_view" in whitelist + assert "skills_list" in whitelist + # dangerous tools must NOT be in the whitelist + assert "terminal" not in whitelist + assert "send_message" not in whitelist + assert "delegate_task" not in whitelist + assert "web_search" not in whitelist + assert "execute_code" not in whitelist def test_background_review_agent_tools_are_limited(): - """Verify the resolved memory+skills toolsets only contain memory and skill tools.""" + """Verify the resolved memory+skills toolsets only contain memory and skill tools. + + Sanity check on the source of truth for what the runtime whitelist is + derived from — if a future PR adds e.g. `terminal` to the `memory` + toolset, the review-fork safety contract silently breaks. + """ from toolsets import resolve_multiple_toolsets expected_tools = set(resolve_multiple_toolsets(["memory", "skills"])) diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py new file mode 100644 index 000000000000..46e47bae13e3 --- /dev/null +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -0,0 +1,418 @@ +"""Integration test for the codex_app_server runtime path through AIAgent. + +Verifies that: + - api_mode='codex_app_server' is accepted on AIAgent construction + - run_conversation() takes the early-return path and never enters the + chat completions loop + - Projected messages from a fake Codex session land in the messages list + - tool_iterations from the codex session tick the skill nudge counter + - Memory nudge counter ticks once per turn + - The returned dict has the same shape as the chat_completions path +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +import run_agent +from agent.transports.codex_app_server_session import CodexAppServerSession, TurnResult + + +@pytest.fixture +def fake_session(monkeypatch): + """Replace CodexAppServerSession with a stub that returns a fixed + TurnResult, so we can drive AIAgent without spawning real codex.""" + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text=f"echo: {user_input}", + projected_messages=[ + {"role": "assistant", "content": None, + "tool_calls": [{"id": "exec_1", "type": "function", + "function": {"name": "exec_command", + "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "exec_1", "content": "ok"}, + {"role": "assistant", "content": f"echo: {user_input}"}, + ], + tool_iterations=1, + interrupted=False, + error=None, + turn_id="turn-stub-1", + thread_id="thread-stub-1", + ) + + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1" + ) + + +def _make_codex_agent(): + """Construct an AIAgent in codex_app_server mode without contacting any + real provider. We pass api_mode explicitly so the constructor takes the + fast path for direct credentials.""" + return run_agent.AIAgent( + api_key="stub", + base_url="https://stub.invalid", + provider="openai", + api_mode="codex_app_server", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +class TestApiModeAccepted: + def test_api_mode_is_codex_app_server(self): + agent = _make_codex_agent() + assert agent.api_mode == "codex_app_server" + + +class TestRunConversationCodexPath: + def test_run_conversation_returns_codex_shape(self, fake_session): + agent = _make_codex_agent() + # No background review fork during tests + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello there") + assert result["final_response"] == "echo: hello there" + assert result["completed"] is True + assert result["partial"] is False + assert result["error"] is None + assert result["api_calls"] == 1 + assert result["codex_thread_id"] == "thread-stub-1" + assert result["codex_turn_id"] == "turn-stub-1" + + def test_projected_messages_are_spliced(self, fake_session): + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello") + msgs = result["messages"] + # User message + 3 projected (assistant tool_call + tool + assistant text) + assert len(msgs) >= 4 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == "hello" + # Last assistant message has the final text + final = [m for m in msgs if m.get("role") == "assistant" + and m.get("content") == "echo: hello"] + assert final, f"expected final assistant message in {msgs}" + + def test_nudge_counters_tick(self, fake_session): + """The skill nudge counter must accumulate tool_iterations across + turns. The memory nudge counter is gated on memory being configured + (which we skip via skip_memory=True), so we don't assert on it here — + a separate test below covers that path explicitly.""" + agent = _make_codex_agent() + agent._iters_since_skill = 0 + agent._user_turn_count = 0 + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("first") + assert agent._iters_since_skill == 1 # one tool_iteration in fake turn + # _user_turn_count is incremented by run_conversation pre-loop, not + # by the codex helper — confirms we delegate that to the standard flow. + assert agent._user_turn_count == 1 + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("second") + assert agent._iters_since_skill == 2 + assert agent._user_turn_count == 2 + + def test_user_message_not_duplicated(self, fake_session): + """Regression guard: the user message must appear exactly once in + the messages list. The standard run_conversation pre-loop appends + it, and the codex helper must NOT append again.""" + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("ping unique 12345") + user_count = sum( + 1 for m in result["messages"] + if m.get("role") == "user" and m.get("content") == "ping unique 12345" + ) + assert user_count == 1, f"user message appeared {user_count}× in {result['messages']}" + + def test_background_review_NOT_invoked_below_threshold(self, fake_session): + """A single turn shouldn't trigger background review — counters + haven't reached the nudge interval (default 10).""" + agent = _make_codex_agent() + agent._memory_nudge_interval = 10 + agent._skill_nudge_interval = 10 + agent._iters_since_skill = 0 + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("ping") + # Below threshold → review should NOT fire (was a real bug: + # the helper was calling _spawn_background_review() with no + # args after every turn, which would crash with TypeError). + assert not spawn.called + + def test_background_review_skill_trigger_fires_above_threshold( + self, monkeypatch + ): + """When tool iterations cross the skill nudge interval, the + background review fires with review_skills=True and the right + messages_snapshot signature.""" + from agent.transports.codex_app_server_session import ( + CodexAppServerSession, TurnResult, + ) + # Make the fake session report 10 tool iterations in one turn + # (matching the default skill threshold). + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text=f"echo: {user_input}", + projected_messages=[ + {"role": "assistant", "content": f"echo: {user_input}"}, + ], + tool_iterations=10, + turn_id="t1", thread_id="th1", + ) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th1" + ) + + agent = _make_codex_agent() + agent._skill_nudge_interval = 10 + agent._iters_since_skill = 0 + # Make valid_tool_names include 'skill_manage' so the gate passes + agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set())) + agent.valid_tool_names.add("skill_manage") + + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("do tool work") + + assert spawn.called, "skill threshold tripped but review didn't fire" + # Verify the call signature matches what _spawn_background_review + # actually expects — this is the regression guard for the original + # bug where the codex path called it with no args at all. + call = spawn.call_args + assert "messages_snapshot" in call.kwargs + assert isinstance(call.kwargs["messages_snapshot"], list) + assert call.kwargs["review_skills"] is True + # Counter should be reset after the review fires + assert agent._iters_since_skill == 0 + + def test_background_review_signature_never_breaks(self, fake_session): + """Even when no trigger fires, the helper must never call + _spawn_background_review with the wrong signature. Run a turn, + then run another turn after manually tripping the skill counter + and confirm the call shape is the kwargs-only form the function + actually accepts.""" + agent = _make_codex_agent() + agent._skill_nudge_interval = 1 # very low so any iter trips it + agent._iters_since_skill = 0 + agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set())) + agent.valid_tool_names.add("skill_manage") + + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("first") + # The fake session reports tool_iterations=1, which trips + # _skill_nudge_interval=1. So review should fire. + assert spawn.called + # Critical invariant: positional args must be empty, all real + # args must be kwargs (matching _spawn_background_review's + # actual signature). + call = spawn.call_args + assert call.args == (), ( + f"expected no positional args, got {call.args!r} — " + "would crash _spawn_background_review at runtime" + ) + assert "messages_snapshot" in call.kwargs + + def test_chat_completions_loop_is_not_entered(self, fake_session): + """The early-return must bypass the regular API call loop entirely. + We confirm by patching the SDK call and asserting it's never invoked.""" + agent = _make_codex_agent() + # The chat_completions loop calls self.client.chat.completions.create(...) + # If our early-return works, that path is dead. + with patch.object(agent, "client") as client_mock, patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("hi") + assert not client_mock.chat.completions.create.called + + +class TestReviewForkApiModeDowngrade: + """When the parent agent runs on codex_app_server, the background + review fork must downgrade to codex_responses — otherwise the fork + can't dispatch agent-loop tools (memory, skill_manage) which is the + whole point of the review.""" + + def test_codex_app_server_parent_downgrades_review_fork(self): + """Live test against the real _spawn_background_review code path: + verify the review_agent gets api_mode=codex_responses when the + parent is codex_app_server.""" + from unittest.mock import MagicMock, patch as _patch + agent = _make_codex_agent() + # Pretend memory + skills are configured so the review fork + # reaches the AIAgent constructor. + agent._memory_store = MagicMock() + agent._memory_enabled = True + agent._user_profile_enabled = True + # Mock _current_main_runtime to return the parent's codex_app_server + # state so we can confirm the helper detects + downgrades it. + agent._current_main_runtime = lambda: { + "api_mode": "codex_app_server", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "stub-token", + } + # Capture what AIAgent gets constructed with inside the helper. + captured = {} + + def _capture_init(self, **kwargs): + captured.update(kwargs) + # Set bare attributes the rest of the spawn function reads + # so it can finish without exploding. + self.api_mode = kwargs.get("api_mode") + self.provider = kwargs.get("provider") + self.model = kwargs.get("model") + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = False + self._user_profile_enabled = False + self._memory_nudge_interval = 0 + self._skill_nudge_interval = 0 + self.suppress_status_output = False + self._session_messages = [] + + def _no_op_run_conv(*a, **kw): + return {"final_response": "", "messages": []} + self.run_conversation = _no_op_run_conv + + def _no_op_close(*a, **kw): + return None + self.close = _no_op_close + + with _patch("run_agent.AIAgent.__init__", _capture_init): + agent._spawn_background_review( + messages_snapshot=[{"role": "user", "content": "x"}], + review_memory=True, + review_skills=False, + ) + # Wait for the spawned thread to actually execute + import time + for _ in range(30): + if "api_mode" in captured: + break + time.sleep(0.1) + + assert captured.get("api_mode") == "codex_responses", ( + f"review fork should be downgraded to codex_responses when " + f"parent is codex_app_server; got {captured.get('api_mode')!r}" + ) + + +class TestErrorHandling: + def test_session_exception_returns_partial_with_error(self, monkeypatch): + def boom_run_turn(self, user_input, **kwargs): + raise RuntimeError("subprocess died") + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "t1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + assert result["completed"] is False + assert result["partial"] is True + assert "subprocess died" in result["error"] + assert "codex-runtime auto" in result["final_response"] + + def test_interrupted_turn_marked_partial(self, monkeypatch): + def interrupted_turn(self, user_input, **kwargs): + return TurnResult( + final_text="", + projected_messages=[], + tool_iterations=0, + interrupted=True, + error="user interrupted", + turn_id="t", + thread_id="th", + ) + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th") + monkeypatch.setattr(CodexAppServerSession, "run_turn", interrupted_turn) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + assert result["completed"] is False + assert result["partial"] is True + assert result["error"] == "user interrupted" + + +class TestSessionRetirementOnRunAgent: + """run_agent.py side: when run_turn returns should_retire=True, the + AIAgent must close + null _codex_session so the next turn respawns.""" + + def test_should_retire_drops_session(self, monkeypatch): + closes = {"count": 0} + + def fake_run_turn(self, user_input, **kwargs): + return TurnResult( + final_text="", + projected_messages=[], + tool_iterations=0, + interrupted=True, + error="turn timed out after 600.0s", + turn_id="tu1", + thread_id="th1", + should_retire=True, + ) + + def fake_close(self): + closes["count"] += 1 + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr(CodexAppServerSession, "close", fake_close) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + + # The session was closed and cleared + assert closes["count"] == 1 + assert getattr(agent, "_codex_session", "MISSING") is None + # Partial result was still returned (caller still sees the error) + assert result["partial"] is True + assert result["error"] == "turn timed out after 600.0s" + + def test_normal_turn_keeps_session(self, fake_session): + """fake_session fixture returns should_retire=False (default). + The session must stay attached for the next turn to reuse.""" + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("hi") + # Session was lazily created and still attached. + assert getattr(agent, "_codex_session", None) is not None + + def test_exception_path_also_drops_session(self, monkeypatch): + """Even if run_turn raises (not just sets should_retire), we must + drop the session — a thrown exception is the strongest possible + signal the process is dead.""" + closes = {"count": 0} + + def boom_run_turn(self, user_input, **kwargs): + raise RuntimeError("codex segfaulted") + + def fake_close(self): + closes["count"] += 1 + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn) + monkeypatch.setattr(CodexAppServerSession, "close", fake_close) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + + assert closes["count"] == 1 + assert agent._codex_session is None + assert result["completed"] is False + assert "codex segfaulted" in result["error"] diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index f935821ada94..3e23f3eb5d3f 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -16,6 +16,16 @@ from agent.context_compressor import ContextCompressor +@pytest.fixture(autouse=True) +def _stable_aux_provider_config(): + """Keep feasibility tests independent from the developer's config.yaml.""" + with patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", None, None, None, None), + ): + yield + + def _make_agent( *, compression_enabled: bool = True, @@ -41,6 +51,7 @@ def _make_agent( agent.tool_progress_callback = None agent._compression_warning = None agent._aux_compression_context_length_config = None + agent._custom_providers = [] agent.tools = [] compressor = MagicMock(spec=ContextCompressor) @@ -182,6 +193,7 @@ def test_feasibility_check_passes_config_context_length(mock_get_client, mock_ct api_key="sk-custom", config_context_length=1_000_000, provider="openrouter", + custom_providers=[], ) @@ -205,6 +217,7 @@ def test_feasibility_check_ignores_invalid_context_length(mock_get_client, mock_ api_key="sk-test", config_context_length=None, provider="openrouter", + custom_providers=[], ) @@ -258,6 +271,7 @@ def on_session_start(self, *args, **kwargs): api_key="sk-custom", config_context_length=1_000_000, provider="", + custom_providers=[], ) diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 8b04a73262b5..c925a508915d 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -1,4 +1,4 @@ -"""Tests that switch_model preserves config_context_length.""" +"""Tests that switch_model does not inherit stale context_length overrides.""" from unittest.mock import MagicMock, patch @@ -19,7 +19,7 @@ def _make_agent_with_compressor(config_context_length=None) -> AIAgent: agent.client = MagicMock() agent.quiet_mode = True - # Store config_context_length for later use in switch_model + # Store the initial config_context_length override used at agent construction. agent._config_context_length = config_context_length # Context compressor with primary model values @@ -41,8 +41,8 @@ def _make_agent_with_compressor(config_context_length=None) -> AIAgent: @patch("agent.model_metadata.get_model_context_length", return_value=131_072) -def test_switch_model_preserves_config_context_length(mock_ctx_len): - """When switching models, config_context_length should be passed to get_model_context_length.""" +def test_switch_model_clears_previous_config_context_length(mock_ctx_len): + """Switching models must not reuse the previous model.context_length override.""" agent = _make_agent_with_compressor(config_context_length=32_768) assert agent.context_compressor.model == "primary-model" @@ -51,13 +51,14 @@ def test_switch_model_preserves_config_context_length(mock_ctx_len): # Switch model agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="https://openrouter.ai/api/v1") - # Verify get_model_context_length was called with config_context_length + # Verify the old config override is not passed to the new model. mock_ctx_len.assert_called_once() call_kwargs = mock_ctx_len.call_args.kwargs - assert call_kwargs.get("config_context_length") == 32_768 + assert call_kwargs.get("config_context_length") is None - # Verify compressor was updated + # Verify compressor was updated from the newly resolved model metadata. assert agent.context_compressor.model == "new-model" + assert agent.context_compressor.context_length == 131_072 def test_switch_model_without_config_context_length(): diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py new file mode 100644 index 000000000000..8db8988f40c5 --- /dev/null +++ b/tests/test_gateway_streaming_nested_config.py @@ -0,0 +1,46 @@ +"""Regression test for #25676 — nested gateway.streaming config must be loaded.""" +from pathlib import Path +from unittest.mock import patch, MagicMock +import json + +import pytest +import yaml + + +def _load_with_yaml_dict(yaml_dict: dict): + """Patch filesystem so load_gateway_config() sees *yaml_dict* as config.yaml.""" + from gateway.config import load_gateway_config + + fake_home = Path("/tmp/fake_hermes_home_25676") + + def fake_exists(self): + return str(self).endswith("config.yaml") + + with patch("gateway.config.get_hermes_home", return_value=fake_home), \ + patch.object(Path, "exists", fake_exists), \ + patch("builtins.open", create=True) as mock_file: + mock_file.return_value.__enter__ = lambda s: s + mock_file.return_value.__exit__ = MagicMock(return_value=False) + with patch("yaml.safe_load", return_value=yaml_dict): + return load_gateway_config() + + +class TestStreamingConfigNested: + def test_top_level_streaming(self): + cfg = _load_with_yaml_dict({"streaming": {"enabled": True, "transport": "draft"}}) + assert cfg.streaming.enabled is True + assert cfg.streaming.transport == "draft" + + def test_nested_gateway_streaming(self): + """Regression for #25676.""" + cfg = _load_with_yaml_dict({"gateway": {"streaming": {"enabled": True, "transport": "draft"}}}) + assert cfg.streaming.enabled is True + assert cfg.streaming.transport == "draft" + + def test_top_level_takes_precedence(self): + cfg = _load_with_yaml_dict({ + "streaming": {"enabled": True, "transport": "edit"}, + "gateway": {"streaming": {"enabled": False, "transport": "draft"}}, + }) + assert cfg.streaming.enabled is True + assert cfg.streaming.transport == "edit" diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py new file mode 100644 index 000000000000..6ec3b565384d --- /dev/null +++ b/tests/test_install_sh_browser_install.py @@ -0,0 +1,60 @@ +"""Regression tests for install.sh browser setup. + +Browser automation is optional. The installer should not leave Hermes +half-installed just because Playwright's managed Chromium download hangs on an +unsupported distribution. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" + + +def test_install_script_skips_playwright_download_when_system_browser_exists() -> None: + text = INSTALL_SH.read_text() + + assert "find_system_browser()" in text + assert "google-chrome google-chrome-stable chromium chromium-browser chrome" in text + assert "Skipping Playwright browser download; Hermes will use the system browser." in text + + +def test_install_script_persists_system_browser_for_agent_browser() -> None: + text = INSTALL_SH.read_text() + + assert "configure_browser_env_from_system_browser()" in text + assert "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" in text + + +def test_playwright_installs_are_timeout_guarded() -> None: + text = INSTALL_SH.read_text() + + assert "run_browser_install_with_timeout()" in text + assert "run_browser_install_with_timeout 600 npx playwright install chromium" in text + # --with-deps is still invoked on apt-based systems, but only when sudo + # is available non-interactively (root or passwordless sudo). Non-sudo + # service users fall back to the browser-only install — see + # install_node_deps() in install.sh. + assert "run_browser_install_with_timeout 600 npx playwright install --with-deps chromium" in text + + +def test_install_script_supports_skip_browser_flag() -> None: + """--skip-browser (and --no-playwright alias) skips the Playwright install.""" + text = INSTALL_SH.read_text() + + assert "--skip-browser|--no-playwright)" in text + assert "SKIP_BROWSER=true" in text + assert 'if [ "$SKIP_BROWSER" = true ]; then' in text + assert "--skip-browser Skip Playwright/Chromium install" in text + + +def test_install_script_skips_with_deps_when_no_sudo() -> None: + """Non-sudo users on apt distros must not block on an interactive sudo prompt.""" + text = INSTALL_SH.read_text() + + # The apt branch must gate --with-deps behind a sudo capability check + # (root or non-interactive sudo), otherwise the installer hangs for + # service-user installs (systemd accounts, operator users, etc.). + assert 'if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then' in text + assert "sudo npx playwright install-deps chromium" in text diff --git a/tests/test_install_sh_symlink_stomp.py b/tests/test_install_sh_symlink_stomp.py new file mode 100644 index 000000000000..450d6fe20882 --- /dev/null +++ b/tests/test_install_sh_symlink_stomp.py @@ -0,0 +1,123 @@ +"""Regression for #21454: re-running install.sh on a symlinked prior install. + +Older versions of ``install.sh`` created ``$command_link_dir/hermes`` as a +symlink to the pip-generated entry point at ``$HERMES_BIN`` (i.e. +``venv/bin/hermes``). When ``setup_path()`` later switched to writing a bash +shim with ``cat > "$command_link_dir/hermes" < str: + """Return the install.sh shim-write block used by setup_path().""" + text = INSTALL_SH.read_text() + match = re.search( + r"(?Pmkdir -p \"\$command_link_dir\".*?chmod \+x \"\$command_link_dir/hermes\")", + text, + re.DOTALL, + ) + assert match is not None, ( + "Could not locate the setup_path shim-write block in scripts/install.sh" + ) + return match["block"] + + +def test_setup_path_shim_block_removes_old_link_before_writing() -> None: + """Static guard: the rm must precede the cat heredoc, not follow it.""" + block = _extract_setup_path_shim_block() + rm_idx = block.find('rm -f "$command_link_dir/hermes"') + cat_idx = block.find('cat > "$command_link_dir/hermes" <` heredoc, otherwise an existing symlink (left by older " + "installs) will be followed and the pip entry point overwritten. " + "See #21454." + ) + assert cat_idx != -1, "expected `cat >` heredoc still present" + assert rm_idx < cat_idx, ( + "`rm -f` must come *before* the `cat >` heredoc, not after." + ) + + +def test_re_running_setup_path_block_preserves_pip_entry_point(tmp_path: Path) -> None: + """Behavioral repro: simulate prior-install symlink + new-install heredoc. + + Layout mirrors a real install: + + tmp/ + venv/bin/hermes <- pip entry point (the one we must preserve) + local_bin/hermes <- symlink → ../venv/bin/hermes (old install) + + Then we run the exact shim-write block from setup_path() with + ``HERMES_BIN`` and ``command_link_dir`` pointed at this fixture. The fix + requires that, after the run: + + * ``venv/bin/hermes`` still contains its original pip-script body + * ``local_bin/hermes`` is a regular file (not a symlink) holding the shim + """ + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + pip_entry = venv_bin / "hermes" + pip_marker = "#!/usr/bin/env python\n# pip-generated entry point — must not be overwritten\n" + pip_entry.write_text(pip_marker) + pip_entry.chmod(pip_entry.stat().st_mode | stat.S_IXUSR) + + command_link_dir = tmp_path / "local_bin" + command_link_dir.mkdir() + shim_path = command_link_dir / "hermes" + # Reproduce the prior-install state: shim path is a symlink to the + # pip-generated entry point. + shim_path.symlink_to(pip_entry) + assert shim_path.is_symlink() + + block = _extract_setup_path_shim_block() + # Drive the block with the real env vars setup_path() sets. + script = f'set -e\nHERMES_BIN={pip_entry!s}\ncommand_link_dir={command_link_dir!s}\n{block}\n' + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert result.returncode == 0, ( + f"shim-write block failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + # The pip entry point must still be the original pip script — not a + # re-written self-recursing bash shim. + assert pip_entry.read_text() == pip_marker, ( + "venv/bin/hermes was overwritten by setup_path() — symlink-stomp " + "regression (#21454)." + ) + + # The shim path itself must now be a regular file holding the launcher. + assert shim_path.exists() + assert not shim_path.is_symlink(), ( + "command_link_dir/hermes must be replaced with a regular file, not " + "left as a symlink — otherwise the next install will stomp again." + ) + shim_text = shim_path.read_text() + assert "unset PYTHONPATH" in shim_text + assert "unset PYTHONHOME" in shim_text + assert f'exec "{pip_entry}"' in shim_text + shim_mode = shim_path.stat().st_mode + assert shim_mode & stat.S_IXUSR, "shim must be user-executable" diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index afd618a92e68..a6f4fc6b72ed 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -246,3 +246,11 @@ def test_get_all_toolsets_includes_plugin_toolset(self, monkeypatch): all_toolsets = get_all_toolsets() assert "plugin_bundle" in all_toolsets assert all_toolsets["plugin_bundle"]["tools"] == ["plugin_tool"] + + +class TestDefaultPlatformWebSearchCoverage: + def test_hermes_whatsapp_toolset_includes_web_search(self): + assert "web_search" in resolve_toolset("hermes-whatsapp") + + def test_hermes_api_server_toolset_includes_web_search(self): + assert "web_search" in resolve_toolset("hermes-api-server") diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index ef3fca4352fa..760dfa5d230a 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -41,6 +41,16 @@ def test_always_includes_default_ms_playwright_cache(self, monkeypatch): class TestChromiumInstalled: + def test_true_when_plain_chromium_on_path(self, monkeypatch): + monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) + monkeypatch.setattr( + bt.shutil, + "which", + lambda name: "/usr/bin/chromium" if name == "chromium" else None, + ) + + assert bt._chromium_installed() is True + def test_true_when_chromium_dir_present(self, monkeypatch, tmp_path): monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) (tmp_path / "chromium-1208").mkdir() @@ -108,4 +118,3 @@ class TestRunBrowserCommandChromiumGuard: """ - diff --git a/tests/tools/test_clarify_gateway.py b/tests/tools/test_clarify_gateway.py index 61ea55c8cfcb..86385be3571f 100644 --- a/tests/tools/test_clarify_gateway.py +++ b/tests/tools/test_clarify_gateway.py @@ -205,3 +205,23 @@ def test_get_pending_for_session_returns_oldest_text_awaiting(self): pending2 = cm.get_pending_for_session("sk") assert pending2 is not None assert pending2.clarify_id == "first" + def test_text_fallback_enables_awaiting_text_for_multi_choice(self): + """When base send_clarify renders choices as text, mark_awaiting_text + is called so the gateway text-intercept can capture the reply.""" + from tools import clarify_gateway as cm + + entry = cm.register("id-tf", "sk-tf", "Pick one", ["A", "B", "C"]) + # Initially, multi-choice does NOT await text (button path) + assert entry.awaiting_text is False + + # After the base send_clarify text fallback calls mark_awaiting_text: + flipped = cm.mark_awaiting_text("id-tf") + assert flipped is True + + # Now get_pending_for_session should find it + pending = cm.get_pending_for_session("sk-tf") + assert pending is not None + assert pending.clarify_id == "id-tf" + + # Clean up + cm.clear_session("sk-tf") diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 90e2ea847f8d..750874400c4d 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -39,6 +39,7 @@ FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 FAKE_BMP = b"BM" + b"\x00" * 100 +FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # ═════════════════════════════════════════════════════════════════════════ @@ -393,10 +394,54 @@ def fake_run(cmd, **kw): if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_BMP) return MagicMock(returncode=0) + + def fake_convert(path): + assert path == dest + path.write_bytes(FAKE_PNG) + return True + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): - with patch("hermes_cli.clipboard._convert_to_png", return_value=True): + with patch("hermes_cli.clipboard._convert_to_png", side_effect=fake_convert): + assert _wayland_save(dest) is True + + def test_jpeg_extraction_converts_to_real_png(self, tmp_path): + dest = tmp_path / "out.png" + + def fake_run(cmd, **kw): + if "--list-types" in cmd: + return MagicMock(stdout="image/jpeg\ntext/plain\n", returncode=0) + if "stdout" in kw and hasattr(kw["stdout"], "write"): + kw["stdout"].write(FAKE_JPEG) + return MagicMock(returncode=0) + + def fake_convert(path): + assert path == dest + path.write_bytes(FAKE_PNG) + return True + + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): + with patch("hermes_cli.clipboard._convert_to_png", side_effect=fake_convert) as mock_convert: assert _wayland_save(dest) is True + mock_convert.assert_called_once_with(dest) + assert dest.read_bytes() == FAKE_PNG + + def test_non_png_conversion_failure_cleans_up(self, tmp_path): + dest = tmp_path / "out.png" + + def fake_run(cmd, **kw): + if "--list-types" in cmd: + return MagicMock(stdout="image/jpeg\n", returncode=0) + if "stdout" in kw and hasattr(kw["stdout"], "write"): + kw["stdout"].write(FAKE_JPEG) + return MagicMock(returncode=0) + + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): + with patch("hermes_cli.clipboard._convert_to_png", return_value=True): + assert _wayland_save(dest) is False + + assert not dest.exists() + def test_no_image_types(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 58700dcaaf20..5b0359503489 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -591,6 +591,67 @@ def test_trajectory_normalize_strips_images(self): for p in cleaned["content"] ) + def test_computer_use_image_result_becomes_error_for_text_only_model(self): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent.provider = "deepseek" + agent.model = "deepseek-v4-pro" + result = { + "_multimodal": True, + "content": [ + {"type": "text", "text": "screen captured"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ], + "text_summary": "screen captured", + } + + with patch.object(agent, "_model_supports_vision", return_value=False): + content = agent._tool_result_content_for_active_model("computer_use", result) + + parsed = json.loads(content) + assert "computer_use returned screenshot/image content" in parsed["error"] + assert parsed["text_summary"] == "screen captured" + assert "image_url" not in content + + def test_computer_use_image_result_preserved_for_vision_model(self): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + result = { + "_multimodal": True, + "content": [ + {"type": "text", "text": "screen captured"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ], + } + + with patch.object(agent, "_model_supports_vision", return_value=True): + content = agent._tool_result_content_for_active_model("computer_use", result) + + assert content is result["content"] + assert any(part.get("type") == "image_url" for part in content) + + def test_other_multimodal_tool_uses_text_summary_for_text_only_model(self): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent.provider = "custom" + agent.model = "text-only" + result = { + "_multimodal": True, + "content": [ + {"type": "text", "text": "analysis text"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ], + "text_summary": "analysis summary", + } + + with patch.object(agent, "_model_supports_vision", return_value=False): + content = agent._tool_result_content_for_active_model("vision_analyze", result) + + assert content == "analysis summary" + # --------------------------------------------------------------------------- # Universality: does the schema work without Anthropic? diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 9beecc0d995a..714c5995eaab 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -226,3 +226,182 @@ 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 + + +# --------------------------------------------------------------------------- +# Version-aware _is_satisfied (Piece B — "stale pin" detection) +# +# The original implementation returned True the moment the package name +# was importable, ignoring the spec's version range. That meant pin bumps +# in LAZY_DEPS never propagated to users who already lazy-installed the +# backend at an older version. _is_satisfied now parses the spec and +# checks the installed version against the constraint. +# --------------------------------------------------------------------------- + + +class TestIsSatisfiedVersionAware: + def _fake_version(self, monkeypatch, installed_versions: dict): + """Patch importlib.metadata.version() inside lazy_deps.""" + from importlib.metadata import PackageNotFoundError + + def _version(pkg): + if pkg in installed_versions: + return installed_versions[pkg] + raise PackageNotFoundError(pkg) + + # Patch at the import site lazy_deps uses (inside the function). + import importlib.metadata as _md + monkeypatch.setattr(_md, "version", _version) + + def test_exact_pin_match_returns_true(self, monkeypatch): + self._fake_version(monkeypatch, {"honcho-ai": "2.0.1"}) + assert ld._is_satisfied("honcho-ai==2.0.1") is True + + def test_exact_pin_mismatch_returns_false(self, monkeypatch): + # Installed 2.0.0, spec requires 2.0.1 → False (needs upgrade). + self._fake_version(monkeypatch, {"honcho-ai": "2.0.0"}) + assert ld._is_satisfied("honcho-ai==2.0.1") is False + + def test_range_within_returns_true(self, monkeypatch): + self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True + + def test_range_above_returns_false(self, monkeypatch): + # Installed too new for the upper bound. + self._fake_version(monkeypatch, {"slack-bolt": "2.0.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False + + def test_range_below_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {"slack-bolt": "1.0.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False + + def test_package_not_installed_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {}) + assert ld._is_satisfied("anthropic==0.86.0") is False + + def test_bare_package_name_presence_is_enough(self, monkeypatch): + # No version constraint — presence alone counts as satisfied. + self._fake_version(monkeypatch, {"somepkg": "1.0.0"}) + assert ld._is_satisfied("somepkg") is True + + def test_extras_block_in_spec_is_stripped(self, monkeypatch): + # mautrix[encryption]==0.21.0 — the [encryption] block must not + # confuse the specifier parser. + self._fake_version(monkeypatch, {"mautrix": "0.21.0"}) + assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True + + def test_extras_block_mismatch_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {"mautrix": "0.20.0"}) + assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False + + +# --------------------------------------------------------------------------- +# active_features + refresh_active_features (Piece A — hermes update wiring) +# --------------------------------------------------------------------------- + + +class TestActiveFeatures: + def test_no_packages_installed_returns_empty(self, monkeypatch): + monkeypatch.setattr(ld, "_is_present", lambda spec: False) + assert ld.active_features() == [] + + def test_finds_features_with_at_least_one_package_installed(self, monkeypatch): + # Pretend only honcho-ai is installed; nothing else. + monkeypatch.setattr( + ld, "_is_present", + lambda spec: ld._pkg_name_from_spec(spec) == "honcho-ai", + ) + active = ld.active_features() + assert "memory.honcho" in active + # Backends the user never enabled stay quiet. + assert "memory.hindsight" not in active + assert "platform.slack" not in active + + def test_multi_package_feature_active_if_any_present(self, monkeypatch): + # platform.slack has 3 packages; only one needs to be present + # for the feature to count as active (user activated it before, + # one transitive may have been uninstalled separately). + monkeypatch.setattr( + ld, "_is_present", + lambda spec: ld._pkg_name_from_spec(spec) == "slack-bolt", + ) + assert "platform.slack" in ld.active_features() + + +class TestRefreshActiveFeatures: + def test_no_active_features_returns_empty(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: []) + assert ld.refresh_active_features() == {} + + def test_already_current_is_noop(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==1.0.0",)) + 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"), + ) + result = ld.refresh_active_features() + assert result == {"test.feat": "current"} + + def test_stale_pin_triggers_reinstall(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + # First _is_satisfied check (in feature_missing) says no; after + # install, post-install check says yes. + states = iter([False, True]) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states)) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + result = ld.refresh_active_features() + assert result == {"test.feat": "refreshed"} + + def test_install_failure_recorded_not_raised(self, monkeypatch): + # A failed refresh must NOT raise out of hermes update. + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + 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: PyPI 404 quarantine" + ), + ) + result = ld.refresh_active_features() + assert "test.feat" in result + assert result["test.feat"].startswith("failed:") + assert "404 quarantine" in result["test.feat"] + + def test_lazy_installs_disabled_marked_skipped(self, monkeypatch): + # security.allow_lazy_installs=false → don't error, mark skipped + # so hermes update can render "respecting your config" message. + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + result = ld.refresh_active_features() + assert "test.feat" in result + assert result["test.feat"].startswith("skipped:") + + def test_mixed_results_returns_per_feature_status(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"]) + monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",)) + monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",)) + # a.ok: already satisfied → "current" + # b.fail: missing + install fails → "failed:" + def fake_satisfied(spec): + return ld._pkg_name_from_spec(spec) == "pkga" + 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(False, "", "nope"), + ) + result = ld.refresh_active_features() + assert result["a.ok"] == "current" + assert result["b.fail"].startswith("failed:") diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index a10c7f436166..5558a0df48c1 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -1592,6 +1592,40 @@ async def _test(): asyncio.run(_test()) + def test_initial_oauth_failure_does_not_retry(self): + """Initial OAuth failures stop immediately to avoid repeated browser prompts.""" + from tools.mcp_tool import MCPServerTask + + run_count = 0 + target_server = None + oauth_error = RuntimeError("Token exchange failed (400): Unknown client_id") + + original_run_stdio = MCPServerTask._run_stdio + + async def patched_run_stdio(self_srv, config): + nonlocal run_count, target_server + run_count += 1 + if target_server is not self_srv: + return await original_run_stdio(self_srv, config) + raise oauth_error + + async def _test(): + nonlocal target_server + server = MCPServerTask("oauth_srv") + target_server = server + + with patch.object(MCPServerTask, "_run_stdio", patched_run_stdio), \ + patch("tools.mcp_tool._is_auth_error", return_value=True), \ + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await server.run({"command": "test"}) + + assert run_count == 1 + assert server._error is oauth_error + assert server._ready.is_set() + assert mock_sleep.await_count == 0 + + asyncio.run(_test()) + # --------------------------------------------------------------------------- # Configurable timeouts diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 0023b5c9bd2c..7ad5fff4f165 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest.mock import patch -from tools.registry import ToolRegistry, discover_builtin_tools +from tools.registry import ToolRegistry, _module_registers_tools, discover_builtin_tools def _dummy_handler(args, **kwargs): @@ -289,43 +289,19 @@ def test_check_tool_availability_survives_raising_check(self): class TestBuiltinDiscovery: - def test_matches_previous_manual_builtin_tool_set(self): - expected = { - "tools.browser_cdp_tool", - "tools.browser_dialog_tool", - "tools.browser_tool", - "tools.clarify_tool", - "tools.code_execution_tool", - "tools.computer_use_tool", - "tools.cronjob_tools", - "tools.delegate_tool", - "tools.discord_tool", - "tools.feishu_doc_tool", - "tools.feishu_drive_tool", - "tools.file_tools", - "tools.homeassistant_tool", - "tools.image_generation_tool", - "tools.kanban_tools", - "tools.memory_tool", - "tools.mixture_of_agents_tool", - "tools.process_registry", - "tools.rl_training_tool", - "tools.send_message_tool", - "tools.session_search_tool", - "tools.skill_manager_tool", - "tools.skills_tool", - "tools.terminal_tool", - "tools.todo_tool", - "tools.tts_tool", - "tools.vision_tools", - "tools.web_tools", - "tools.yuanbao_tools", - } + def test_discovers_all_real_self_registering_builtin_tool_modules(self): + tools_dir = Path(__file__).resolve().parents[2] / "tools" + expected = [ + f"tools.{path.stem}" + for path in sorted(tools_dir.glob("*.py")) + if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"} + and _module_registers_tools(path) + ] with patch("tools.registry.importlib.import_module"): - imported = discover_builtin_tools(Path(__file__).resolve().parents[2] / "tools") + imported = discover_builtin_tools(tools_dir) - assert set(imported) == expected + assert imported == expected def test_imports_only_self_registering_modules(self, tmp_path): tools_dir = tmp_path / "tools" diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index d95fc0671d44..9502467546e1 100644 --- a/tests/tools/test_skills_tool.py +++ b/tests/tools/test_skills_tool.py @@ -1076,3 +1076,168 @@ def fake_secret_callback(var_name, prompt, metadata=None): assert result["setup_needed"] is False assert result["missing_required_environment_variables"] == [] assert result["readiness_status"] == "available" + + +class TestSkillViewCollisionDetection: + """Regression tests for skill_view name collision handling. + + When a skill name resolves to multiple paths across the local skills + dir and external_dirs, skill_view must refuse to guess. Silent + shadowing — where ``/skills`` shows the local version but + ``skill_view`` loads the external one — is the bug class this guards + against. Reproduces with `skills.external_dirs` registered in + config.yaml and a same-name skill nested under a category locally. + + Adapted from a regression suite originally proposed by @polkn in PR + #6136 (which used local-first precedence). The collision-refusal + behavior preserves the same protection without silently picking a + side, and gives the user an actionable hint (use the categorized + path) to recover. + """ + + def _patch_dirs(self, local_dir, external_dirs): + """Patch SKILLS_DIR (module-level) and get_external_skills_dirs at source.""" + return ( + patch("tools.skills_tool.SKILLS_DIR", local_dir), + patch( + "agent.skill_utils.get_external_skills_dirs", + return_value=list(external_dirs), + ), + ) + + def test_nested_local_collides_with_top_level_external(self, tmp_path): + """The original bug scenario: nested local + top-level external, + same name. Now refuses with both paths surfaced.""" + local_dir = tmp_path / "local" + external_dir = tmp_path / "external" + local_dir.mkdir() + external_dir.mkdir() + + _make_skill( + local_dir, + "explore-codebase", + category="foundations/runtime", + body="LOCAL VERSION", + ) + _make_skill(external_dir, "explore-codebase", body="EXTERNAL VERSION") + + p1, p2 = self._patch_dirs(local_dir, [external_dir]) + with p1, p2: + raw = skill_view("explore-codebase") + + result = json.loads(raw) + assert result["success"] is False + assert "Ambiguous skill name 'explore-codebase'" in result["error"] + assert "matches" in result + assert len(result["matches"]) == 2 + # Both paths surfaced + assert any("foundations/runtime" in p for p in result["matches"]) + assert any("external" in p for p in result["matches"]) + assert "hint" in result + + def test_top_level_local_collides_with_external(self, tmp_path): + """Top-level local + top-level external with the same name also + refuses — same-name shadowing is ambiguous regardless of nesting.""" + local_dir = tmp_path / "local" + external_dir = tmp_path / "external" + local_dir.mkdir() + external_dir.mkdir() + + _make_skill(local_dir, "shared-name", body="LOCAL VERSION") + _make_skill(external_dir, "shared-name", body="EXTERNAL VERSION") + + p1, p2 = self._patch_dirs(local_dir, [external_dir]) + with p1, p2: + raw = skill_view("shared-name") + + result = json.loads(raw) + assert result["success"] is False + assert "Ambiguous" in result["error"] + assert len(result["matches"]) == 2 + + def test_collision_resolvable_via_categorized_path(self, tmp_path): + """User can recover from a collision by passing the full + categorized path — the bare name is ambiguous, the path is not.""" + local_dir = tmp_path / "local" + external_dir = tmp_path / "external" + local_dir.mkdir() + external_dir.mkdir() + + _make_skill( + local_dir, + "explore-codebase", + category="foundations/runtime", + body="LOCAL VERSION", + ) + _make_skill(external_dir, "explore-codebase", body="EXTERNAL VERSION") + + p1, p2 = self._patch_dirs(local_dir, [external_dir]) + with p1, p2: + raw = skill_view("foundations/runtime/explore-codebase") + + result = json.loads(raw) + assert result["success"] is True + assert "LOCAL VERSION" in result["content"] + + def test_external_skill_resolves_when_no_collision(self, tmp_path): + """External-only skills still resolve normally when there's no + local skill of the same name.""" + local_dir = tmp_path / "local" + external_dir = tmp_path / "external" + local_dir.mkdir() + external_dir.mkdir() + + _make_skill(external_dir, "external-only", body="EXTERNAL BODY") + + p1, p2 = self._patch_dirs(local_dir, [external_dir]) + with p1, p2: + raw = skill_view("external-only") + + result = json.loads(raw) + assert result["success"] is True + assert "EXTERNAL BODY" in result["content"] + + def test_two_externals_same_name_also_refuse(self, tmp_path): + """Collision detection is symmetric — two external dirs with + same-name skills also trigger the refusal.""" + local_dir = tmp_path / "local" + ext_a = tmp_path / "ext_a" + ext_b = tmp_path / "ext_b" + local_dir.mkdir() + ext_a.mkdir() + ext_b.mkdir() + + _make_skill(ext_a, "pr", body="EXT_A VERSION") + _make_skill(ext_b, "pr", body="EXT_B VERSION") + + p1, p2 = self._patch_dirs(local_dir, [ext_a, ext_b]) + with p1, p2: + raw = skill_view("pr") + + result = json.loads(raw) + assert result["success"] is False + assert "Ambiguous" in result["error"] + assert len(result["matches"]) == 2 + + def test_local_only_skill_loads_normally(self, tmp_path): + """Sanity: a single local skill (no external collision) loads + without any error.""" + local_dir = tmp_path / "local" + external_dir = tmp_path / "external" + local_dir.mkdir() + external_dir.mkdir() + + _make_skill( + local_dir, + "my-skill", + category="foundations/runtime", + body="LOCAL BODY", + ) + + p1, p2 = self._patch_dirs(local_dir, [external_dir]) + with p1, p2: + raw = skill_view("my-skill") + + result = json.loads(raw) + assert result["success"] is True + assert "LOCAL BODY" in result["content"] diff --git a/tests/tools/test_transcription.py b/tests/tools/test_transcription.py index e56577ca5569..32f0ad487980 100644 --- a/tests/tools/test_transcription.py +++ b/tests/tools/test_transcription.py @@ -8,11 +8,16 @@ import os import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch, mock_open import pytest +def _fake_faster_whisper_module(mock_model): + return SimpleNamespace(WhisperModel=MagicMock(return_value=mock_model)) + + # --------------------------------------------------------------------------- # Provider selection # --------------------------------------------------------------------------- @@ -137,8 +142,9 @@ def test_successful_transcription(self, tmp_path): mock_model = MagicMock() mock_model.transcribe.return_value = ([mock_segment], mock_info) + fake_fw = _fake_faster_whisper_module(mock_model) with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ - patch("faster_whisper.WhisperModel", return_value=mock_model), \ + patch.dict("sys.modules", {"faster_whisper": fake_fw}), \ patch("tools.transcription_tools._local_model", None): from tools.transcription_tools import _transcribe_local result = _transcribe_local(str(audio_file), "base") @@ -300,7 +306,8 @@ def test_local_transcribe_normalises_model(self): }), \ patch("tools.transcription_tools._local_model", None), \ patch("tools.transcription_tools._local_model_name", None), \ - patch("faster_whisper.WhisperModel", return_value=mock_model) as mock_cls: + patch.dict("sys.modules", {"faster_whisper": _fake_faster_whisper_module(mock_model)}): + mock_cls = __import__("faster_whisper").WhisperModel from tools.transcription_tools import transcribe_audio transcribe_audio(audio_file) # WhisperModel must NOT have been called with "whisper-1" diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index ce45cb9f1e61..7f83565b5d8d 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -1363,3 +1363,45 @@ def test_model_override_passed_to_xai(self, sample_ogg): transcribe_audio(sample_ogg, model="custom-stt") assert mock_xai.call_args[0][1] == "custom-stt" + + +# ============================================================================ +# Shell safety — shlex.split on auto-detected templates +# ============================================================================ +class TestShellSafety: + def test_auto_detected_template_is_shlex_safe(self, monkeypatch): + """Auto-detected whisper command should be safely splittable.""" + import shlex + monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) + monkeypatch.setattr( + "tools.transcription_tools._find_whisper_binary", + lambda: "/usr/bin/whisper", + ) + from tools.transcription_tools import _get_local_command_template + template = _get_local_command_template() + assert template is not None + cmd = template.format( + input_path=shlex.quote("/tmp/test.wav"), + output_dir=shlex.quote("/tmp/out"), + language=shlex.quote("en"), + model=shlex.quote("base"), + ) + parts = shlex.split(cmd) + assert parts[0] == "/usr/bin/whisper" + assert "/tmp/test.wav" in parts + + def test_env_var_template_uses_shell_path(self, monkeypatch): + """When HERMES_LOCAL_STT_COMMAND is set, use_shell should be True.""" + import os + from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + monkeypatch.setenv(LOCAL_STT_COMMAND_ENV, "whisper {input_path} | tee log.txt") + use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) + assert use_shell is True + + def test_no_env_var_uses_list_mode(self, monkeypatch): + """When no env var is set, use_shell should be False.""" + import os + from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + monkeypatch.delenv(LOCAL_STT_COMMAND_ENV, raising=False) + use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) + assert use_shell is False diff --git a/tests/tools/test_tts_kittentts.py b/tests/tools/test_tts_kittentts.py index ab841f59f4ad..f4918df4496b 100644 --- a/tests/tools/test_tts_kittentts.py +++ b/tests/tools/test_tts_kittentts.py @@ -3,7 +3,6 @@ import json from unittest.mock import MagicMock, patch -import numpy as np import pytest @@ -27,7 +26,7 @@ def mock_kittentts_module(): """Inject a fake kittentts + soundfile module that return stub objects.""" fake_model = MagicMock() # 24kHz float32 PCM at ~2s of silence - fake_model.generate.return_value = np.zeros(48000, dtype=np.float32) + fake_model.generate.return_value = [0.0] * 48000 fake_cls = MagicMock(return_value=fake_model) fake_kittentts = MagicMock() fake_kittentts.KittenTTS = fake_cls diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py index 8a3866aaa8a3..d9274bb84d79 100644 --- a/tests/tools/test_tts_speed.py +++ b/tests/tools/test_tts_speed.py @@ -8,7 +8,12 @@ @pytest.fixture(autouse=True) def clean_env(monkeypatch): - for key in ("OPENAI_API_KEY", "MINIMAX_API_KEY", "HERMES_SESSION_PLATFORM"): + for key in ( + "OPENAI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_GROUP_ID", + "HERMES_SESSION_PLATFORM", + ): monkeypatch.delenv(key, raising=False) @@ -110,37 +115,126 @@ def test_speed_clamped_high(self, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# MiniMax TTS (new API: raw audio, no speed/voice_setting) +# MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting, +# JSON response with hex-encoded audio. Falls back to the legacy +# text_to_speech endpoint shape when the base_url points at it.) # --------------------------------------------------------------------------- -class TestMinimaxTtsSpeed: + +def _hex_response(payload_audio: bytes = b"\x00\x01\x02\x03"): + """Build a mock response shaped like a successful t2a_v2 reply.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = { + "data": {"audio": payload_audio.hex(), "status": 2}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + } + return mock_response + + +class TestMinimaxTtsT2aV2: + """Default path: base_url contains 't2a_v2'.""" + + def _run(self, tts_config, tmp_path, monkeypatch, response=None): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + resp = response if response is not None else _hex_response() + with patch("requests.post", return_value=resp) as mock_post: + from tools.tts_tool import _generate_minimax_tts + output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + return mock_post, output + + def test_nested_payload(self, tmp_path, monkeypatch): + """Default endpoint uses nested voice_setting / audio_setting.""" + mock_post, _ = self._run({}, tmp_path, monkeypatch) + payload = mock_post.call_args[1]["json"] + assert payload["model"] == "speech-02-hd" + assert payload["text"] == "Hello" + assert "voice_setting" in payload + assert payload["voice_setting"]["voice_id"] == "English_expressive_narrator" + assert "audio_setting" in payload + assert payload["audio_setting"]["format"] == "mp3" + # Don't send flat top-level voice_id alongside nested voice_setting. + assert "voice_id" not in payload + + def test_decodes_hex_audio(self, tmp_path, monkeypatch): + """t2a_v2 hex-encoded audio is decoded and written verbatim.""" + _, output = self._run({}, tmp_path, monkeypatch) + with open(output, "rb") as f: + assert f.read() == b"\x00\x01\x02\x03" + + def test_default_url_is_t2a_v2(self, tmp_path, monkeypatch): + """Default base URL points at the live t2a_v2 endpoint.""" + mock_post, _ = self._run({}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "t2a_v2" in url + assert "api.minimax.io" in url + + def test_group_id_from_config(self, tmp_path, monkeypatch): + """group_id from config attaches as ?GroupId=.""" + mock_post, _ = self._run({"minimax": {"group_id": "G123"}}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "GroupId=G123" in url + + def test_group_id_from_env(self, tmp_path, monkeypatch): + """MINIMAX_GROUP_ID env var attaches as ?GroupId=.""" + monkeypatch.setenv("MINIMAX_GROUP_ID", "G456") + mock_post, _ = self._run({}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "GroupId=G456" in url + + def test_group_id_already_in_url_left_alone(self, tmp_path, monkeypatch): + """If user already set GroupId in base_url, don't double-append it.""" + cfg = {"minimax": { + "base_url": "https://api.minimax.io/v1/t2a_v2?GroupId=PRESET", + "group_id": "IGNORED", + }} + mock_post, _ = self._run(cfg, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert url.count("GroupId=") == 1 + assert "GroupId=PRESET" in url + + def test_api_error_raises(self, tmp_path, monkeypatch): + """Non-zero base_resp.status_code surfaces as RuntimeError.""" + resp = MagicMock() + resp.status_code = 200 + resp.headers = {"Content-Type": "application/json"} + resp.json.return_value = { + "data": {"audio": "", "status": 1}, + "base_resp": {"status_code": 2013, "status_msg": "invalid voice"}, + } + with pytest.raises(RuntimeError, match="2013"): + self._run({}, tmp_path, monkeypatch, response=resp) + + +class TestMinimaxTtsLegacyTextToSpeech: + """Legacy path: caller pins base_url to the old text_to_speech endpoint.""" + + LEGACY_URL = "https://api.minimax.chat/v1/text_to_speech" + def _run(self, tts_config, tmp_path, monkeypatch): monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + cfg = dict(tts_config) + cfg.setdefault("minimax", {})["base_url"] = self.LEGACY_URL mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"Content-Type": "audio/mpeg"} mock_response.content = b"\x00\x01\x02\x03" - - # requests is imported locally inside _generate_minimax_tts with patch("requests.post", return_value=mock_response) as mock_post: from tools.tts_tool import _generate_minimax_tts - output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), cfg) return mock_post, output - def test_simple_payload(self, tmp_path, monkeypatch): - """New API uses flat payload with model, text, voice_id.""" + def test_flat_payload(self, tmp_path, monkeypatch): + """Legacy endpoint keeps the flat {model, text, voice_id} shape.""" mock_post, _ = self._run({}, tmp_path, monkeypatch) payload = mock_post.call_args[1]["json"] - assert "model" in payload - assert "text" in payload assert "voice_id" in payload assert "voice_setting" not in payload assert "audio_setting" not in payload - assert "stream" not in payload def test_writes_raw_audio(self, tmp_path, monkeypatch): - """New API returns raw bytes written directly to file.""" + """Legacy endpoint returns raw bytes written directly to file.""" _, output = self._run({}, tmp_path, monkeypatch) - assert output == str(tmp_path / "out.mp3") with open(output, "rb") as f: assert f.read() == b"\x00\x01\x02\x03" diff --git a/tests/tools/test_video_generation_dispatch.py b/tests/tools/test_video_generation_dispatch.py new file mode 100644 index 000000000000..36551acbe029 --- /dev/null +++ b/tests/tools/test_video_generation_dispatch.py @@ -0,0 +1,126 @@ +"""Tests for the unified ``video_generate`` tool dispatch surface.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +import pytest + +from agent import video_gen_registry +from agent.video_gen_provider import VideoGenProvider + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +class _RecordingProvider(VideoGenProvider): + """Captures the kwargs the tool layer hands it.""" + + def __init__(self, name: str = "fake"): + self._name = name + self.last_kwargs: Dict[str, Any] = {} + + @property + def name(self) -> str: + return self._name + + def list_models(self) -> List[Dict[str, Any]]: + return [{"id": "model-a"}] + + def default_model(self) -> Optional[str]: + return "model-a" + + def generate(self, prompt, **kwargs): + self.last_kwargs = {"prompt": prompt, **kwargs} + modality = "image" if kwargs.get("image_url") else "text" + return { + "success": True, + "video": "https://example.com/v.mp4", + "model": kwargs.get("model") or "model-a", + "prompt": prompt, + "modality": modality, + "aspect_ratio": kwargs.get("aspect_ratio", ""), + "duration": kwargs.get("duration") or 0, + "provider": self._name, + } + + +class _RaisingProvider(VideoGenProvider): + @property + def name(self) -> str: + return "raises" + + def generate(self, prompt, **kwargs): + raise RuntimeError("boom") + + +class TestUnifiedDispatch: + def _run(self, args: Dict[str, Any], *, configured: Optional[str] = None) -> Dict[str, Any]: + from tools import video_generation_tool + import hermes_cli.plugins as plugins_module + + saved = video_generation_tool._read_configured_video_provider + video_generation_tool._read_configured_video_provider = lambda: configured # type: ignore + saved_discover = plugins_module._ensure_plugins_discovered + plugins_module._ensure_plugins_discovered = lambda *_a, **_k: None # type: ignore + try: + raw = video_generation_tool._handle_video_generate(args) + finally: + video_generation_tool._read_configured_video_provider = saved # type: ignore + plugins_module._ensure_plugins_discovered = saved_discover # type: ignore + return json.loads(raw) + + def test_no_provider_returns_clear_error(self): + result = self._run({"prompt": "a dog"}) + assert result["success"] is False + assert result["error_type"] == "no_provider_configured" + + def test_unknown_provider_returns_clear_error(self): + result = self._run({"prompt": "a dog"}, configured="ghost") + assert result["success"] is False + assert result["error_type"] == "provider_not_registered" + + def test_text_to_video_routes_without_image_url(self): + provider = _RecordingProvider("rec") + video_gen_registry.register_provider(provider) + result = self._run({"prompt": "a happy dog"}) + assert result["success"] is True + assert result["modality"] == "text" + assert "image_url" not in provider.last_kwargs + assert provider.last_kwargs["aspect_ratio"] == "16:9" + assert provider.last_kwargs["resolution"] == "720p" + + def test_image_to_video_routes_with_image_url(self): + provider = _RecordingProvider("rec") + video_gen_registry.register_provider(provider) + result = self._run({ + "prompt": "animate this", + "image_url": "https://example.com/img.png", + }) + assert result["success"] is True + assert result["modality"] == "image" + assert provider.last_kwargs["image_url"] == "https://example.com/img.png" + + def test_prompt_required(self): + provider = _RecordingProvider("rec") + video_gen_registry.register_provider(provider) + result = self._run({"prompt": "", "image_url": "https://example.com/i.png"}) + assert "error" in result + assert "prompt" in result["error"].lower() + + def test_provider_exception_caught(self): + video_gen_registry.register_provider(_RaisingProvider()) + result = self._run({"prompt": "x"}) + assert result["success"] is False + assert result["error_type"] == "provider_exception" + + def test_operation_field_not_in_schema(self): + """Make sure we removed the operation field from the schema.""" + from tools.video_generation_tool import VIDEO_GENERATE_SCHEMA + assert "operation" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"] + assert "video_url" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"] diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py new file mode 100644 index 000000000000..590215468b59 --- /dev/null +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -0,0 +1,153 @@ +"""Tests for the dynamic schema builder under the simplified surface.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest +import yaml + +from agent import video_gen_registry +from agent.video_gen_provider import VideoGenProvider + + +@pytest.fixture(autouse=True) +def _reset_registry(): + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +@pytest.fixture +def cfg_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + return tmp_path + + +def _write_cfg(home, cfg: dict): + (home / "config.yaml").write_text(yaml.safe_dump(cfg)) + + +class _BothModalitiesProvider(VideoGenProvider): + """Supports both text-to-video AND image-to-video (the common case).""" + + @property + def name(self) -> str: + return "both" + + def is_available(self) -> bool: + return True + + def list_models(self) -> List[Dict[str, Any]]: + return [{"id": "family-a", "modalities": ["text", "image"]}] + + def default_model(self) -> Optional[str]: + return "family-a" + + def capabilities(self) -> Dict[str, Any]: + return { + "modalities": ["text", "image"], + "aspect_ratios": ["16:9", "9:16"], + "resolutions": ["720p", "1080p"], + "min_duration": 1, + "max_duration": 15, + "supports_audio": True, + "supports_negative_prompt": True, + "max_reference_images": 0, + } + + def generate(self, prompt, **kwargs): + return {"success": True} + + +class _ImageOnlyProvider(VideoGenProvider): + """Backend with only image-to-video support (rare but possible).""" + + @property + def name(self) -> str: + return "img-only" + + def is_available(self) -> bool: + return True + + def list_models(self) -> List[Dict[str, Any]]: + return [{"id": "img-only-v1", "modalities": ["image"]}] + + def default_model(self) -> Optional[str]: + return "img-only-v1" + + def capabilities(self) -> Dict[str, Any]: + return {"modalities": ["image"], "min_duration": 1, "max_duration": 10} + + def generate(self, prompt, **kwargs): + return {"success": True} + + +class TestDynamicSchemaBuilder: + def test_no_config_says_so(self, cfg_home): + from tools.video_generation_tool import _build_dynamic_video_schema + + desc = _build_dynamic_video_schema()["description"] + assert "No video backend is configured" in desc + assert "hermes tools" in desc + + def test_does_not_mention_edit_or_extend(self, cfg_home): + """The simplified surface only does text→video and image→video. + The description must not mention edit/extend anywhere.""" + from tools.video_generation_tool import _build_dynamic_video_schema, _GENERIC_DESCRIPTION + + desc = _build_dynamic_video_schema()["description"] + # Block words that would suggest functionality we removed + assert "edit" not in desc.lower() or "audio" in desc.lower() # 'audio' contains 'audi' not 'edit' + # Stronger: no occurrence of the words "edit" or "extend" as standalone + for forbidden in (" edit ", " edits ", " extend ", " extends "): + assert forbidden not in desc.lower(), f"description leaks '{forbidden.strip()}'" + # Sanity: the generic blurb itself is also clean + for forbidden in ("edit", "extend"): + assert forbidden not in _GENERIC_DESCRIPTION.lower() + + def test_both_modalities_advertises_auto_routing(self, cfg_home): + from tools.video_generation_tool import _build_dynamic_video_schema + + _write_cfg(cfg_home, {"video_gen": {"provider": "both"}}) + video_gen_registry.register_provider(_BothModalitiesProvider()) + + import hermes_cli.plugins as plugins_module + saved = plugins_module._ensure_plugins_discovered + plugins_module._ensure_plugins_discovered = lambda *a, **k: None + try: + desc = _build_dynamic_video_schema()["description"] + finally: + plugins_module._ensure_plugins_discovered = saved + + assert "Active backend: Both" in desc + assert "text-to-video" in desc and "image-to-video" in desc + assert "routes automatically" in desc + # operations bullet is gone + assert "operations supported" not in desc + + def test_image_only_model_warns_about_required_image_url(self, cfg_home): + from tools.video_generation_tool import _build_dynamic_video_schema + + _write_cfg(cfg_home, {"video_gen": {"provider": "img-only"}}) + video_gen_registry.register_provider(_ImageOnlyProvider()) + + import hermes_cli.plugins as plugins_module + saved = plugins_module._ensure_plugins_discovered + plugins_module._ensure_plugins_discovered = lambda *a, **k: None + try: + desc = _build_dynamic_video_schema()["description"] + finally: + plugins_module._ensure_plugins_discovered = saved + + assert "image-to-video only" in desc + assert "image_url is REQUIRED" in desc + + def test_builder_wired_into_registry(self): + from tools.registry import discover_builtin_tools, registry + + discover_builtin_tools() + entry = registry._tools["video_generate"] + assert entry.dynamic_schema_overrides is not None + out = entry.dynamic_schema_overrides() + assert "description" in out diff --git a/tests/tools/test_video_generation_tool_surface_matrix.py b/tests/tools/test_video_generation_tool_surface_matrix.py new file mode 100644 index 000000000000..7fe9efefbd6a --- /dev/null +++ b/tests/tools/test_video_generation_tool_surface_matrix.py @@ -0,0 +1,253 @@ +"""Tool-surface routing matrix: every (provider, model, modality) combo. + +This is the integration test for the question Teknium asked: regardless +of which provider+model the user picks and whether they pass an +image_url or not, does the tool surface route correctly to the right +endpoint with the right payload shape? + +Drives ``_handle_video_generate(args)`` end-to-end — config write → +config read → registry lookup → provider.generate() → outbound HTTP/SDK +call. Stubs fal_client and httpx so we observe routing without hitting +the network. +""" + +from __future__ import annotations + +import asyncio +import json +import types +from typing import Any, Dict, List, Optional + +import pytest +import yaml + + +@pytest.fixture(autouse=True) +def _reset_registry(): + from agent import video_gen_registry + video_gen_registry._reset_for_tests() + yield + video_gen_registry._reset_for_tests() + + +@pytest.fixture +def matrix_env(tmp_path, monkeypatch): + """Set up HERMES_HOME, stub fal_client + httpx, force plugin discovery.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("FAL_KEY", "test-key") + monkeypatch.setenv("XAI_API_KEY", "test-key") + + fal_calls: List[Dict[str, Any]] = [] + xai_calls: List[Dict[str, Any]] = [] + + # fal_client stub + fake_fal = types.ModuleType("fal_client") + def _subscribe(endpoint, arguments=None, with_logs=False): + fal_calls.append({"endpoint": endpoint, "arguments": arguments}) + return {"video": {"url": f"https://fake-fal/{endpoint.replace('/','_')}.mp4"}} + fake_fal.subscribe = _subscribe # type: ignore + monkeypatch.setitem(__import__("sys").modules, "fal_client", fake_fal) + + # httpx stub for xAI + import httpx + class _Resp: + def __init__(self, p, s=200): + self.status_code = s + self._p = p + self.text = json.dumps(p) + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError("err", request=None, response=self) # type: ignore + def json(self): + return self._p + class _Client: + async def __aenter__(self): return self + async def __aexit__(self, *a): return None + async def post(self, url, headers=None, json=None, timeout=None): + xai_calls.append({"url": url, "json": json}) + return _Resp({"request_id": "req-1"}) + async def get(self, url, headers=None, timeout=None): + return _Resp({ + "status": "done", + "video": {"url": "https://xai-cdn/out.mp4", "duration": 8}, + "model": "grok-imagine-video", + }) + import plugins.video_gen.xai as xai_plugin + monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", lambda: _Client()) + async def _no_sleep(*a, **k): return None + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + + # Reset FAL plugin's lazy fal_client cache so it picks up the stub + from plugins.video_gen import fal as fal_plugin + fal_plugin._fal_client = None + + # Force discovery + from hermes_cli.plugins import _ensure_plugins_discovered + _ensure_plugins_discovered(force=True) + + return tmp_path, fal_calls, xai_calls + + +def _invoke_tool(home, cfg: dict, args: dict) -> dict: + """Write config, invoke the registered tool handler, return parsed JSON.""" + (home / "config.yaml").write_text(yaml.safe_dump(cfg)) + import hermes_cli.config as cfg_mod + if hasattr(cfg_mod, "_invalidate_load_config_cache"): + cfg_mod._invalidate_load_config_cache() + + from tools.registry import registry + handler = registry._tools["video_generate"].handler + return json.loads(handler(args)) + + +# ───────────────────────────────────────────────────────────────────────── +# FAL: every family × {text-only, text+image} +# ───────────────────────────────────────────────────────────────────────── + +# We parametrize over the catalog so the test discovers new families +# automatically. If someone adds 'sora-2' to FAL_FAMILIES, this matrix +# picks it up — no test changes needed beyond confirming the endpoints. +def _all_fal_families(): + from plugins.video_gen.fal import FAL_FAMILIES + return list(FAL_FAMILIES.keys()) + + +@pytest.mark.parametrize("family_id", _all_fal_families()) +def test_fal_text_only_routes_to_text_endpoint(matrix_env, family_id): + home, fal_calls, _ = matrix_env + from plugins.video_gen.fal import FAL_FAMILIES + + result = _invoke_tool( + home, + {"video_gen": {"provider": "fal", "model": family_id}}, + {"prompt": "a dog running"}, + ) + + assert result["success"] is True, f"{family_id}: {result.get('error')}" + assert result["modality"] == "text" + assert result["provider"] == "fal" + + # Outbound endpoint must be the family's text endpoint + assert len(fal_calls) == 1 + endpoint = fal_calls[0]["endpoint"] + assert endpoint == FAL_FAMILIES[family_id]["text_endpoint"] + + # Payload must NOT contain any image-shaped key + payload = fal_calls[0]["arguments"] or {} + image_keys = [k for k in payload if "image" in k and "url" in k] + assert not image_keys, f"{family_id} text-only leaked image keys: {image_keys}" + + +@pytest.mark.parametrize("family_id", _all_fal_families()) +def test_fal_text_plus_image_routes_to_image_endpoint(matrix_env, family_id): + home, fal_calls, _ = matrix_env + from plugins.video_gen.fal import FAL_FAMILIES + + result = _invoke_tool( + home, + {"video_gen": {"provider": "fal", "model": family_id}}, + {"prompt": "animate this dog", "image_url": "https://example.com/dog.png"}, + ) + + assert result["success"] is True, f"{family_id}: {result.get('error')}" + assert result["modality"] == "image" + assert result["provider"] == "fal" + + # Outbound endpoint must be the family's image endpoint + assert len(fal_calls) == 1 + endpoint = fal_calls[0]["endpoint"] + assert endpoint == FAL_FAMILIES[family_id]["image_endpoint"] + + # Payload must contain the right image key (may be image_url or + # start_image_url depending on the family's image_param_key) + payload = fal_calls[0]["arguments"] or {} + expected_image_key = FAL_FAMILIES[family_id].get("image_param_key") or "image_url" + assert payload.get(expected_image_key) == "https://example.com/dog.png", ( + f"{family_id} text+image missing {expected_image_key} in payload " + f"(keys: {sorted(payload.keys())})" + ) + + +# ───────────────────────────────────────────────────────────────────────── +# xAI: text-only / text+image both go to /videos/generations +# (xAI uses one endpoint with an optional 'image' field, not separate URLs) +# ───────────────────────────────────────────────────────────────────────── + +def test_xai_text_only_via_tool_surface(matrix_env): + home, _, xai_calls = matrix_env + + result = _invoke_tool( + home, + {"video_gen": {"provider": "xai"}}, + {"prompt": "a dog running"}, + ) + assert result["success"] is True + assert result["modality"] == "text" + assert result["provider"] == "xai" + + assert len(xai_calls) == 1 + assert xai_calls[0]["url"].endswith("/videos/generations") + payload = xai_calls[0]["json"] or {} + assert "image" not in payload + assert "reference_images" not in payload + + +def test_xai_text_plus_image_via_tool_surface(matrix_env): + home, _, xai_calls = matrix_env + + result = _invoke_tool( + home, + {"video_gen": {"provider": "xai"}}, + {"prompt": "animate this", "image_url": "https://example.com/img.png"}, + ) + assert result["success"] is True + assert result["modality"] == "image" + assert result["provider"] == "xai" + + assert len(xai_calls) == 1 + assert xai_calls[0]["url"].endswith("/videos/generations") + payload = xai_calls[0]["json"] or {} + assert payload["image"] == {"url": "https://example.com/img.png"} + + +# ───────────────────────────────────────────────────────────────────────── +# tool-level `model` arg overrides config +# ───────────────────────────────────────────────────────────────────────── + +def test_tool_model_arg_overrides_config(matrix_env): + """When the tool call passes model=, it wins over video_gen.model in config.""" + home, fal_calls, _ = matrix_env + + # Config picks pixverse-v6, but tool call says veo3.1 + result = _invoke_tool( + home, + {"video_gen": {"provider": "fal", "model": "pixverse-v6"}}, + {"prompt": "a dog", "model": "veo3.1"}, + ) + + assert result["success"] is True + assert result["model"] == "veo3.1" + # Outbound endpoint reflects the override, not config + assert fal_calls[0]["endpoint"] == "fal-ai/veo3.1" + + +def test_tool_model_arg_with_image_url_routes_to_override_image_endpoint(matrix_env): + """model= override on text+image goes to the override family's image endpoint.""" + home, fal_calls, _ = matrix_env + + result = _invoke_tool( + home, + {"video_gen": {"provider": "fal", "model": "pixverse-v6"}}, + { + "prompt": "animate this", + "image_url": "https://example.com/i.png", + "model": "kling-v3-4k", + }, + ) + + assert result["success"] is True + assert result["model"] == "kling-v3-4k" + assert fal_calls[0]["endpoint"] == "fal-ai/kling-video/v3/4k/image-to-video" + # Kling 4K uses start_image_url + assert fal_calls[0]["arguments"].get("start_image_url") == "https://example.com/i.png" + assert "image_url" not in fal_calls[0]["arguments"] diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 3c0abb307b06..67d39e9a999e 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -20,50 +20,121 @@ class TestWebProviderABCs: - """The ABCs enforce the interface contract.""" + """The unified WebSearchProvider ABC enforces the interface contract. - def test_cannot_instantiate_search_provider(self): - from tools.web_providers.base import WebSearchProvider + After PR #25182, all seven providers are subclasses of + :class:`agent.web_search_provider.WebSearchProvider`. The legacy + in-tree ABCs at ``tools.web_providers.base`` (separate + ``WebSearchProvider`` + ``WebExtractProvider``) were deleted in the + same PR — providers now advertise capabilities via + ``supports_search() / supports_extract() / supports_crawl()`` flags. + """ - with pytest.raises(TypeError): - WebSearchProvider() # type: ignore[abstract] - - def test_cannot_instantiate_extract_provider(self): - from tools.web_providers.base import WebExtractProvider + def test_cannot_instantiate_abc_directly(self): + from agent.web_search_provider import WebSearchProvider with pytest.raises(TypeError): - WebExtractProvider() # type: ignore[abstract] + WebSearchProvider() # type: ignore[abstract] - def test_concrete_search_provider_works(self): - from tools.web_providers.base import WebSearchProvider + def test_concrete_search_only_provider_works(self): + from agent.web_search_provider import WebSearchProvider class Dummy(WebSearchProvider): - def provider_name(self) -> str: + @property + def name(self) -> str: return "dummy" - def is_configured(self) -> bool: + + @property + def display_name(self) -> str: + return "Dummy Search" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: return True + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: return {"success": True, "data": {"web": []}} d = Dummy() - assert d.provider_name() == "dummy" - assert d.is_configured() is True + assert d.name == "dummy" + assert d.display_name == "Dummy Search" + assert d.is_available() is True + assert d.supports_search() is True + assert d.supports_extract() is False # default + assert d.supports_crawl() is False # default assert d.search("test")["success"] is True - def test_concrete_extract_provider_works(self): - from tools.web_providers.base import WebExtractProvider + def test_concrete_multi_capability_provider_works(self): + from agent.web_search_provider import WebSearchProvider - class Dummy(WebExtractProvider): - def provider_name(self) -> str: + class Dummy(WebSearchProvider): + @property + def name(self) -> str: return "dummy" - def is_configured(self) -> bool: + + @property + def display_name(self) -> str: + return "Dummy Multi" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: return True - def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: - return {"success": True, "data": [{"url": urls[0], "content": "x"}]} + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + return {"success": True, "data": {"web": []}} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + return [{"url": urls[0], "content": "x"}] + + def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + return {"results": [{"url": url, "content": "x"}]} d = Dummy() - assert d.provider_name() == "dummy" - assert d.extract(["https://example.com"])["success"] is True + assert d.supports_search() is True + assert d.supports_extract() is True + assert d.supports_crawl() is True + assert d.extract(["https://example.com"])[0]["url"] == "https://example.com" + assert d.crawl("https://example.com")["results"][0]["url"] == "https://example.com" + + def test_search_only_provider_skips_extract_and_crawl(self): + """Search-only providers don't have to implement extract() / crawl().""" + from agent.web_search_provider import WebSearchProvider + + class SearchOnly(WebSearchProvider): + @property + def name(self) -> str: + return "search-only" + + @property + def display_name(self) -> str: + return "Search Only" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + return {"success": True, "data": {"web": []}} + + # Should instantiate fine — extract/crawl have default + # supports_*() returning False and aren't required to be + # overridden when not advertised. + s = SearchOnly() + assert s.supports_search() is True + assert s.supports_extract() is False + assert s.supports_crawl() is False # --------------------------------------------------------------------------- @@ -192,3 +263,72 @@ def tracking_get_search(): assert len(called_with) > 0 assert called_with[0][0] == "search" + + +class TestUnconfiguredErrorEnvelopeParity: + """Regression tests for PR #25182: the post-migration dispatcher must + emit the same top-level error envelope as pre-migration main when no + web backend is configured. + + Plugin-level error wrapping is correct for in-flight errors (per-page + SDK exceptions, scrape timeouts) but PRE-FLIGHT configuration errors + must surface at the top level so function-calling models that check + ``result.get("error")`` detect the failure cleanly. + """ + + def _clear_web_creds(self, monkeypatch): + for k in ( + "BRAVE_SEARCH_API_KEY", + "SEARXNG_URL", + "TAVILY_API_KEY", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + ): + monkeypatch.delenv(k, raising=False) + + def test_unconfigured_search_emits_top_level_error(self, monkeypatch): + """``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}`` + — matching main's ``tool_error()`` envelope, not a per-result shape. + """ + import json + from tools import web_tools + + self._clear_web_creds(monkeypatch) + # Reset firecrawl client cache so the unconfigured state is re-evaluated + monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False) + monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + + result = json.loads(web_tools.web_search_tool("hello world", limit=3)) + assert "error" in result, f"expected top-level 'error' key, got {result}" + # ``Error searching web:`` prefix comes from web_tools' top-level except handler + assert "Error searching web:" in result["error"] + assert "FIRECRAWL_API_KEY" in result["error"] + # No per-result burying + assert "results" not in result + + def test_unconfigured_crawl_emits_top_level_error(self, monkeypatch): + """``web_crawl_tool`` with no creds returns ``{"success": False, "error": "web_crawl requires Firecrawl..."}`` + — the dispatcher gates on ``provider.is_available()`` BEFORE + delegating to the plugin so pre-config errors don't get wrapped + into ``results[]``. + """ + import asyncio + import json + from tools import web_tools + + self._clear_web_creds(monkeypatch) + monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False) + monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + + result = json.loads(asyncio.run(web_tools.web_crawl_tool("https://example.com", use_llm_processing=False))) + assert result.get("success") is False + assert "error" in result, f"expected top-level 'error' key, got {result}" + assert "web_crawl requires Firecrawl" in result["error"] + # Crucially: no per-page burying + assert "results" not in result diff --git a/tests/tools/test_web_providers_brave_free.py b/tests/tools/test_web_providers_brave_free.py index 36fe41640e8c..f441bf0f8b4d 100644 --- a/tests/tools/test_web_providers_brave_free.py +++ b/tests/tools/test_web_providers_brave_free.py @@ -1,8 +1,8 @@ """Tests for the Brave Search (free tier) web search provider. Covers: -- BraveFreeSearchProvider.is_configured() env var gating -- BraveFreeSearchProvider.search() — happy path, HTTP error, request error, bad JSON +- BraveFreeWebSearchProvider.is_available() env var gating +- BraveFreeWebSearchProvider.search() — happy path, HTTP error, request error, bad JSON - Result normalization (title, url, description, position) - Limit truncation + Brave's count cap (20) - _is_backend_available("brave-free") integration @@ -17,34 +17,34 @@ # --------------------------------------------------------------------------- -# BraveFreeSearchProvider unit tests +# BraveFreeWebSearchProvider unit tests # --------------------------------------------------------------------------- class TestBraveFreeProviderIsConfigured: def test_configured_when_key_set(self, monkeypatch): monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider - assert BraveFreeSearchProvider().is_configured() is True + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + assert BraveFreeWebSearchProvider().is_available() is True def test_not_configured_when_key_missing(self, monkeypatch): monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False) - from tools.web_providers.brave_free import BraveFreeSearchProvider - assert BraveFreeSearchProvider().is_configured() is False + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + assert BraveFreeWebSearchProvider().is_available() is False def test_not_configured_when_key_whitespace(self, monkeypatch): monkeypatch.setenv("BRAVE_SEARCH_API_KEY", " ") - from tools.web_providers.brave_free import BraveFreeSearchProvider - assert BraveFreeSearchProvider().is_configured() is False + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + assert BraveFreeWebSearchProvider().is_available() is False def test_provider_name(self): - from tools.web_providers.brave_free import BraveFreeSearchProvider - assert BraveFreeSearchProvider().provider_name() == "brave-free" + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + assert BraveFreeWebSearchProvider().name == "brave-free" def test_implements_web_search_provider(self): - from tools.web_providers.base import WebSearchProvider - from tools.web_providers.brave_free import BraveFreeSearchProvider - assert issubclass(BraveFreeSearchProvider, WebSearchProvider) + from agent.web_search_provider import WebSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider + assert issubclass(BraveFreeWebSearchProvider, WebSearchProvider) class TestBraveFreeProviderSearch: @@ -68,10 +68,10 @@ def _mock_resp(json_data, status_code=200): def test_happy_path_normalizes_results(self, monkeypatch): monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider with patch("httpx.get", return_value=self._mock_resp(self._SAMPLE_RESPONSE)): - result = BraveFreeSearchProvider().search("test query", limit=5) + result = BraveFreeWebSearchProvider().search("test query", limit=5) assert result["success"] is True web = result["data"]["web"] @@ -82,7 +82,7 @@ def test_happy_path_normalizes_results(self, monkeypatch): def test_sends_subscription_token_header_and_count(self, monkeypatch): """Brave uses X-Subscription-Token; count maps from limit.""" monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider captured = {} @@ -93,7 +93,7 @@ def fake_get(url, **kwargs): return self._mock_resp({"web": {"results": []}}) with patch("httpx.get", side_effect=fake_get): - BraveFreeSearchProvider().search("q", limit=5) + BraveFreeWebSearchProvider().search("q", limit=5) assert captured["url"] == "https://api.search.brave.com/res/v1/web/search" assert captured["headers"].get("X-Subscription-Token") == "BSAkey123" @@ -103,7 +103,7 @@ def fake_get(url, **kwargs): def test_count_is_capped_at_20(self, monkeypatch): """Brave caps count at 20 — limit above that clamps.""" monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider captured = {} @@ -112,26 +112,26 @@ def fake_get(url, **kwargs): return self._mock_resp({"web": {"results": []}}) with patch("httpx.get", side_effect=fake_get): - BraveFreeSearchProvider().search("q", limit=100) + BraveFreeWebSearchProvider().search("q", limit=100) assert captured["params"].get("count") == 20 def test_limit_is_respected_client_side(self, monkeypatch): monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider with patch("httpx.get", return_value=self._mock_resp(self._SAMPLE_RESPONSE)): - result = BraveFreeSearchProvider().search("q", limit=2) + result = BraveFreeWebSearchProvider().search("q", limit=2) assert result["success"] is True assert len(result["data"]["web"]) == 2 def test_empty_results(self, monkeypatch): monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider with patch("httpx.get", return_value=self._mock_resp({"web": {"results": []}})): - result = BraveFreeSearchProvider().search("nothing", limit=5) + result = BraveFreeWebSearchProvider().search("nothing", limit=5) assert result["success"] is True assert result["data"]["web"] == [] @@ -139,10 +139,10 @@ def test_empty_results(self, monkeypatch): def test_missing_web_key_returns_empty(self, monkeypatch): """Responses without a ``web`` block should produce an empty result set, not crash.""" monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider with patch("httpx.get", return_value=self._mock_resp({})): - result = BraveFreeSearchProvider().search("q", limit=5) + result = BraveFreeWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"] == [] @@ -150,14 +150,14 @@ def test_missing_web_key_returns_empty(self, monkeypatch): def test_http_error_returns_failure(self, monkeypatch): import httpx monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider bad = MagicMock() bad.status_code = 429 err = httpx.HTTPStatusError("429", request=MagicMock(), response=bad) with patch("httpx.get", side_effect=err): - result = BraveFreeSearchProvider().search("q", limit=5) + result = BraveFreeWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "429" in result["error"] @@ -165,19 +165,19 @@ def test_http_error_returns_failure(self, monkeypatch): def test_request_error_returns_failure(self, monkeypatch): import httpx monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider with patch("httpx.get", side_effect=httpx.RequestError("boom")): - result = BraveFreeSearchProvider().search("q", limit=5) + result = BraveFreeWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "boom" in result["error"] or "Brave" in result["error"] def test_missing_key_returns_failure(self, monkeypatch): monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False) - from tools.web_providers.brave_free import BraveFreeSearchProvider + from plugins.web.brave_free.provider import BraveFreeWebSearchProvider - result = BraveFreeSearchProvider().search("q", limit=5) + result = BraveFreeWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "BRAVE_SEARCH_API_KEY" in result["error"] diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 9a3ceec73722..d575fe63e364 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -1,8 +1,8 @@ """Tests for the DuckDuckGo (ddgs) web search provider. Covers: -- DDGSSearchProvider.is_configured() — reflects package importability -- DDGSSearchProvider.search() — happy path, missing package, runtime error +- DDGSWebSearchProvider.is_available() — reflects package importability +- DDGSWebSearchProvider.search() — happy path, missing package, runtime error - Result normalization (title, url, description, position) - _is_backend_available("ddgs") / _get_backend() integration - web_extract / web_crawl return search-only errors when ddgs is active @@ -40,21 +40,21 @@ def text(self, query, max_results=5): # --------------------------------------------------------------------------- -# DDGSSearchProvider unit tests +# DDGSWebSearchProvider unit tests # --------------------------------------------------------------------------- class TestDDGSProviderIsConfigured: def test_configured_when_package_importable(self, monkeypatch): _install_fake_ddgs(monkeypatch) - # Drop any cached ``tools.web_providers.ddgs`` so is_configured re-imports ddgs fresh - monkeypatch.delitem(sys.modules, "tools.web_providers.ddgs", raising=False) - from tools.web_providers.ddgs import DDGSSearchProvider - assert DDGSSearchProvider().is_configured() is True + # Drop any cached ``plugins.web.ddgs.provider`` so is_configured re-imports ddgs fresh + monkeypatch.delitem(sys.modules, "plugins.web.ddgs.provider", raising=False) + from plugins.web.ddgs.provider import DDGSWebSearchProvider + assert DDGSWebSearchProvider().is_available() is True def test_not_configured_when_package_missing(self, monkeypatch): monkeypatch.delitem(sys.modules, "ddgs", raising=False) - monkeypatch.delitem(sys.modules, "tools.web_providers.ddgs", raising=False) + monkeypatch.delitem(sys.modules, "plugins.web.ddgs.provider", raising=False) # Block the import so ``import ddgs`` raises ImportError even if the package is actually installed import builtins orig_import = builtins.__import__ @@ -65,17 +65,17 @@ def blocked_import(name, *args, **kwargs): return orig_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", blocked_import) - from tools.web_providers.ddgs import DDGSSearchProvider - assert DDGSSearchProvider().is_configured() is False + from plugins.web.ddgs.provider import DDGSWebSearchProvider + assert DDGSWebSearchProvider().is_available() is False def test_provider_name(self): - from tools.web_providers.ddgs import DDGSSearchProvider - assert DDGSSearchProvider().provider_name() == "ddgs" + from plugins.web.ddgs.provider import DDGSWebSearchProvider + assert DDGSWebSearchProvider().name == "ddgs" def test_implements_web_search_provider(self): - from tools.web_providers.base import WebSearchProvider - from tools.web_providers.ddgs import DDGSSearchProvider - assert issubclass(DDGSSearchProvider, WebSearchProvider) + from agent.web_search_provider import WebSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider + assert issubclass(DDGSWebSearchProvider, WebSearchProvider) class TestDDGSProviderSearch: @@ -85,9 +85,9 @@ def test_happy_path_normalizes_results(self, monkeypatch): {"title": "B", "href": "https://b.example.com", "body": "desc B"}, {"title": "C", "href": "https://c.example.com", "body": "desc C"}, ]) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("q", limit=5) + result = DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True web = result["data"]["web"] @@ -99,9 +99,9 @@ def test_accepts_url_key_as_fallback_for_href(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_results=[ {"title": "A", "url": "https://a.example.com", "body": "desc A"}, ]) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("q", limit=5) + result = DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"][0]["url"] == "https://a.example.com" @@ -111,16 +111,16 @@ def test_limit_is_respected(self, monkeypatch): {"title": f"R{i}", "href": f"https://r{i}.example.com", "body": ""} for i in range(10) ]) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("q", limit=3) + result = DDGSWebSearchProvider().search("q", limit=3) assert result["success"] is True assert len(result["data"]["web"]) == 3 def test_missing_package_returns_failure(self, monkeypatch): monkeypatch.delitem(sys.modules, "ddgs", raising=False) - monkeypatch.delitem(sys.modules, "tools.web_providers.ddgs", raising=False) + monkeypatch.delitem(sys.modules, "plugins.web.ddgs.provider", raising=False) import builtins orig_import = builtins.__import__ @@ -130,25 +130,25 @@ def blocked_import(name, *args, **kwargs): return orig_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", blocked_import) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("q", limit=5) + result = DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "ddgs" in result["error"].lower() def test_runtime_error_returns_failure(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_raises=RuntimeError("rate limited 202")) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("q", limit=5) + result = DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "rate limited" in result["error"] or "failed" in result["error"].lower() def test_empty_results(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_results=[]) - from tools.web_providers.ddgs import DDGSSearchProvider + from plugins.web.ddgs.provider import DDGSWebSearchProvider - result = DDGSSearchProvider().search("nothing", limit=5) + result = DDGSWebSearchProvider().search("nothing", limit=5) assert result["success"] is True assert result["data"]["web"] == [] diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py index 4779ed6ce6ea..d579fb0d0a63 100644 --- a/tests/tools/test_web_providers_searxng.py +++ b/tests/tools/test_web_providers_searxng.py @@ -1,8 +1,8 @@ """Tests for the SearXNG web search provider. Covers: -- SearXNGSearchProvider.is_configured() env var gating -- SearXNGSearchProvider.search() — happy path, HTTP error, request error, bad JSON +- SearXNGWebSearchProvider.is_available() env var gating +- SearXNGWebSearchProvider.search() — happy path, HTTP error, request error, bad JSON - Result normalization (title, url, description, position) - Score-based sorting and limit truncation - _is_backend_available("searxng") integration @@ -19,38 +19,38 @@ # --------------------------------------------------------------------------- -# SearXNGSearchProvider unit tests +# SearXNGWebSearchProvider unit tests # --------------------------------------------------------------------------- class TestSearXNGSearchProviderIsConfigured: def test_configured_when_url_set(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider - assert SearXNGSearchProvider().is_configured() is True + from plugins.web.searxng.provider import SearXNGWebSearchProvider + assert SearXNGWebSearchProvider().is_available() is True def test_not_configured_when_url_missing(self, monkeypatch): monkeypatch.delenv("SEARXNG_URL", raising=False) - from tools.web_providers.searxng import SearXNGSearchProvider - assert SearXNGSearchProvider().is_configured() is False + from plugins.web.searxng.provider import SearXNGWebSearchProvider + assert SearXNGWebSearchProvider().is_available() is False def test_not_configured_when_url_empty_string(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", " ") - from tools.web_providers.searxng import SearXNGSearchProvider - assert SearXNGSearchProvider().is_configured() is False + from plugins.web.searxng.provider import SearXNGWebSearchProvider + assert SearXNGWebSearchProvider().is_available() is False def test_provider_name(self): - from tools.web_providers.searxng import SearXNGSearchProvider - assert SearXNGSearchProvider().provider_name() == "searxng" + from plugins.web.searxng.provider import SearXNGWebSearchProvider + assert SearXNGWebSearchProvider().name == "searxng" def test_implements_web_search_provider(self): - from tools.web_providers.base import WebSearchProvider - from tools.web_providers.searxng import SearXNGSearchProvider - assert issubclass(SearXNGSearchProvider, WebSearchProvider) + from agent.web_search_provider import WebSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider + assert issubclass(SearXNGWebSearchProvider, WebSearchProvider) class TestSearXNGSearchProviderSearch: - """Happy path and error handling for SearXNGSearchProvider.search().""" + """Happy path and error handling for SearXNGWebSearchProvider.search().""" _SAMPLE_RESPONSE = { "results": [ @@ -69,11 +69,11 @@ def _make_mock_response(self, json_data, status_code=200): def test_happy_path_returns_normalized_results(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("test query", limit=5) + result = SearXNGWebSearchProvider().search("test query", limit=5) assert result["success"] is True web = result["data"]["web"] @@ -86,7 +86,7 @@ def test_happy_path_returns_normalized_results(self, monkeypatch): def test_results_sorted_by_score_descending(self, monkeypatch): """Results should be sorted by score before limit is applied.""" monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider unordered = { "results": [ {"title": "Low", "url": "https://low.example.com", "content": "", "score": 0.1}, @@ -97,7 +97,7 @@ def test_results_sorted_by_score_descending(self, monkeypatch): mock_resp = self._make_mock_response(unordered) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) assert result["success"] is True assert result["data"]["web"][0]["title"] == "High" @@ -106,33 +106,33 @@ def test_results_sorted_by_score_descending(self, monkeypatch): def test_limit_is_respected(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("query", limit=2) + result = SearXNGWebSearchProvider().search("query", limit=2) assert result["success"] is True assert len(result["data"]["web"]) == 2 def test_position_is_one_indexed(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) positions = [r["position"] for r in result["data"]["web"]] assert positions == [1, 2, 3] def test_empty_results(self, monkeypatch): monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = self._make_mock_response({"results": []}) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("nothing", limit=5) + result = SearXNGWebSearchProvider().search("nothing", limit=5) assert result["success"] is True assert result["data"]["web"] == [] @@ -140,7 +140,7 @@ def test_empty_results(self, monkeypatch): def test_missing_score_falls_back_to_zero(self, monkeypatch): """Results without a score field should sort to the bottom.""" monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider data = { "results": [ {"title": "No score", "url": "https://noscore.example.com", "content": ""}, @@ -150,7 +150,7 @@ def test_missing_score_falls_back_to_zero(self, monkeypatch): mock_resp = self._make_mock_response(data) with patch("httpx.get", return_value=mock_resp): - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) assert result["success"] is True # Has score should sort first (0.8 > 0) @@ -159,14 +159,14 @@ def test_missing_score_falls_back_to_zero(self, monkeypatch): def test_http_error_returns_failure(self, monkeypatch): import httpx monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = MagicMock() mock_resp.status_code = 500 http_err = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_resp) with patch("httpx.get", side_effect=http_err): - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) assert result["success"] is False assert "500" in result["error"] @@ -174,26 +174,26 @@ def test_http_error_returns_failure(self, monkeypatch): def test_request_error_returns_failure(self, monkeypatch): import httpx monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider with patch("httpx.get", side_effect=httpx.RequestError("connection refused")): - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) assert result["success"] is False assert "localhost:8080" in result["error"] or "connection" in result["error"].lower() def test_missing_url_returns_failure(self, monkeypatch): monkeypatch.delenv("SEARXNG_URL", raising=False) - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider - result = SearXNGSearchProvider().search("query", limit=5) + result = SearXNGWebSearchProvider().search("query", limit=5) assert result["success"] is False assert "SEARXNG_URL" in result["error"] def test_trailing_slash_stripped_from_url(self, monkeypatch): """Base URL trailing slash should not produce double-slash in endpoint.""" monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080/") - from tools.web_providers.searxng import SearXNGSearchProvider + from plugins.web.searxng.provider import SearXNGWebSearchProvider mock_resp = self._make_mock_response({"results": []}) calls = [] @@ -202,7 +202,7 @@ def capture_get(url, **kwargs): return mock_resp with patch("httpx.get", side_effect=capture_get): - SearXNGSearchProvider().search("query", limit=5) + SearXNGWebSearchProvider().search("query", limit=5) assert calls[0] == "http://localhost:8080/search", f"Got: {calls[0]}" diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 25ef647f7c0d..87fc27cc3728 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -485,15 +485,28 @@ def test_registered_handler_defaults_limit_to_five(self): def test_web_search_clamps_limit_before_backend_call(self): import tools.web_tools - with patch("tools.web_tools._get_backend", return_value="parallel"), \ - patch("tools.web_tools._parallel_search", return_value={"success": True, "data": {"web": []}}) as mock_search, \ + # After the web-provider plugin migration, _parallel_search lives in + # plugins.web.parallel.provider.ParallelWebSearchProvider.search; the + # tool dispatcher resolves a provider from the registry and calls + # provider.search(query, limit). Mock the provider lookup so we can + # assert the limit is clamped before reaching the backend. + fake_search = MagicMock(return_value={"success": True, "data": {"web": []}}) + fake_provider = MagicMock( + name="ParallelWebSearchProvider", + supports_search=MagicMock(return_value=True), + ) + fake_provider.search = fake_search + fake_provider.name = "parallel" + + with patch("tools.web_tools._get_search_backend", return_value="parallel"), \ + patch("agent.web_search_registry.get_provider", return_value=fake_provider), \ patch("tools.interrupt.is_interrupted", return_value=False), \ patch.object(tools.web_tools._debug, "log_call"), \ patch.object(tools.web_tools._debug, "save"): result = json.loads(tools.web_tools.web_search_tool("docs", limit=500)) assert result == {"success": True, "data": {"web": []}} - mock_search.assert_called_once_with("docs", 100) + fake_search.assert_called_once_with("docs", 100) class TestWebSearchErrorHandling: @@ -502,11 +515,19 @@ class TestWebSearchErrorHandling: def test_search_error_response_does_not_expose_diagnostics(self): import tools.web_tools - firecrawl_client = MagicMock() - firecrawl_client.search.side_effect = RuntimeError("boom") - - with patch("tools.web_tools._get_backend", return_value="firecrawl"), \ - patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \ + # After the web-provider plugin migration, the firecrawl client lives + # at plugins.web.firecrawl.provider._get_firecrawl_client. We mock the + # registry's get_provider to return a fake provider whose .search() + # raises so we can verify error sanitization. + fake_provider = MagicMock( + name="FirecrawlWebSearchProvider", + supports_search=MagicMock(return_value=True), + ) + fake_provider.search.side_effect = RuntimeError("boom") + fake_provider.name = "firecrawl" + + with patch("tools.web_tools._get_search_backend", return_value="firecrawl"), \ + patch("agent.web_search_registry.get_provider", return_value=fake_provider), \ patch("tools.interrupt.is_interrupted", return_value=False), \ patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \ patch.object(tools.web_tools._debug, "save"): diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 4573e0276508..0e734cbae787 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -350,11 +350,16 @@ def test_browser_navigate_allows_when_shared_file_missing(monkeypatch, tmp_path) @pytest.mark.asyncio async def test_web_extract_short_circuits_blocked_url(monkeypatch): from tools import web_tools + from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + # The per-URL website-policy gate moved into the firecrawl plugin's + # extract() during the web-provider migration. Patch it at the new + # location; the dispatcher-level gate (used by web_crawl_tool's + # pre-flight) still lives on tools.web_tools. monkeypatch.setattr( - web_tools, + firecrawl_provider, "check_website_access", lambda url: { "host": "blocked.test", @@ -364,11 +369,13 @@ async def test_web_extract_short_circuits_blocked_url(monkeypatch): }, ) monkeypatch.setattr( - web_tools, + firecrawl_provider, "_get_firecrawl_client", lambda: pytest.fail("firecrawl should not run for blocked URL"), ) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + # Force the firecrawl plugin to be the active extract provider. + monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") result = json.loads(await web_tools.web_extract_tool(["https://blocked.test"], use_llm_processing=False)) @@ -398,6 +405,7 @@ def test_check_website_access_fails_open_on_malformed_config(tmp_path, monkeypat @pytest.mark.asyncio async def test_web_extract_blocks_redirected_final_url(monkeypatch): from tools import web_tools + from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) @@ -424,9 +432,12 @@ def scrape(self, url, formats): }, } - monkeypatch.setattr(web_tools, "check_website_access", fake_check) - monkeypatch.setattr(web_tools, "_get_firecrawl_client", lambda: FakeFirecrawlClient()) + # After the web-provider migration, the per-URL gate + firecrawl client + # live in the plugin. Patch both at the plugin location. + monkeypatch.setattr(firecrawl_provider, "check_website_access", fake_check) + monkeypatch.setattr(firecrawl_provider, "_get_firecrawl_client", lambda: FakeFirecrawlClient()) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") result = json.loads(await web_tools.web_extract_tool(["https://allowed.test"], use_llm_processing=False)) @@ -443,6 +454,9 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + # The dispatcher-level (seed-URL) policy gate still lives on web_tools. + # No per-page gate runs in this test because the dispatcher returns + # immediately when the seed is blocked, before delegating to the plugin. monkeypatch.setattr( web_tools, "check_website_access", @@ -453,10 +467,13 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): "message": "Blocked by website policy", }, ) + # If the dispatcher ever reaches the firecrawl plugin's crawl(), the test + # fails — pin the plugin module's client lookup so we'd notice. + from plugins.web.firecrawl import provider as firecrawl_provider monkeypatch.setattr( - web_tools, + firecrawl_provider, "_get_firecrawl_client", - lambda: pytest.fail("firecrawl should not run for blocked crawl URL"), + lambda: pytest.fail("firecrawl plugin should not run for blocked crawl URL"), ) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) @@ -469,13 +486,17 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): @pytest.mark.asyncio async def test_web_crawl_blocks_redirected_final_url(monkeypatch): from tools import web_tools + from plugins.web.firecrawl import provider as firecrawl_provider - # web_crawl_tool checks for Firecrawl env before website policy + # Force the firecrawl plugin to be the active crawl provider. monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) def fake_check(url): + # Dispatcher seed-URL gate (web_tools.check_website_access call) + # and plugin per-page gate (firecrawl_provider.check_website_access + # call) both flow through this single fake_check. if url == "https://allowed.test": return None if url == "https://blocked.test/final": @@ -501,8 +522,13 @@ def crawl(self, url, **kwargs): ] } + # After PR #25182 follow-up: per-page policy gate lives in + # plugins.web.firecrawl.provider.crawl(). Patch the gate + client at + # the plugin location. The dispatcher-level (seed) gate also reads + # web_tools.check_website_access — patch both. monkeypatch.setattr(web_tools, "check_website_access", fake_check) - monkeypatch.setattr(web_tools, "_get_firecrawl_client", lambda: FakeCrawlClient()) + monkeypatch.setattr(firecrawl_provider, "check_website_access", fake_check) + monkeypatch.setattr(firecrawl_provider, "_get_firecrawl_client", lambda: FakeCrawlClient()) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) result = json.loads(await web_tools.web_crawl_tool("https://allowed.test", use_llm_processing=False)) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 40ba7cab25c5..575beba6c026 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1873,7 +1873,13 @@ def _run_browser_command( # - Ubuntu 23.10+ / AppArmor systems: unprivileged user namespaces # are restricted, causing Chromium to exit with "No usable sandbox" # even for non-root users running under systemd or containers. - if "AGENT_BROWSER_CHROME_FLAGS" not in browser_env: + # Honour either the legacy AGENT_BROWSER_CHROME_FLAGS (never consumed by + # agent-browser itself, but documented in older notes) or the real + # AGENT_BROWSER_ARGS — if the user pre-sets either, don't overwrite it. + if ( + "AGENT_BROWSER_ARGS" not in browser_env + and "AGENT_BROWSER_CHROME_FLAGS" not in browser_env + ): _needs_sandbox_bypass = False if hasattr(os, "geteuid") and os.geteuid() == 0: _needs_sandbox_bypass = True @@ -1892,8 +1898,8 @@ def _run_browser_command( except OSError: pass if _needs_sandbox_bypass: - browser_env["AGENT_BROWSER_CHROME_FLAGS"] = ( - "--no-sandbox --disable-dev-shm-usage" + browser_env["AGENT_BROWSER_ARGS"] = ( + "--no-sandbox,--disable-dev-shm-usage" ) # Use temp files for stdout/stderr instead of pipes. @@ -3381,8 +3387,8 @@ def _chromium_installed() -> bool: 1. ``AGENT_BROWSER_EXECUTABLE_PATH`` env var — the official way to point agent-browser at a pre-installed Chrome/Chromium. - 2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium-browser``, - ``chrome``). + 2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium``, + ``chromium-browser``, ``chrome``). 3. Playwright's browser cache (current logic) — directories containing ``chromium-*`` or ``chromium_headless_shell-*``. @@ -3405,7 +3411,12 @@ def _chromium_installed() -> bool: return True # 2. System Chrome/Chromium in PATH (common names) - system_chrome = shutil.which("google-chrome") or shutil.which("chromium-browser") or shutil.which("chrome") + system_chrome = ( + shutil.which("google-chrome") + or shutil.which("chromium") + or shutil.which("chromium-browser") + or shutil.which("chrome") + ) if system_chrome: _cached_chromium_installed = True return True diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b2c02aedaf8a..f4da5127a18b 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1017,7 +1017,18 @@ def _child_thinking(text: str) -> None: effective_provider = override_provider or getattr(parent_agent, "provider", None) effective_base_url = override_base_url or parent_agent.base_url effective_api_key = override_api_key or parent_api_key - effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None) + # Bug #20558 / PR #20563: api_mode must NOT be inherited when the child uses a + # different provider than the parent — each provider has its own API surface + # (e.g. MiniMax uses anthropic_messages, DeepSeek uses chat_completions). + # Inheriting the parent's mode causes 404 errors when the child routes to the + # wrong endpoint. Derive the mode from the target provider when it differs. + _parent_provider = getattr(parent_agent, "provider", None) or "" + if override_api_mode is not None: + effective_api_mode = override_api_mode + elif effective_provider != _parent_provider: + effective_api_mode = None # force re-derivation from provider's defaults + else: + effective_api_mode = getattr(parent_agent, "api_mode", None) effective_acp_command = override_acp_command or getattr( parent_agent, "acp_command", None ) diff --git a/tools/file_operations.py b/tools/file_operations.py index 4b64421622fc..13d9314b9120 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -909,19 +909,29 @@ def write_file(self, path: str, content: str) -> WriteResult: if _is_write_denied(path): return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.") - # Capture pre-write content for lint-delta computation. Only do this - # when an in-process OR shell linter exists for this extension — no - # point paying for the read otherwise. For in-process linters we - # pass the content directly; for shell linters the pre-state isn't - # useful (we'd have to re-write-read to lint the old version, which - # defeats the purpose), so we skip the capture and accept the naive - # "all errors" report. + # Capture pre-write content. Two consumers want it: + # + # 1. The lint-delta layer (for in-process linters like ast.parse + # and json.loads) needs the previous content to compute the + # set of NEW lint errors introduced by this write. + # 2. The LSP layer needs pre/post content to build a line-shift + # map — pre-existing diagnostics below the edit point shift + # when lines are added/removed, and the shift map remaps + # baseline diagnostics into post-edit coordinates so the + # strict (range-aware) delta key matches. + # + # The set of extensions we capture pre_content for is therefore + # the UNION of in-process lint coverage and LSP coverage. For + # extensions outside both sets (binaries, opaque formats), + # skipping the read keeps the hot path fast. ext = os.path.splitext(path)[1].lower() pre_content: Optional[str] = None - if ext in LINTERS_INPROC: + want_pre = ext in LINTERS_INPROC or self._lsp_handles_extension(ext) + if want_pre: # Best-effort read; failure (file missing, permission) leaves - # pre_content as None which makes the delta step degrade - # gracefully to "report all errors". + # pre_content as None which makes both downstream consumers + # degrade gracefully (lint reports all errors; LSP skips the + # shift map). read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null" read_result = self._exec(read_cmd) if read_result.exit_code == 0 and read_result.stdout: @@ -966,11 +976,15 @@ def write_file(self, path: str, content: str) -> WriteResult: # Semantic diagnostics from the LSP layer — separate channel. # Only fired when the syntax tier reported clean (no point asking - # an LSP for a file that won't even parse). Best-effort: - # ``""`` is returned for any failure path. + # an LSP for a file that won't even parse). Pass pre/post + # content so the LSP layer can build a line-shift map and + # remap baseline diagnostics into post-edit coordinates. + # Best-effort: ``""`` is returned for any failure path. lsp_diagnostics: Optional[str] = None if lint_result.success or lint_result.skipped: - block = self._maybe_lsp_diagnostics(path) + block = self._maybe_lsp_diagnostics( + path, pre_content=pre_content, post_content=content + ) if block: lsp_diagnostics = block @@ -1295,6 +1309,29 @@ def _lsp_local_only(self) -> bool: return False return isinstance(env, LocalEnvironment) + def _lsp_handles_extension(self, ext: str) -> bool: + """Return True iff some registered LSP server claims this extension. + + Used to decide whether to capture pre-write content for the + line-shift map. Capturing is cheap (one ``cat`` on the host) + but pointless if no LSP would ever look at the file. + + Safe to call on remote backends — the registry is purely + in-process metadata; we still gate the actual LSP path on + :meth:`_lsp_local_only`. + """ + if not ext: + return False + try: + from agent.lsp.servers import SERVERS + except Exception: # noqa: BLE001 + return False + ext_lower = ext.lower() + for srv in SERVERS: + if ext_lower in srv.extensions: + return True + return False + def _snapshot_lsp_baseline(self, path: str) -> None: """Capture pre-edit LSP diagnostics so the post-write delta is correct. @@ -1318,12 +1355,25 @@ def _snapshot_lsp_baseline(self, path: str) -> None: except Exception: # noqa: BLE001 pass - def _maybe_lsp_diagnostics(self, path: str) -> str: + def _maybe_lsp_diagnostics( + self, + path: str, + *, + pre_content: Optional[str] = None, + post_content: Optional[str] = None, + ) -> str: """Best-effort LSP semantic diagnostics for ``path``. Returns a formatted ```` block, or empty string when LSP is unavailable / disabled / produced no errors. + When both ``pre_content`` and ``post_content`` are provided, + a line-shift map is built and passed to the LSPService so + baseline diagnostics are remapped into post-edit coordinates + before the set-difference. Without this, edits that delete + or insert lines surface every pre-existing diagnostic below + the edit point as "introduced by this edit". + Wraps everything in a try/except so a misbehaving LSP server can't break a write. This intentionally swallows all errors — the calling tier already returned a clean syntax result, so @@ -1344,8 +1394,20 @@ def _maybe_lsp_diagnostics(self, path: str) -> str: return "" if svc is None or not svc.enabled_for(path): return "" + + # Build a line-shift map when we have both pre and post — it + # remaps baseline diagnostics into post-edit coordinates so + # the strict (range-aware) delta key matches correctly. + line_shift = None + if pre_content is not None and post_content is not None and pre_content != post_content: + try: + from agent.lsp.range_shift import build_line_shift + line_shift = build_line_shift(pre_content, post_content) + except Exception: # noqa: BLE001 + line_shift = None + try: - diagnostics = svc.get_diagnostics_sync(path, delta=True) + diagnostics = svc.get_diagnostics_sync(path, delta=True, line_shift=line_shift) except Exception: # noqa: BLE001 return "" if not diagnostics: diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 6e298c23320c..09347e8281c5 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -59,7 +59,7 @@ import sys from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional logger = logging.getLogger(__name__) @@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str: return m.group(1) if m else spec +def _specifier_from_spec(spec: str) -> str: + """Extract just the version-specifier portion of a pip spec. + + ``"honcho-ai==2.0.1"`` → ``"==2.0.1"`` + ``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"`` + ``"package"`` → ``""`` (no version constraint) + """ + # Strip the package name + optional [extras] block. + m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec) + if not m: + return "" + return spec[m.end():] + + def _is_satisfied(spec: str) -> bool: - """Best-effort check: is ``spec`` already satisfied in the current env? + """Is ``spec`` already satisfied in the current env? + + Checks both presence AND version. If the package is installed at a + version outside the spec's range, returns False so the caller will + upgrade/downgrade to the pinned version. This is what makes + ``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already- + installed backends instead of silently leaving stale versions in place. - 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. + If ``packaging`` is unavailable for any reason (it's a transitive of + pip so this should never happen), we fall back to a presence-only check + so we err on the side of "don't churn". + """ + pkg = _pkg_name_from_spec(spec) + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: + return False + try: + installed = version(pkg) + except PackageNotFoundError: + return False + except Exception: + return False + + spec_tail = _specifier_from_spec(spec) + if not spec_tail: + # Bare ``"package"`` — no version constraint, presence is enough. + return True + + try: + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version + except ImportError: + # packaging unavailable — fall back to "installed counts as satisfied". + return True + + try: + return Version(installed) in SpecifierSet(spec_tail) + except (InvalidSpecifier, InvalidVersion, Exception): + # Malformed spec or installed version we can't parse — don't churn. + return True + + +def _is_present(spec: str) -> bool: + """Cheap presence-only check (package name installed at any version). + + Used by :func:`active_features` to detect backends the user has + previously activated, regardless of whether the version pin moved. """ pkg = _pkg_name_from_spec(spec) try: @@ -440,3 +497,107 @@ def feature_install_command(feature: str) -> Optional[str]: return None specs = LAZY_DEPS[feature] return "uv pip install " + " ".join(repr(s) for s in specs) + + +def active_features() -> list[str]: + """Return the list of features the user has ever lazy-installed. + + A feature counts as "active" if at least one of its declared packages + is currently installed in the venv (presence check, ignoring version). + Features the user has never enabled stay quiet. + + Used by ``hermes update`` to figure out which lazy backends need a + refresh pass when pins move in :data:`LAZY_DEPS`. + """ + active = [] + for feature, specs in LAZY_DEPS.items(): + if any(_is_present(s) for s in specs): + active.append(feature) + return active + + +def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: + """Re-run ``ensure`` for every feature the user has previously activated. + + Returns a ``{feature: status}`` map where status is one of: + ``"current"`` — pins already satisfied, no install run + ``"refreshed"`` — pins were stale, reinstall succeeded + ``"failed: "`` — install attempt failed; caller decides + whether to surface it (we don't raise) + ``"skipped: "`` — gated off (config flag, user decline) + + Intended for ``hermes update``. Never raises; lazy-install failures + here must not block the rest of the update flow. + """ + results: dict[str, str] = {} + for feature in active_features(): + missing = feature_missing(feature) + if not missing: + results[feature] = "current" + continue + try: + ensure(feature, prompt=prompt) + results[feature] = "refreshed" + except FeatureUnavailable as e: + # Distinguish "user opted out" from "install failed" so the + # update command can render the right message. + if "lazy installs disabled" in str(e) or "declined" in str(e): + results[feature] = f"skipped: {e.reason}" + else: + results[feature] = f"failed: {e.reason}" + except Exception as e: + results[feature] = f"failed: {e}" + return results + + +def ensure_and_bind( + feature: str, + importer: Callable[[], dict[str, Any]], + target_globals: dict, + *, + prompt: bool = False, +) -> bool: + """Ensure a feature is installed, then rebind names into the caller's globals. + + Combines :func:`ensure` with a post-install import step that rebinds + module-level names. This eliminates the error-prone pattern of manually + listing every global that needs updating after lazy-install. + + ``importer`` is a zero-arg callable that returns a dict of + ``{name: value}`` for all symbols the caller needs rebound. It is called + only after :func:`ensure` succeeds (or if the packages are already + installed). + + Returns True on success, False if deps couldn't be installed or imported. + + Example usage in a platform adapter:: + + def check_slack_requirements() -> bool: + if SLACK_AVAILABLE: + return True + def _import(): + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient + import aiohttp + return { + "AsyncApp": AsyncApp, + "AsyncSocketModeHandler": AsyncSocketModeHandler, + "AsyncWebClient": AsyncWebClient, + "aiohttp": aiohttp, + "SLACK_AVAILABLE": True, + } + return ensure_and_bind("platform.slack", _import, globals(), prompt=False) + """ + try: + ensure(feature, prompt=prompt) + except (FeatureUnavailable, Exception): + return False + + try: + bindings = importer() + except ImportError: + return False + + target_globals.update(bindings) + return True diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 1e10b276f1e8..ee1843043dc5 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1499,6 +1499,16 @@ async def run(self, config: dict): # should not permanently kill the server. # (Ported from Kilo Code's MCP resilience fix.) if not self._ready.is_set(): + if _is_auth_error(exc): + logger.warning( + "MCP server '%s' failed initial OAuth authentication, " + "not retrying automatically: %s", + self.name, exc, + ) + self._error = exc + self._ready.set() + return + initial_retries += 1 if initial_retries > _MAX_INITIAL_CONNECT_RETRIES: logger.warning( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 32296729fe24..0fcd449b80bc 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -956,49 +956,83 @@ def skill_view( skill_dir = None skill_md = None - # Search all dirs: local first, then external (first match wins) + # Collision detection: collect ALL candidates across every dir using + # every lookup strategy (direct path, recursive by parent dir name, + # legacy flat .md). If more than one matches, refuse and tell + # the caller — silent shadowing of a local skill by a same-named + # external skill is a real bug class (`/skills` shows one, agent + # loaded the other) so we surface it loudly instead of guessing. + from agent.skill_utils import iter_skill_index_files + + candidates: List[Tuple[Optional[Path], Path]] = [] # (skill_dir, skill_md) + seen_md: set = set() + + def _record(sd: Optional[Path], smd: Path) -> None: + try: + key = smd.resolve() + except Exception: + key = smd + if key in seen_md: + return + seen_md.add(key) + candidates.append((sd, smd)) + for search_dir in all_dirs: - # Try direct path first (e.g., "mlops/axolotl") + # Strategy 1: direct path (e.g., "mlops/axolotl" or bare "axolotl" + # at the top of the dir). direct_path = search_dir / name if direct_path.is_dir() and (direct_path / "SKILL.md").exists(): - skill_dir = direct_path - skill_md = direct_path / "SKILL.md" - break + _record(direct_path, direct_path / "SKILL.md") elif direct_path.with_suffix(".md").exists(): - skill_md = direct_path.with_suffix(".md") - break + _record(None, direct_path.with_suffix(".md")) + + # Strategy 1b: categorized form for plugin namespace fall-through + # (e.g., a "myplugin:explore" name with no plugin registered also + # tries the on-disk path "myplugin/explore"). if local_category_name: categorized_path = search_dir / local_category_name if categorized_path.is_dir() and (categorized_path / "SKILL.md").exists(): - skill_dir = categorized_path - skill_md = categorized_path / "SKILL.md" - break + _record(categorized_path, categorized_path / "SKILL.md") elif categorized_path.with_suffix(".md").exists(): - skill_md = categorized_path.with_suffix(".md") - break - - # Search by directory name across all dirs - if not skill_md: - for search_dir in all_dirs: - from agent.skill_utils import iter_skill_index_files - - for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"): - if found_skill_md.parent.name == name: - skill_dir = found_skill_md.parent - skill_md = found_skill_md - break - if skill_md: - break + _record(None, categorized_path.with_suffix(".md")) + + # Strategy 2: recursive by directory name (catches nested skills + # like "foundations/runtime/explore-codebase" called by bare name). + for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"): + if found_skill_md.parent.name == name: + _record(found_skill_md.parent, found_skill_md) + + # Strategy 3: legacy flat .md files anywhere under the dir. + for found_md in search_dir.rglob(f"{name}.md"): + if found_md.name != "SKILL.md": + _record(None, found_md) + + if len(candidates) > 1: + paths = [str(smd) for _, smd in candidates] + logging.getLogger(__name__).warning( + "Skill name collision for '%s': %d candidates — %s", + name, len(candidates), "; ".join(paths), + ) + return json.dumps( + { + "success": False, + "error": ( + f"Ambiguous skill name '{name}': {len(candidates)} skills " + "match across your local skills dir and external_dirs. " + "Refusing to guess — load one explicitly by its categorized path." + ), + "matches": paths, + "hint": ( + "Pass the full relative path instead of the bare name " + "(e.g., 'category/skill-name'), or rename one of the " + "colliding skills so each name is unique." + ), + }, + ensure_ascii=False, + ) - # Legacy: flat .md files - if not skill_md: - for search_dir in all_dirs: - for found_md in search_dir.rglob(f"{name}.md"): - if found_md.name != "SKILL.md": - skill_md = found_md - break - if skill_md: - break + if candidates: + skill_dir, skill_md = candidates[0] if not skill_md or not skill_md.exists(): available = [s["name"] for s in _sort_skills(_find_all_skills())[:20]] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 4d8512c345ef..e0d07e80f6e4 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1544,9 +1544,29 @@ def _command_requires_pipe_stdin(command: str) -> bool: ) -_SHELL_LEVEL_BACKGROUND_RE = re.compile(r"\b(?:nohup|disown|setsid)\b", re.IGNORECASE) +_SHELL_LEVEL_BACKGROUND_RE = re.compile( + r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE +) _INLINE_BACKGROUND_AMP_RE = re.compile(r"\s&\s") _TRAILING_BACKGROUND_AMP_RE = re.compile(r"\s&\s*(?:#.*)?$") + + +def _strip_quotes(command: str) -> str: + """Remove single- and double-quoted content so regex checks don't match inside strings. + + This prevents false positives when keywords like 'nohup' or 'setsid' appear + in commit messages, Python -c code, echo arguments, or PR body text. + Also strips backtick-quoted content and heredoc-style inline text. + """ + # Remove single-quoted strings (no escaping inside single quotes in shell) + result = re.sub(r"'[^']*'", "''", command) + # Remove double-quoted strings (handle escaped quotes) + result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result) + # Remove backtick-quoted strings + result = re.sub(r"`[^`]*`", "``", result) + return result + + _LONG_LIVED_FOREGROUND_PATTERNS = ( re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)\b", re.IGNORECASE), re.compile(r"\bdocker\s+compose\s+up\b", re.IGNORECASE), @@ -1579,21 +1599,25 @@ def _foreground_background_guidance(command: str) -> str | None: if _looks_like_help_or_version_command(command): return None - if _SHELL_LEVEL_BACKGROUND_RE.search(command): + # Strip quoted content so keywords inside strings/arguments don't trigger + # false positives (e.g., git commit -m "... setsid ...", python3 -c "os.setsid"). + unquoted = _strip_quotes(command) + + if _SHELL_LEVEL_BACKGROUND_RE.search(unquoted): return ( "Foreground command uses shell-level background wrappers (nohup/disown/setsid). " "Use terminal(background=true) so Hermes can track the process, then run " "readiness checks and tests in separate commands." ) - if _INLINE_BACKGROUND_AMP_RE.search(command) or _TRAILING_BACKGROUND_AMP_RE.search(command): + if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted): return ( "Foreground command uses '&' backgrounding. Use terminal(background=true) for long-lived " "processes, then run health checks and tests in follow-up terminal calls." ) for pattern in _LONG_LIVED_FOREGROUND_PATTERNS: - if pattern.search(command): + if pattern.search(unquoted): return ( "This foreground command appears to start a long-lived server/watch process. " "Run it with background=true, verify readiness (health endpoint/log signal), " diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 5009947895c4..942fba01120c 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -505,7 +505,13 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] language=shlex.quote(language), model=shlex.quote(normalized_model), ) - subprocess.run(command, shell=True, check=True, capture_output=True, text=True) + # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode. + use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) + if use_shell: + subprocess.run(command, shell=True, check=True, capture_output=True, text=True) + else: + subprocess.run(shlex.split(command), check=True, capture_output=True, text=True) + txt_files = sorted(Path(output_dir).glob("*.txt")) if not txt_files: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 1ea3ba21c635..9f0d272dac05 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -159,9 +159,9 @@ def _import_piper(): DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality DEFAULT_OPENAI_VOICE = "alloy" DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" -DEFAULT_MINIMAX_MODEL = "speech-01" -DEFAULT_MINIMAX_VOICE_ID = "female-shaonv" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech" +DEFAULT_MINIMAX_MODEL = "speech-02-hd" +DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator" +DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2" DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603" DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral DEFAULT_XAI_VOICE_ID = "eve" @@ -960,11 +960,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - # =========================================================================== def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: """ - Generate audio using MiniMax TTS API (v1/text_to_speech). + Generate audio using MiniMax TTS API. - The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload - and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike - the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio. + Supports two endpoints: + - v1/text_to_speech: simple payload, returns raw audio (Content-Type: audio/mpeg) + - v1/t2a_v2: nested voice_setting/audio_setting, returns JSON with hex-encoded audio Args: text: Text to convert (max 10,000 characters). @@ -984,56 +984,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any model = mm_config.get("model", DEFAULT_MINIMAX_MODEL) voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID) base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL) - - payload = { - "model": model, - "text": text, - "voice_id": voice_id, - } + speed = mm_config.get("speed", 1.0) + vol = mm_config.get("vol", 1.0) + pitch = mm_config.get("pitch", 0) + emotion = mm_config.get("emotion", "neutral") + sample_rate = mm_config.get("sample_rate", 32000) + bitrate = mm_config.get("bitrate", 128000) + + # MiniMax accounts scope TTS requests by GroupId. When present, the docs + # show it as a ?GroupId= query param on the t2a_v2 URL. Accept it + # from config or from the MINIMAX_GROUP_ID env var; only attach when the + # URL doesn't already carry one. + group_id = ( + str(mm_config.get("group_id") or "").strip() + or (get_env_value("MINIMAX_GROUP_ID") or "").strip() + ) + if group_id and "GroupId=" not in base_url: + sep = "&" if "?" in base_url else "?" + base_url = f"{base_url}{sep}GroupId={group_id}" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", } - response = requests.post(base_url, json=payload, headers=headers, timeout=60) + # Detect endpoint from URL + is_t2a_v2 = "t2a_v2" in base_url - content_type = response.headers.get("Content-Type", "") + if is_t2a_v2: + # t2a_v2 endpoint: nested voice_setting/audio_setting structure + payload = { + "model": model, + "text": text, + "voice_setting": { + "voice_id": voice_id, + "speed": speed, + "vol": vol, + "pitch": pitch, + "emotion": emotion, + }, + "audio_setting": { + "sample_rate": sample_rate, + "bitrate": bitrate, + "format": "mp3", + "channel": 1, + }, + } + else: + # text_to_speech endpoint: flat payload + payload = { + "model": model, + "text": text, + "voice_id": voice_id, + } - if "audio/" in content_type: - # New API: returns raw audio directly - with open(output_path, "wb") as f: - f.write(response.content) - return output_path + response = requests.post(base_url, json=payload, headers=headers, timeout=60) - # Legacy / fallback: try parsing as JSON with hex-encoded audio - try: + if is_t2a_v2: + # t2a_v2 returns JSON with hex-encoded audio result = response.json() - except Exception: - response.raise_for_status() - raise RuntimeError( - f"MiniMax TTS returned unexpected Content-Type '{content_type}' " - f"({len(response.content)} bytes)" - ) + base_resp = result.get("base_resp", {}) + status_code = base_resp.get("status_code", -1) - base_resp = result.get("base_resp", {}) - status_code = base_resp.get("status_code", -1) + if status_code != 0: + status_msg = base_resp.get("status_msg", "unknown error") + raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") - if status_code != 0: - status_msg = base_resp.get("status_msg", "unknown error") - raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") + hex_audio = result.get("data", {}).get("audio", "") + if not hex_audio: + raise RuntimeError("MiniMax TTS returned empty audio data") - hex_audio = result.get("data", {}).get("audio", "") - if not hex_audio: - raise RuntimeError("MiniMax TTS returned empty audio data") + audio_bytes = bytes.fromhex(hex_audio) + with open(output_path, "wb") as f: + f.write(audio_bytes) + return output_path - # Legacy: hex-encoded audio - audio_bytes = bytes.fromhex(hex_audio) + else: + # text_to_speech returns raw audio directly + content_type = response.headers.get("Content-Type", "") - with open(output_path, "wb") as f: - f.write(audio_bytes) + if "audio/" in content_type: + with open(output_path, "wb") as f: + f.write(response.content) + return output_path - return output_path + # Fallback: try parsing as JSON + try: + result = response.json() + base_resp = result.get("base_resp", {}) + status_code = base_resp.get("status_code", -1) + if status_code != 0: + status_msg = base_resp.get("status_msg", "unknown error") + raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") + except Exception: + response.raise_for_status() + raise RuntimeError( + f"MiniMax TTS returned unexpected Content-Type '{content_type}' " + f"({len(response.content)} bytes)" + ) + + raise RuntimeError("MiniMax TTS returned no audio data") # =========================================================================== diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py new file mode 100644 index 000000000000..63d80165dc01 --- /dev/null +++ b/tools/video_generation_tool.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +""" +Video Generation Tool +===================== + +Single ``video_generate`` tool that dispatches to a plugin-registered +video generation provider. Mirrors the ``image_generate`` design: + +- ``agent/video_gen_provider.py`` defines the :class:`VideoGenProvider` ABC. +- ``agent/video_gen_registry.py`` holds the active providers (populated by + plugins at import time). +- Each provider lives under ``plugins/video_gen//``. + +The tool itself is intentionally backend-agnostic and ships **no in-tree +provider** — turn on a backend by enabling a plugin (``hermes plugins +enable video_gen/``) and selecting it in ``hermes tools`` → Video +Generation. + +Unified surface +--------------- +One tool covers the common cases — text-to-video, image-to-video, video +edit, video extend — with a compact schema: + + prompt text instruction (required for generate/edit) + operation "generate" | "edit" | "extend" + image_url drives image-to-video when operation=generate + video_url source video for edit/extend + reference_image_urls list, up to provider-declared cap + duration seconds (provider clamps) + aspect_ratio "16:9" | "9:16" | "1:1" | ... + resolution "480p" | "540p" | "720p" | "1080p" + negative_prompt optional (Pixverse/Kling style) + audio optional (Veo3/Pixverse pricing tier) + seed optional + model optional, override the active provider's default + +Providers ignore parameters they do not support. The tool layer does +**lightweight** validation (type/required-prompt) and lets each provider +do its own clamping inside :meth:`VideoGenProvider.generate` — that keeps +the tool surface stable as new providers ship with different capabilities. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional + +from agent.video_gen_provider import ( + COMMON_ASPECT_RATIOS, + COMMON_RESOLUTIONS, + DEFAULT_ASPECT_RATIO, + DEFAULT_RESOLUTION, + error_response, +) +from tools.registry import registry, tool_error + +logger = logging.getLogger(__name__) + + +VIDEO_GENERATE_SCHEMA: Dict[str, Any] = { + "name": "video_generate", + # Placeholder — the real description is built dynamically at + # get_tool_definitions() time so it reflects the active backend's + # actual capabilities (which modalities / resolutions / duration + # ranges the user's currently-selected model supports). + # See _build_dynamic_video_schema() below and the dynamic-tool-schemas + # skill at github/hermes-agent-dev/references/dynamic-tool-schemas.md. + "description": "(rebuilt at get_definitions() time — see _build_dynamic_video_schema)", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": ( + "Text instruction describing the desired video, motion, " + "subject, style, camera movement, etc." + ), + }, + "image_url": { + "type": "string", + "description": ( + "Optional public URL of a still image. When provided, " + "the active backend routes to its image-to-video " + "endpoint (animate the image); when omitted, it routes " + "to text-to-video. Pass either a URL the user supplied " + "or a path/URL from the conversation." + ), + }, + "reference_image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional list of reference image URLs (style or " + "character refs). Only supported by some backends; " + "the active backend's description below indicates whether " + "this is honored and what the max is." + ), + }, + "duration": { + "type": "integer", + "description": ( + "Desired video duration in seconds. Providers clamp to " + "their supported range (commonly 4-15s). Omit to use the " + "provider's default." + ), + }, + "aspect_ratio": { + "type": "string", + "enum": list(COMMON_ASPECT_RATIOS), + "description": ( + "Output aspect ratio. Providers clamp to their supported " + "set." + ), + "default": DEFAULT_ASPECT_RATIO, + }, + "resolution": { + "type": "string", + "enum": list(COMMON_RESOLUTIONS), + "description": ( + "Output resolution. Providers clamp to their supported " + "set." + ), + "default": DEFAULT_RESOLUTION, + }, + "negative_prompt": { + "type": "string", + "description": ( + "Optional negative prompt — content to avoid in the " + "output. Supported by Pixverse, Kling, and similar; " + "ignored by providers that do not support it." + ), + }, + "audio": { + "type": "boolean", + "description": ( + "Optional audio generation toggle. Supported by Veo3 and " + "Pixverse (affects pricing tier); ignored elsewhere." + ), + }, + "seed": { + "type": "integer", + "description": ( + "Optional seed for reproducible outputs (provider-" + "dependent)." + ), + }, + "model": { + "type": "string", + "description": ( + "Optional model override. If omitted, the user's " + "configured ``video_gen.model`` (set via `hermes tools` " + "→ Video Generation) is used. Models that the active " + "provider does not know are rejected." + ), + }, + }, + "required": ["prompt"], + }, +} + + +# --------------------------------------------------------------------------- +# Config readers (mirror image_generation_tool.py) +# --------------------------------------------------------------------------- + + +def _read_video_gen_section() -> Dict[str, Any]: + try: + from hermes_cli.config import load_config + + cfg = load_config() + section = cfg.get("video_gen") if isinstance(cfg, dict) else None + return section if isinstance(section, dict) else {} + except Exception as exc: + logger.debug("Could not read video_gen config: %s", exc) + return {} + + +def _read_configured_video_provider() -> Optional[str]: + value = _read_video_gen_section().get("provider") + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _read_configured_video_model() -> Optional[str]: + value = _read_video_gen_section().get("model") + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +# --------------------------------------------------------------------------- +# Availability check +# --------------------------------------------------------------------------- + + +def check_video_generation_requirements() -> bool: + """Return True when at least one registered provider reports available. + + Triggers plugin discovery (idempotent) so user-installed plugins are + visible to the toolset gate. + """ + try: + from agent.video_gen_registry import list_providers + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + for provider in list_providers(): + try: + if provider.is_available(): + return True + except Exception: + continue + except Exception: + pass + return False + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + + +def _resolve_active_provider(): + """Return the active provider object or None. + + Forces plugin discovery before checking the registry — handles cases + where a long-lived session was started before a plugin was installed. + """ + try: + from agent.video_gen_registry import get_active_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + provider = get_active_provider() + if provider is None: + _ensure_plugins_discovered(force=True) + provider = get_active_provider() + return provider + except Exception as exc: + logger.debug("video_gen provider resolution failed: %s", exc) + return None + + +def _missing_provider_error(configured: Optional[str]) -> str: + if configured: + msg = ( + f"video_gen.provider='{configured}' is set but no plugin " + f"registered that name. Run `hermes plugins list` to see " + f"installed video gen backends, or `hermes tools` → Video " + f"Generation to pick one." + ) + return json.dumps(error_response( + error=msg, error_type="provider_not_registered", + provider=configured, + )) + msg = ( + "No video generation backend is configured. Run `hermes tools` → " + "Video Generation to enable one (xAI, FAL, or Google Veo)." + ) + return json.dumps(error_response( + error=msg, error_type="no_provider_configured", + )) + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + + +def _coerce_int(value: Any) -> Optional[int]: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return None + + +def _normalize_reference_images(value: Any) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)): + return None + out: List[str] = [] + for item in value: + if isinstance(item, str) and item.strip(): + out.append(item.strip()) + return out or None + + +def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str: + prompt = (args.get("prompt") or "").strip() + image_url = (args.get("image_url") or "").strip() or None + reference_image_urls = _normalize_reference_images(args.get("reference_image_urls")) + duration = _coerce_int(args.get("duration")) + aspect_ratio = (args.get("aspect_ratio") or DEFAULT_ASPECT_RATIO).strip() or DEFAULT_ASPECT_RATIO + resolution = (args.get("resolution") or DEFAULT_RESOLUTION).strip() or DEFAULT_RESOLUTION + negative_prompt = (args.get("negative_prompt") or "").strip() or None + audio = _coerce_bool(args.get("audio")) + seed = _coerce_int(args.get("seed")) + model_override = (args.get("model") or "").strip() or None + + # Soft validation — providers do their own. Prompt is required by the + # schema; the backend may still accept image-only on its image-to-video + # endpoint but our surface always needs a prompt. + if not prompt: + return tool_error("prompt is required for video generation") + + # Resolve the active provider. + configured = _read_configured_video_provider() + provider = _resolve_active_provider() + if provider is None: + return _missing_provider_error(configured) + + # Resolve model: explicit arg wins, then config, then provider default. + model = model_override or _read_configured_video_model() or provider.default_model() + + kwargs: Dict[str, Any] = { + "model": model, + "image_url": image_url, + "reference_image_urls": reference_image_urls, + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "negative_prompt": negative_prompt, + "audio": audio, + "seed": seed, + } + # Drop None entries so providers see clean defaults. + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + try: + result = provider.generate(prompt=prompt, **kwargs) + except TypeError as exc: + # A provider that hasn't widened its signature is a bug, not a + # caller error — log and surface a clear contract message. + logger.warning( + "video_gen provider '%s' rejected kwargs (signature too narrow): %s", + getattr(provider, "name", "?"), exc, + ) + return json.dumps(error_response( + error=( + f"Provider '{getattr(provider, 'name', '?')}' signature is " + f"out of date with the video_generate schema. Report this " + f"to the plugin author." + ), + error_type="provider_contract", + provider=getattr(provider, "name", ""), + model=model or "", + prompt=prompt, + )) + except Exception as exc: + logger.warning( + "video_gen provider '%s' raised: %s", + getattr(provider, "name", "?"), exc, + ) + return json.dumps(error_response( + error=f"Provider '{getattr(provider, 'name', '?')}' error: {exc}", + error_type="provider_exception", + provider=getattr(provider, "name", ""), + model=model or "", + prompt=prompt, + )) + + if not isinstance(result, dict): + return json.dumps(error_response( + error="Provider returned a non-dict result", + error_type="provider_contract", + provider=getattr(provider, "name", ""), + model=model or "", + prompt=prompt, + )) + + return json.dumps(result) + + +# --------------------------------------------------------------------------- +# Dynamic schema — reflect the active backend's actual capabilities +# --------------------------------------------------------------------------- +# +# Why dynamic: the user's configured backend determines which operations +# (generate/edit/extend), modalities (text / image / refs), aspect ratios, +# resolutions, durations, and audio/negative-prompt flags are real. A model +# that calls video_generate without knowing the active backend wastes a +# turn on something like "fal-ai/veo3.1/image-to-video requires image_url". +# Surfacing the per-model surface in the description means the model +# usually gets the call right on the first try. +# +# Memoization: model_tools.get_tool_definitions() keys its cache on +# config.yaml mtime, so when the user changes provider/model via +# `hermes tools` or `/skills`, the schema rebuilds automatically. + + +_GENERIC_DESCRIPTION = ( + "Generate a video from a text prompt (text-to-video) or animate a " + "still image (image-to-video) using the user's configured video " + "generation backend. Pass `image_url` to animate that image; omit it " + "to generate from text alone. The backend auto-routes to the right " + "endpoint. The backend and model family are user-configured via " + "`hermes tools` → Video Generation; the agent does not pick them. " + "Long-running generations may take 30 seconds to several minutes — " + "the call blocks until the video is ready. Returns either an HTTP " + "URL or an absolute file path in the `video` field; display it with " + "markdown ![description](url-or-path) and the gateway will deliver it." +) + + +def _format_model_caveats( + model_meta: Dict[str, Any], + backend_caps: Dict[str, Any], +) -> List[str]: + """Pull human-readable caveats out of one model's catalog metadata. + + Only surfaces things that meaningfully differ from the backend's + overall capabilities — repeating defaults is noise. + """ + caveats: List[str] = [] + + modalities = set(model_meta.get("modalities") or []) + modality = model_meta.get("modality") # FAL's plugin uses this key for single-modality entries + if modality: + modalities.add(modality) + + if "image" in modalities and "text" not in modalities: + caveats.append( + "this model is image-to-video only — image_url is REQUIRED; " + "text-only calls will be rejected" + ) + elif "text" in modalities and "image" not in modalities: + caveats.append( + "this model is text-to-video only — image_url is not supported" + ) + + return caveats + + +def _build_dynamic_video_schema() -> Dict[str, Any]: + """Build a description that reflects the active backend's actual surface. + + Cheap: reads config (already memoized by the caller), asks the active + provider for `capabilities()` and the active model's catalog entry, + and formats a few lines of prose. Falls back to the generic + description when no provider is configured or registered. + """ + parts: List[str] = [_GENERIC_DESCRIPTION] + + configured = _read_configured_video_provider() + configured_model = _read_configured_video_model() + + if not configured: + parts.append( + "\nNo video backend is configured. Calls will return an error " + "until the user picks one via `hermes tools` → Video Generation." + ) + return {"description": "\n".join(parts)} + + try: + from agent.video_gen_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + provider = get_provider(configured) + except Exception: + provider = None + + if provider is None: + parts.append( + f"\nActive backend: {configured} (plugin not yet loaded — the " + f"tool will retry discovery on first call)." + ) + return {"description": "\n".join(parts)} + + try: + caps = provider.capabilities() or {} + except Exception: + caps = {} + try: + models = provider.list_models() or [] + except Exception: + models = [] + + active_model = configured_model or provider.default_model() + model_meta = next( + (m for m in models if isinstance(m, dict) and m.get("id") == active_model), + {}, + ) + + backend_label = provider.display_name + line = f"\nActive backend: {backend_label}" + if active_model: + line += f" · model: {active_model}" + parts.append(line) + + # Model-specific caveats (the high-signal stuff) + for c in _format_model_caveats(model_meta, caps): + parts.append(f"- {c}") + + # Backend modality summary — only useful when the backend supports + # both text and image. Single-modality backends are already covered by + # the model caveat above. + modalities = set(caps.get("modalities") or []) + if "text" in modalities and "image" in modalities and not model_meta.get("modality"): + parts.append( + "- supports both text-to-video (omit image_url) and " + "image-to-video (pass image_url) — routes automatically" + ) + + if caps.get("aspect_ratios"): + parts.append(f"- aspect_ratio choices: {', '.join(caps['aspect_ratios'])}") + if caps.get("resolutions"): + parts.append(f"- resolution choices: {', '.join(caps['resolutions'])}") + if caps.get("min_duration") and caps.get("max_duration"): + parts.append( + f"- duration range: {caps['min_duration']}-{caps['max_duration']}s" + ) + if caps.get("supports_audio"): + parts.append("- audio: pass `audio=true` to enable native audio (pricing tier)") + if caps.get("supports_negative_prompt"): + parts.append("- negative_prompt: supported") + max_refs = caps.get("max_reference_images") or 0 + if max_refs: + parts.append(f"- reference_image_urls: up to {max_refs} images") + + return {"description": "\n".join(parts)} + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + + +registry.register( + name="video_generate", + toolset="video_gen", + schema=VIDEO_GENERATE_SCHEMA, + handler=_handle_video_generate, + check_fn=check_video_generation_requirements, + requires_env=[], + is_async=False, + emoji="🎬", + dynamic_schema_overrides=_build_dynamic_video_schema, +) diff --git a/tools/web_providers/ARCHITECTURE.md b/tools/web_providers/ARCHITECTURE.md deleted file mode 100644 index f4a7b335e87e..000000000000 --- a/tools/web_providers/ARCHITECTURE.md +++ /dev/null @@ -1,73 +0,0 @@ -# Web Tools Provider Architecture - -## Overview - -Web tools (`web_search`, `web_extract`) use a **per-capability backend selection** system that allows different providers for search and extract independently. - -## Config Keys - -```yaml -web: - backend: "firecrawl" # Shared fallback — applies to both if specific keys not set - search_backend: "" # Per-capability override for web_search - extract_backend: "" # Per-capability override for web_extract -``` - -**Selection priority (per capability):** -1. `web.search_backend` / `web.extract_backend` (explicit per-capability) -2. `web.backend` (shared fallback) -3. Auto-detect from environment variables - -When per-capability keys are empty (default), behavior is identical to the legacy single-backend selection. - -## Architecture - -``` -web_search_tool() - └─ _get_search_backend() - ├─ web.search_backend (if set + available) - └─ _get_backend() fallback - -web_extract_tool() - └─ _get_extract_backend() - ├─ web.extract_backend (if set + available) - └─ _get_backend() fallback -``` - -## Provider ABCs - -New providers implement these interfaces in `tools/web_providers/`: - -```python -from tools.web_providers.base import WebSearchProvider, WebExtractProvider - -class MySearchProvider(WebSearchProvider): - def provider_name(self) -> str: ... - def is_configured(self) -> bool: ... - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: ... - -class MyExtractProvider(WebExtractProvider): - def provider_name(self) -> str: ... - def is_configured(self) -> bool: ... - def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: ... -``` - -## Adding a New Search Provider - -1. Create `tools/web_providers/your_provider.py` implementing `WebSearchProvider` -2. Add availability check to `_is_backend_available()` in `web_tools.py` -3. Add dispatch branch in `web_search_tool()` -4. Add provider to `hermes tools` picker in `tools_config.py` -5. Add env var to `OPTIONAL_ENV_VARS` in `config.py` (if needed) -6. Write tests in `tests/tools/` - -Search-only providers (like SearXNG) don't need to implement `WebExtractProvider`. -Extract-only providers don't need to implement `WebSearchProvider`. - -## hermes tools UX - -The provider picker uses **progressive disclosure**: -- **Default path** (90% of users): Pick one provider → sets `web.backend` for both. One selection, done. -- **Advanced path**: "Configure separately" option at bottom → two-step sub-picker for search + extract independently. - -See `.hermes/plans/2026-05-03-web-tools-provider-architecture.md` for the full UX flow diagram. diff --git a/tools/web_providers/__init__.py b/tools/web_providers/__init__.py deleted file mode 100644 index 15134175d213..000000000000 --- a/tools/web_providers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Web capability providers — search, extract, crawl. - -Each capability has an ABC in ``base.py`` and vendor implementations in -sibling modules. Provider registries in ``web_tools.py`` map config names -to provider classes. -""" diff --git a/tools/web_providers/base.py b/tools/web_providers/base.py deleted file mode 100644 index 217721891911..000000000000 --- a/tools/web_providers/base.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Abstract base classes for web capability providers.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Dict, List - - -class WebSearchProvider(ABC): - """Interface for web search backends (Firecrawl, Tavily, Exa, etc.). - - Implementations live in sibling modules. The user selects a provider - via ``hermes tools``; the choice is persisted as - ``config["web"]["search_backend"]`` (falling back to - ``config["web"]["backend"]``). - - Search providers return results in a normalized format:: - - { - "success": True, - "data": { - "web": [ - {"title": str, "url": str, "description": str, "position": int}, - ... - ] - } - } - - On failure:: - - {"success": False, "error": str} - """ - - @abstractmethod - def provider_name(self) -> str: - """Short, human-readable name shown in logs and diagnostics.""" - - @abstractmethod - def is_configured(self) -> bool: - """Return True when all required env vars / credentials are present. - - Called at tool-registration time to gate availability. - Must be cheap — no network calls. - """ - - @abstractmethod - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a web search and return normalized results.""" - - -class WebExtractProvider(ABC): - """Interface for web content extraction backends. - - Implementations live in sibling modules. The user selects a provider - via ``hermes tools``; the choice is persisted as - ``config["web"]["extract_backend"]`` (falling back to - ``config["web"]["backend"]``). - - Extract providers return results in a normalized format:: - - { - "success": True, - "data": [ - {"url": str, "title": str, "content": str, - "raw_content": str, "metadata": dict}, - ... - ] - } - - On failure:: - - {"success": False, "error": str} - """ - - @abstractmethod - def provider_name(self) -> str: - """Short, human-readable name shown in logs and diagnostics.""" - - @abstractmethod - def is_configured(self) -> bool: - """Return True when all required env vars / credentials are present. - - Called at tool-registration time to gate availability. - Must be cheap — no network calls. - """ - - @abstractmethod - def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: - """Extract content from the given URLs and return normalized results.""" diff --git a/tools/web_tools.py b/tools/web_tools.py index 79ddc8d27f25..e2743248d227 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -46,52 +46,56 @@ import re import asyncio from typing import List, Dict, Any, Optional, TYPE_CHECKING -import httpx -# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top — -# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and -# we only need it when the backend is actually "firecrawl". We expose -# ``Firecrawl`` as a thin proxy that imports the SDK on first call/ -# isinstance check, so both (a) the in-module ``Firecrawl(...)`` construction -# site in _get_firecrawl_client() works unchanged, and (b) tests using -# ``patch("tools.web_tools.Firecrawl", ...)`` keep working. +import httpx # noqa: F401 — kept at module top so tests can patch tools.web_tools.httpx +# After the web-provider plugin migration (PR #25182), the Firecrawl SDK +# proxy, client construction, and response-shape normalizers all live in +# plugins.web.firecrawl.provider. We re-export the names that external +# code, integration tests, and unit-test patches reach for so the public +# surface stays stable. if TYPE_CHECKING: from firecrawl import Firecrawl # noqa: F401 — type hints only +from plugins.web.firecrawl.provider import ( + Firecrawl, + _FirecrawlProxy, + _FIRECRAWL_CLS_CACHE, + _extract_scrape_payload, + _extract_web_search_results, + _firecrawl_backend_help_suffix, + _get_direct_firecrawl_config, + _get_firecrawl_client, + _get_firecrawl_gateway_url, + _has_direct_firecrawl_config, + _is_tool_gateway_ready, + _load_firecrawl_cls, + _normalize_result_list, + _raise_web_backend_configuration_error, + _to_plain_object, + check_firecrawl_api_key, +) +# Tavily helpers re-exported for backward-compat with existing unit tests +# (tests/tools/test_web_tools_tavily.py imports these names directly). +from plugins.web.tavily.provider import ( # noqa: F401 — backward-compat names + _normalize_tavily_documents, + _normalize_tavily_search_results, + _tavily_request, +) +# Parallel + Exa clients re-exported for backward-compat with existing +# unit tests (tests/tools/test_web_tools_config.py imports _get_parallel_client +# / _get_async_parallel_client / _get_exa_client directly). +from plugins.web.parallel.provider import ( # noqa: F401 — backward-compat names + _get_async_parallel_client, + _get_parallel_client, +) +from plugins.web.exa.provider import _get_exa_client # noqa: F401 -_FIRECRAWL_CLS_CACHE: Optional[type] = None - - -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 - - -class _FirecrawlProxy: - """Module-level proxy that looks like ``firecrawl.Firecrawl`` but imports lazily.""" - - __slots__ = () - - def __call__(self, *args, **kwargs): - return _load_firecrawl_cls()(*args, **kwargs) - - def __instancecheck__(self, obj): - return isinstance(obj, _load_firecrawl_cls()) - - def __repr__(self): - return "" - - -Firecrawl = _FirecrawlProxy() +# Module-level cache slots for the per-vendor clients. The plugins read/write +# these via tools.web_tools so unit tests that reset +# ``tools.web_tools.__client = None`` between cases keep working. +_firecrawl_client: Optional[Any] = None +_firecrawl_client_config: Optional[Any] = None +_parallel_client: Optional[Any] = None +_async_parallel_client: Optional[Any] = None +_exa_client: Optional[Any] = None from agent.auxiliary_client import ( async_call_llm, @@ -99,12 +103,14 @@ def __repr__(self): get_async_text_auxiliary_client, ) from tools.debug_helpers import DebugSession -from tools.managed_tool_gateway import ( +# Imported solely so unit tests can monkeypatch these names on +# tools.web_tools (the firecrawl plugin reads them via its own import chain). +from tools.managed_tool_gateway import ( # noqa: F401 — backward-compat names for tests build_vendor_gateway_url, read_nous_access_token as _read_nous_access_token, resolve_managed_tool_gateway, ) -from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway # noqa: F401 from tools.url_safety import is_safe_url from tools.website_policy import check_website_access import sys @@ -231,64 +237,12 @@ def _ddgs_package_importable() -> bool: # ─── Firecrawl Client ──────────────────────────────────────────────────────── -_firecrawl_client = None -_firecrawl_client_config = None - - -def _get_direct_firecrawl_config() -> Optional[tuple[Dict[str, str], tuple[str, Optional[str], Optional[str]]]]: - """Return explicit direct Firecrawl kwargs + cache key, or None when unset.""" - api_key = os.getenv("FIRECRAWL_API_KEY", "").strip() - api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/") - - if not api_key and not api_url: - return None - - kwargs: Dict[str, str] = {} - if api_key: - kwargs["api_key"] = api_key - if api_url: - kwargs["api_url"] = api_url - - return kwargs, ("direct", api_url or None, api_key or None) - - -def _get_firecrawl_gateway_url() -> str: - """Return configured Firecrawl gateway URL.""" - return build_vendor_gateway_url("firecrawl") - - -def _is_tool_gateway_ready() -> bool: - """Return True when gateway URL and a Nous Subscriber token are available.""" - return resolve_managed_tool_gateway("firecrawl", token_reader=_read_nous_access_token) is not None - - -def _has_direct_firecrawl_config() -> bool: - """Return True when direct Firecrawl config is explicitly configured.""" - return _get_direct_firecrawl_config() is not None - - -def _raise_web_backend_configuration_error() -> None: - """Raise a clear error for unsupported web backend configuration.""" - message = ( - "Web tools are not configured. " - "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance." - ) - if managed_nous_tools_enabled(): - message += ( - " With your Nous subscription you can also use the Tool Gateway — " - "run `hermes tools` and select Nous Subscription as the web provider." - ) - raise ValueError(message) - - -def _firecrawl_backend_help_suffix() -> str: - """Return optional managed-gateway guidance for Firecrawl help text.""" - if not managed_nous_tools_enabled(): - return "" - return ( - ", or use the Nous Tool Gateway via your subscription " - "(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)" - ) +# ─── Firecrawl Client ──────────────────────────────────────────────────────── +# After PR #25182, the firecrawl client, lazy SDK proxy, dual-auth config +# resolution, response normalizers, and check_firecrawl_api_key() all live +# in plugins.web.firecrawl.provider and are re-exported at the top of this +# module so external callers (integration tests, tool-registry gating) and +# unit tests that patch tools.web_tools. continue to work. def _web_requires_env() -> list[str]: @@ -316,261 +270,17 @@ def _web_requires_env() -> list[str]: ] -def _get_firecrawl_client(): - """Get or create Firecrawl client. - - When ``web.use_gateway`` is set in config, the Tool Gateway is preferred - even if direct Firecrawl credentials are present. Otherwise direct - Firecrawl takes precedence when explicitly configured. - """ - global _firecrawl_client, _firecrawl_client_config - - direct_config = _get_direct_firecrawl_config() - if direct_config is not None and not prefers_gateway("web"): - kwargs, client_config = direct_config - else: - managed_gateway = resolve_managed_tool_gateway( - "firecrawl", - token_reader=_read_nous_access_token, - ) - if managed_gateway is None: - logger.error("Firecrawl client initialization failed: missing direct config and tool-gateway auth.") - _raise_web_backend_configuration_error() - - kwargs = { - "api_key": managed_gateway.nous_user_token, - "api_url": managed_gateway.gateway_origin, - } - client_config = ( - "tool-gateway", - kwargs["api_url"], - managed_gateway.nous_user_token, - ) - - if _firecrawl_client is not None and _firecrawl_client_config == client_config: - return _firecrawl_client - - # Uses the module-level `Firecrawl` name (lazy proxy at module top). - _firecrawl_client = Firecrawl(**kwargs) - _firecrawl_client_config = client_config - return _firecrawl_client - -# ─── Parallel Client ───────────────────────────────────────────────────────── - -_parallel_client = None -_async_parallel_client = None - -def _get_parallel_client(): - """Get or create the Parallel sync client (lazy initialization). - - 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: - api_key = os.getenv("PARALLEL_API_KEY") - if not api_key: - raise ValueError( - "PARALLEL_API_KEY environment variable not set. " - "Get your API key at https://parallel.ai" - ) - _parallel_client = Parallel(api_key=api_key) - return _parallel_client - - -def _get_async_parallel_client(): - """Get or create the Parallel async client (lazy initialization). - - 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: - api_key = os.getenv("PARALLEL_API_KEY") - if not api_key: - raise ValueError( - "PARALLEL_API_KEY environment variable not set. " - "Get your API key at https://parallel.ai" - ) - _async_parallel_client = AsyncParallel(api_key=api_key) - return _async_parallel_client - -# ─── Tavily Client ─────────────────────────────────────────────────────────── - -_TAVILY_BASE_URL = os.getenv("TAVILY_BASE_URL", "https://api.tavily.com") - - -def _tavily_request(endpoint: str, payload: dict) -> dict: - """Send a POST request to the Tavily API. - - Auth is provided via ``api_key`` in the JSON body (no header-based auth). - Raises ``ValueError`` if ``TAVILY_API_KEY`` is not set. - """ - api_key = os.getenv("TAVILY_API_KEY") - if not api_key: - raise ValueError( - "TAVILY_API_KEY environment variable not set. " - "Get your API key at https://app.tavily.com/home" - ) - payload["api_key"] = api_key - url = f"{_TAVILY_BASE_URL}/{endpoint.lstrip('/')}" - logger.info("Tavily %s request to %s", endpoint, url) - # Tavily /crawl requires Bearer auth in header (body-only auth returns 401) - headers = {"Authorization": f"Bearer {api_key}"} if endpoint.strip("/") == "crawl" else {} - response = httpx.post(url, json=payload, headers=headers, timeout=60) - response.raise_for_status() - return response.json() - - -def _normalize_tavily_search_results(response: dict) -> dict: - """Normalize Tavily /search response to the standard web search format. - - Tavily returns ``{results: [{title, url, content, score, ...}]}``. - We map to ``{success, data: {web: [{title, url, description, position}]}}``. - """ - web_results = [] - for i, result in enumerate(response.get("results", [])): - web_results.append({ - "title": result.get("title", ""), - "url": result.get("url", ""), - "description": result.get("content", ""), - "position": i + 1, - }) - return {"success": True, "data": {"web": web_results}} - - -def _normalize_tavily_documents(response: dict, fallback_url: str = "") -> List[Dict[str, Any]]: - """Normalize Tavily /extract or /crawl response to the standard document format. - - Maps results to ``{url, title, content, raw_content, metadata}`` and - includes any ``failed_results`` / ``failed_urls`` as error entries. - """ - documents: List[Dict[str, Any]] = [] - for result in response.get("results", []): - url = result.get("url", fallback_url) - raw = result.get("raw_content", "") or result.get("content", "") - documents.append({ - "url": url, - "title": result.get("title", ""), - "content": raw, - "raw_content": raw, - "metadata": {"sourceURL": url, "title": result.get("title", "")}, - }) - # Handle failed results - for fail in response.get("failed_results", []): - documents.append({ - "url": fail.get("url", fallback_url), - "title": "", - "content": "", - "raw_content": "", - "error": fail.get("error", "extraction failed"), - "metadata": {"sourceURL": fail.get("url", fallback_url)}, - }) - for fail_url in response.get("failed_urls", []): - url_str = fail_url if isinstance(fail_url, str) else str(fail_url) - documents.append({ - "url": url_str, - "title": "", - "content": "", - "raw_content": "", - "error": "extraction failed", - "metadata": {"sourceURL": url_str}, - }) - return documents - - -def _to_plain_object(value: Any) -> Any: - """Convert SDK objects to plain python data structures when possible.""" - if value is None: - return None - - if isinstance(value, (dict, list, str, int, float, bool)): - return value - - if hasattr(value, "model_dump"): - try: - return value.model_dump() - except Exception: - pass - - if hasattr(value, "__dict__"): - try: - return {k: v for k, v in value.__dict__.items() if not k.startswith("_")} - except Exception: - pass - - return value - - -def _normalize_result_list(values: Any) -> List[Dict[str, Any]]: - """Normalize mixed SDK/list payloads into a list of dicts.""" - if not isinstance(values, list): - return [] - - normalized: List[Dict[str, Any]] = [] - for item in values: - plain = _to_plain_object(item) - if isinstance(plain, dict): - normalized.append(plain) - return normalized - - -def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]: - """Extract Firecrawl search results across SDK/direct/gateway response shapes.""" - response_plain = _to_plain_object(response) - - if isinstance(response_plain, dict): - data = response_plain.get("data") - if isinstance(data, list): - return _normalize_result_list(data) - - if isinstance(data, dict): - data_web = _normalize_result_list(data.get("web")) - if data_web: - return data_web - data_results = _normalize_result_list(data.get("results")) - if data_results: - return data_results - - top_web = _normalize_result_list(response_plain.get("web")) - if top_web: - return top_web - - top_results = _normalize_result_list(response_plain.get("results")) - if top_results: - return top_results - - if hasattr(response, "web"): - return _normalize_result_list(getattr(response, "web", [])) - - return [] - - -def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]: - """Normalize Firecrawl scrape payload shape across SDK and gateway variants.""" - result_plain = _to_plain_object(scrape_result) - if not isinstance(result_plain, dict): - return {} - - nested = result_plain.get("data") - if isinstance(nested, dict): - return nested - - return result_plain +# ─── Parallel / Tavily / Firecrawl helpers — moved into plugins ────────────── +# After PR #25182, the per-vendor client construction, request helpers, and +# response normalizers all live in plugins.web..provider: +# - parallel: plugins/web/parallel/provider.py +# - tavily: plugins/web/tavily/provider.py +# - firecrawl: plugins/web/firecrawl/provider.py +# The names from the firecrawl plugin (Firecrawl proxy, _get_firecrawl_client, +# _to_plain_object, _normalize_result_list, _extract_web_search_results, +# _extract_scrape_payload, _is_tool_gateway_ready, etc.) are re-exported at +# the top of this module for backward-compat with integration tests and +# unit-test patches. DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000 @@ -1005,172 +715,13 @@ def clean_base64_images(text: str) -> str: return cleaned_text -# ─── Exa Client ────────────────────────────────────────────────────────────── - -_exa_client = None - -def _get_exa_client(): - """Get or create the Exa client (lazy initialization). - - 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: - api_key = os.getenv("EXA_API_KEY") - if not api_key: - raise ValueError( - "EXA_API_KEY environment variable not set. " - "Get your API key at https://exa.ai" - ) - _exa_client = Exa(api_key=api_key) - _exa_client.headers["x-exa-integration"] = "hermes-agent" - return _exa_client - - -# ─── Exa Search & Extract Helpers ───────────────────────────────────────────── - -def _exa_search(query: str, limit: int = 10) -> dict: - """Search using the Exa SDK and return results as a dict.""" - from tools.interrupt import is_interrupted - if is_interrupted(): - return {"error": "Interrupted", "success": False} - - logger.info("Exa search: '%s' (limit=%d)", query, limit) - response = _get_exa_client().search( - query, - num_results=limit, - contents={ - "highlights": True, - }, - ) - - web_results = [] - for i, result in enumerate(response.results or []): - highlights = result.highlights or [] - web_results.append({ - "url": result.url or "", - "title": result.title or "", - "description": " ".join(highlights) if highlights else "", - "position": i + 1, - }) - - return {"success": True, "data": {"web": web_results}} - - -def _exa_extract(urls: List[str]) -> List[Dict[str, Any]]: - """Extract content from URLs using the Exa SDK. - - Returns a list of result dicts matching the structure expected by the - LLM post-processing pipeline (url, title, content, metadata). - """ - from tools.interrupt import is_interrupted - if is_interrupted(): - return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] - - logger.info("Exa extract: %d URL(s)", len(urls)) - response = _get_exa_client().get_contents( - urls, - text=True, - ) - - results = [] - for result in response.results or []: - content = result.text or "" - url = result.url or "" - title = result.title or "" - results.append({ - "url": url, - "title": title, - "content": content, - "raw_content": content, - "metadata": {"sourceURL": url, "title": title}, - }) - - return results - - -# ─── Parallel Search & Extract Helpers ──────────────────────────────────────── - -def _parallel_search(query: str, limit: int = 5) -> dict: - """Search using the Parallel SDK and return results as a dict.""" - from tools.interrupt import is_interrupted - if is_interrupted(): - return {"error": "Interrupted", "success": False} - - mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - if mode not in {"fast", "one-shot", "agentic"}: - mode = "agentic" - - logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit) - response = _get_parallel_client().beta.search( - search_queries=[query], - objective=query, - mode=mode, - max_results=min(limit, 20), - ) - - web_results = [] - for i, result in enumerate(response.results or []): - excerpts = result.excerpts or [] - web_results.append({ - "url": result.url or "", - "title": result.title or "", - "description": " ".join(excerpts) if excerpts else "", - "position": i + 1, - }) - - return {"success": True, "data": {"web": web_results}} - - -async def _parallel_extract(urls: List[str]) -> List[Dict[str, Any]]: - """Extract content from URLs using the Parallel async SDK. - - Returns a list of result dicts matching the structure expected by the - LLM post-processing pipeline (url, title, content, metadata). - """ - from tools.interrupt import is_interrupted - if is_interrupted(): - return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] - - logger.info("Parallel extract: %d URL(s)", len(urls)) - response = await _get_async_parallel_client().beta.extract( - urls=urls, - full_content=True, - ) - - results = [] - for result in response.results or []: - content = result.full_content or "" - if not content: - content = "\n\n".join(result.excerpts or []) - url = result.url or "" - title = result.title or "" - results.append({ - "url": url, - "title": title, - "content": content, - "raw_content": content, - "metadata": {"sourceURL": url, "title": title}, - }) - - for error in response.errors or []: - results.append({ - "url": error.url or "", - "title": "", - "content": "", - "error": error.content or error.error_type or "extraction failed", - "metadata": {"sourceURL": error.url or ""}, - }) - - return results +# ─── Exa / Parallel inline helpers — moved into plugins ────────────────────── +# After PR #25182, the exa client + search/extract and parallel client + +# search/extract helpers all live in their respective plugins: +# - plugins/web/exa/provider.py +# - plugins/web/parallel/provider.py +# Both plugins register through agent.web_search_registry and the +# dispatchers in this file resolve them via get_active_*_provider(). def web_search_tool(query: str, limit: int = 5) -> str: @@ -1229,105 +780,45 @@ def web_search_tool(query: str, limit: int = 5) -> str: if is_interrupted(): return tool_error("Interrupted", success=False) - # Dispatch to the configured search backend - backend = _get_search_backend() - if backend == "parallel": - response_data = _parallel_search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "exa": - response_data = _exa_search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "searxng": - from tools.web_providers.searxng import SearXNGSearchProvider - response_data = SearXNGSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "brave-free": - from tools.web_providers.brave_free import BraveFreeSearchProvider - response_data = BraveFreeSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "ddgs": - from tools.web_providers.ddgs import DDGSSearchProvider - response_data = DDGSSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "tavily": - logger.info("Tavily search: '%s' (limit: %d)", query, limit) - raw = _tavily_request("search", { - "query": query, - "max_results": min(limit, 20), - "include_raw_content": False, - "include_images": False, - }) - response_data = _normalize_tavily_search_results(raw) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - logger.info("Searching the web for: '%s' (limit: %d)", query, limit) - - response = _get_firecrawl_client().search( - query=query, - limit=limit + # Dispatch through the web search registry. All 7 providers + # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) + # now live as plugins; the dispatcher is just a registry lookup + + # delegation. Sync only — every provider's search() is sync. + from agent.web_search_registry import ( + get_active_search_provider, + get_provider as _wsp_get_provider, ) - web_results = _extract_web_search_results(response) - results_count = len(web_results) - logger.info("Found %d search results", results_count) - - # Build response with just search metadata (URLs, titles, descriptions) - response_data = { - "success": True, - "data": { - "web": web_results + backend = _get_search_backend() + provider = _wsp_get_provider(backend) if backend else None + if provider is None or not provider.supports_search(): + # Fall back to availability-walked active provider when the + # configured backend isn't a registered search provider (typo, + # uninstalled plugin, or capability mismatch). + provider = get_active_search_provider() + + if provider is None: + response_data = { + "success": False, + "error": ( + "No web search provider configured. " + "Run `hermes tools` to set one up." + ), } - } - - # Capture debug information - debug_call_data["results_count"] = results_count - - # Convert to JSON + else: + logger.info( + "Web search via %s: '%s' (limit: %d)", + provider.name, query, limit, + ) + response_data = provider.search(query, limit) + + debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - - # Log debug information _debug.log_call("web_search_tool", debug_call_data) _debug.save() - return result_json - + except Exception as e: error_msg = f"Error searching web: {str(e)}" logger.debug("%s", error_msg) @@ -1418,129 +909,68 @@ async def web_extract_tool( else: backend = _get_extract_backend() - if backend == "parallel": - results = await _parallel_extract(safe_urls) - elif backend == "exa": - results = _exa_extract(safe_urls) - elif backend == "tavily": - logger.info("Tavily extract: %d URL(s)", len(safe_urls)) - raw = _tavily_request("extract", { - "urls": safe_urls, - "include_images": False, - }) - results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "") - 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({ - "success": False, - "error": f"{_label} is a search-only backend and cannot extract URL content. " - "Set web.extract_backend to firecrawl, tavily, exa, or parallel.", - }, ensure_ascii=False) + # All seven providers (brave-free, ddgs, searxng, exa, parallel, + # tavily, firecrawl) now live as plugins. The dispatcher is a + # registry lookup + delegation. Some providers' extract() is + # async (parallel, firecrawl), others sync (exa, tavily) — we + # detect coroutine functions and await; sync functions run + # inline (the policy gate, SSRF re-check, etc. live inside the + # provider itself for the firecrawl per-URL loop). + from agent.web_search_registry import ( + get_active_extract_provider, + get_provider as _wsp_get_provider, + ) + + provider = _wsp_get_provider(backend) if backend else None + if provider is None or not provider.supports_extract(): + # When the configured name IS registered but doesn't support + # extract (search-only providers like brave-free / ddgs / + # searxng), surface that as a typed "search-only" error + # rather than silently switching backends. When the name + # isn't registered at all (typo / uninstalled plugin), fall + # through to the active-provider walk. + if provider is not None and not provider.supports_extract(): + return json.dumps( + { + "success": False, + "error": ( + f"{provider.display_name} is a search-only " + "backend and cannot extract URL content. " + "Set web.extract_backend to firecrawl, " + "tavily, exa, or parallel." + ), + }, + ensure_ascii=False, + ) + provider = get_active_extract_provider() + if provider is None: + return json.dumps( + { + "success": False, + "error": ( + "No web extract provider configured. " + "Set web.extract_backend to firecrawl, " + "tavily, exa, or parallel." + ), + }, + ensure_ascii=False, + ) + + logger.info( + "Web extract via %s: %d URL(s)", provider.name, len(safe_urls) + ) + + # Async-or-sync dispatch: parallel + firecrawl have async + # extract(); exa + tavily are sync. + import inspect + if inspect.iscoroutinefunction(provider.extract): + results = await provider.extract(safe_urls, format=format) else: - # ── Firecrawl extraction ── - # Determine requested formats for Firecrawl v2 - formats: List[str] = [] - if format == "markdown": - formats = ["markdown"] - elif format == "html": - formats = ["html"] - else: - # Default: request markdown for LLM-readiness and include html as backup - formats = ["markdown", "html"] - - # Always use individual scraping for simplicity and reliability - # Batch scraping adds complexity without much benefit for small numbers of URLs - results: List[Dict[str, Any]] = [] - - from tools.interrupt import is_interrupted as _is_interrupted - for url in safe_urls: - if _is_interrupted(): - results.append({"url": url, "error": "Interrupted", "title": ""}) - continue - - # Website policy check — block before fetching - blocked = check_website_access(url) - if blocked: - logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"]) - results.append({ - "url": url, "title": "", "content": "", - "error": blocked["message"], - "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}, - }) - continue - - try: - logger.info("Scraping: %s", url) - # Run synchronous Firecrawl scrape in a thread with a - # 60s timeout so a hung fetch doesn't block the session. - try: - scrape_result = await asyncio.wait_for( - asyncio.to_thread( - _get_firecrawl_client().scrape, - url=url, - formats=formats, - ), - timeout=60, - ) - except asyncio.TimeoutError: - logger.warning("Firecrawl scrape timed out for %s", url) - results.append({ - "url": url, "title": "", "content": "", - "error": "Scrape timed out after 60s — page may be too large or unresponsive. Try browser_navigate instead.", - }) - continue - - scrape_payload = _extract_scrape_payload(scrape_result) - metadata = scrape_payload.get("metadata", {}) - title = "" - content_markdown = scrape_payload.get("markdown") - content_html = scrape_payload.get("html") - - # Ensure metadata is a dict (not an object) - if not isinstance(metadata, dict): - if hasattr(metadata, 'model_dump'): - metadata = metadata.model_dump() - elif hasattr(metadata, '__dict__'): - metadata = metadata.__dict__ - else: - metadata = {} - - # Get title from metadata - title = metadata.get("title", "") - - # Re-check final URL after redirect - final_url = metadata.get("sourceURL", url) - final_blocked = check_website_access(final_url) - if final_blocked: - logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"]) - results.append({ - "url": final_url, "title": title, "content": "", "raw_content": "", - "error": final_blocked["message"], - "blocked_by_policy": {"host": final_blocked["host"], "rule": final_blocked["rule"], "source": final_blocked["source"]}, - }) - continue - - # Choose content based on requested format - chosen_content = content_markdown if (format == "markdown" or (format is None and content_markdown)) else content_html or content_markdown or "" - - results.append({ - "url": final_url, - "title": title, - "content": chosen_content, - "raw_content": chosen_content, - "metadata": metadata # Now guaranteed to be a dict - }) - - except Exception as scrape_err: - logger.debug("Scrape failed for %s: %s", url, scrape_err) - results.append({ - "url": url, - "title": "", - "content": "", - "raw_content": "", - "error": str(scrape_err) - }) + # Run sync extract() in a thread so we don't block the + # event loop on network I/O. + results = await asyncio.to_thread( + provider.extract, safe_urls, format=format + ) # Merge any SSRF-blocked results back in if ssrf_blocked: @@ -1728,8 +1158,60 @@ async def web_crawl_tool( auxiliary_available = check_auxiliary_model() backend = _get_backend() - # Tavily supports crawl via its /crawl endpoint - if backend == "tavily": + # Tavily (and any future plugin advertising supports_crawl=True) + # dispatches through agent.web_search_registry. The crawl response + # shape — {"results": [{"url", "title", "content", ...}]} — is then + # post-processed by the shared LLM-summarization path below. + from agent.web_search_registry import ( + get_active_crawl_provider, + get_provider as _wsp_get_provider, + ) + + crawl_provider = _wsp_get_provider(backend) if backend else None + if crawl_provider is not None and not crawl_provider.supports_crawl(): + # When the configured provider is search-only AND cannot + # extract URLs either (brave-free / ddgs / searxng), surface a + # typed "search-only" error rather than silently switching to + # a different crawl backend. When the provider supports extract + # but not crawl (e.g. firecrawl), fall through to the legacy + # firecrawl-via-extract path below. + if not crawl_provider.supports_extract(): + return json.dumps( + { + "success": False, + "error": ( + f"{crawl_provider.display_name} is a search-only " + "backend and cannot crawl URLs. " + "Set FIRECRAWL_API_KEY for crawling, or use " + "web_search instead." + ), + }, + ensure_ascii=False, + ) + crawl_provider = None # let legacy firecrawl path handle it + if crawl_provider is None: + crawl_provider = get_active_crawl_provider() + + # Mirror main's upstream availability gate: when the resolved + # provider is configured-but-unavailable (e.g. firecrawl without + # FIRECRAWL_API_KEY), short-circuit BEFORE we dispatch so the + # error envelope matches the legacy top-level shape + # ``{"success": False, "error": "..."}`` rather than burying the + # configuration message inside a per-page ``results[]`` entry. + if crawl_provider is not None and not crawl_provider.is_available(): + return json.dumps( + { + "success": False, + "error": ( + "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, " + f"FIRECRAWL_API_URL{_firecrawl_backend_help_suffix()}, " + "or use web_search + web_extract instead." + ), + }, + ensure_ascii=False, + ) + + if crawl_provider is not None: # Ensure URL has protocol if not url.startswith(('http://', 'https://')): url = f'https://{url}' @@ -1750,18 +1232,28 @@ async def web_crawl_tool( if _is_int(): return tool_error("Interrupted", success=False) - logger.info("Tavily crawl: %s", url) - payload: Dict[str, Any] = { - "url": url, - "limit": 20, - "extract_depth": depth, - } + logger.info("Web crawl via %s: %s", crawl_provider.name, url) + + # Async-or-sync dispatch — Tavily's crawl is sync, but a future + # async-crawl provider works transparently. + import inspect + crawl_kwargs = {"depth": depth, "limit": 20} if instructions: - payload["instructions"] = instructions - raw = _tavily_request("crawl", payload) - results = _normalize_tavily_documents(raw, fallback_url=url) + crawl_kwargs["instructions"] = instructions + + if inspect.iscoroutinefunction(crawl_provider.crawl): + response = await crawl_provider.crawl(url, **crawl_kwargs) + else: + response = await asyncio.to_thread( + crawl_provider.crawl, url, **crawl_kwargs + ) + + # Provider returns {"results": [...]} matching what the shared + # LLM post-processing below expects. + if not isinstance(response, dict): + response = {"results": []} + response.setdefault("results", []) - response = {"results": results} # Fall through to the shared LLM processing and trimming below # (skip the Firecrawl-specific crawl logic) pages_crawled = len(response.get('results', [])) @@ -1812,280 +1304,23 @@ async def _process_tavily_crawl(result): _debug.save() return cleaned_result - # SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl - 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. " - "Set FIRECRAWL_API_KEY for crawling, or use web_search instead.", - "success": False, - }, ensure_ascii=False) - - # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API - if not check_firecrawl_api_key(): - return json.dumps({ - "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL" - f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.", + # No registered provider supports crawl AND no crawl-capable plugin + # is available. Surface a typed error pointing the user at the two + # crawl-capable providers (Firecrawl + Tavily). + return json.dumps( + { "success": False, - }, ensure_ascii=False) - - # Ensure URL has protocol - if not url.startswith(('http://', 'https://')): - url = f'https://{url}' - logger.info("Added https:// prefix to URL: %s", url) - - instructions_text = f" with instructions: '{instructions}'" if instructions else "" - logger.info("Crawling %s%s", url, instructions_text) - - # SSRF protection — block private/internal addresses - if not is_safe_url(url): - return json.dumps({"results": [{"url": url, "title": "", "content": "", - "error": "Blocked: URL targets a private or internal network address"}]}, ensure_ascii=False) - - # Website policy check — block before crawling - blocked = check_website_access(url) - if blocked: - logger.info("Blocked web_crawl for %s by rule %s", blocked["host"], blocked["rule"]) - return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": blocked["message"], - "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}}]}, ensure_ascii=False) - - # Use Firecrawl's v2 crawl functionality - # Docs: https://docs.firecrawl.dev/features/crawl - # The crawl() method automatically waits for completion and returns all data - - # Build crawl parameters - keep it simple - crawl_params = { - "limit": 20, # Limit number of pages to crawl - "scrape_options": { - "formats": ["markdown"] # Just markdown for simplicity - } - } - - # Note: The 'prompt' parameter is not documented for crawl - # Instructions are typically used with the Extract endpoint, not Crawl - if instructions: - logger.info("Instructions parameter ignored (not supported in crawl API)") - - from tools.interrupt import is_interrupted as _is_int - if _is_int(): - return tool_error("Interrupted", success=False) - - try: - crawl_result = _get_firecrawl_client().crawl( - url=url, - **crawl_params - ) - except Exception as e: - logger.debug("Crawl API call failed: %s", e) - raise - - pages: List[Dict[str, Any]] = [] - - # Process crawl results - the crawl method returns a CrawlJob object with data attribute - data_list = [] - - # The crawl_result is a CrawlJob object with a 'data' attribute containing list of Document objects - if hasattr(crawl_result, 'data'): - data_list = crawl_result.data if crawl_result.data else [] - logger.info("Status: %s", getattr(crawl_result, 'status', 'unknown')) - logger.info("Retrieved %d pages", len(data_list)) - - # Debug: Check other attributes if no data - if not data_list: - logger.debug("CrawlJob attributes: %s", [attr for attr in dir(crawl_result) if not attr.startswith('_')]) - logger.debug("Status: %s", getattr(crawl_result, 'status', 'N/A')) - logger.debug("Total: %s", getattr(crawl_result, 'total', 'N/A')) - logger.debug("Completed: %s", getattr(crawl_result, 'completed', 'N/A')) - - elif isinstance(crawl_result, dict) and 'data' in crawl_result: - data_list = crawl_result.get("data", []) - else: - logger.warning("Unexpected crawl result type") - logger.debug("Result type: %s", type(crawl_result)) - if hasattr(crawl_result, '__dict__'): - logger.debug("Result attributes: %s", list(crawl_result.__dict__.keys())) - - for item in data_list: - # Process each crawled page - properly handle object serialization - page_url = "Unknown URL" - title = "" - content_markdown = None - content_html = None - metadata = {} - - # Extract data from the item - if hasattr(item, 'model_dump'): - # Pydantic model - use model_dump to get dict - item_dict = item.model_dump() - content_markdown = item_dict.get('markdown') - content_html = item_dict.get('html') - metadata = item_dict.get('metadata', {}) - elif hasattr(item, '__dict__'): - # Regular object with attributes - content_markdown = getattr(item, 'markdown', None) - content_html = getattr(item, 'html', None) - - # Handle metadata - convert to dict if it's an object - metadata_obj = getattr(item, 'metadata', {}) - if hasattr(metadata_obj, 'model_dump'): - metadata = metadata_obj.model_dump() - elif hasattr(metadata_obj, '__dict__'): - metadata = metadata_obj.__dict__ - elif isinstance(metadata_obj, dict): - metadata = metadata_obj - else: - metadata = {} - elif isinstance(item, dict): - # Already a dictionary - content_markdown = item.get('markdown') - content_html = item.get('html') - metadata = item.get('metadata', {}) - - # Ensure metadata is a dict (not an object) - if not isinstance(metadata, dict): - if hasattr(metadata, 'model_dump'): - metadata = metadata.model_dump() - elif hasattr(metadata, '__dict__'): - metadata = metadata.__dict__ - else: - metadata = {} - - # Extract URL and title from metadata - page_url = metadata.get("sourceURL", metadata.get("url", "Unknown URL")) - title = metadata.get("title", "") - - # Re-check crawled page URL against policy - page_blocked = check_website_access(page_url) - if page_blocked: - logger.info("Blocked crawled page %s by rule %s", page_blocked["host"], page_blocked["rule"]) - pages.append({ - "url": page_url, "title": title, "content": "", "raw_content": "", - "error": page_blocked["message"], - "blocked_by_policy": {"host": page_blocked["host"], "rule": page_blocked["rule"], "source": page_blocked["source"]}, - }) - continue - - # Choose content (prefer markdown) - content = content_markdown or content_html or "" - - pages.append({ - "url": page_url, - "title": title, - "content": content, - "raw_content": content, - "metadata": metadata # Now guaranteed to be a dict - }) + "error": ( + "web_crawl has no available backend. " + "Set FIRECRAWL_API_KEY (or FIRECRAWL_API_URL for " + f"self-hosted){_firecrawl_backend_help_suffix()}, " + "or set TAVILY_API_KEY for Tavily. " + "Alternatively use web_search + web_extract instead." + ), + }, + ensure_ascii=False, + ) - response = {"results": pages} - - pages_crawled = len(response.get('results', [])) - logger.info("Crawled %d pages", pages_crawled) - - debug_call_data["pages_crawled"] = pages_crawled - debug_call_data["original_response_size"] = len(json.dumps(response)) - - # Process each result with LLM if enabled - if use_llm_processing and auxiliary_available: - logger.info("Processing crawled content with LLM (parallel)...") - debug_call_data["processing_applied"].append("llm_processing") - - # Prepare tasks for parallel processing - async def process_single_crawl_result(result): - """Process a single crawl result with LLM and return updated result with metrics.""" - page_url = result.get('url', 'Unknown URL') - title = result.get('title', '') - content = result.get('content', '') - - if not content: - return result, None, "no_content" - - original_size = len(content) - - # Process content with LLM - processed = await process_content_with_llm( - content, page_url, title, effective_model, min_length - ) - - if processed: - processed_size = len(processed) - compression_ratio = processed_size / original_size if original_size > 0 else 1.0 - - # Update result with processed content - result['raw_content'] = content - result['content'] = processed - - metrics = { - "url": page_url, - "original_size": original_size, - "processed_size": processed_size, - "compression_ratio": compression_ratio, - "model_used": effective_model - } - return result, metrics, "processed" - else: - metrics = { - "url": page_url, - "original_size": original_size, - "processed_size": original_size, - "compression_ratio": 1.0, - "model_used": None, - "reason": "content_too_short" - } - return result, metrics, "too_short" - - # Run all LLM processing in parallel - results_list = response.get('results', []) - tasks = [process_single_crawl_result(result) for result in results_list] - processed_results = await asyncio.gather(*tasks) - - # Collect metrics and print results - for result, metrics, status in processed_results: - page_url = result.get('url', 'Unknown URL') - if status == "processed": - debug_call_data["compression_metrics"].append(metrics) - debug_call_data["pages_processed_with_llm"] += 1 - logger.info("%s (processed)", page_url) - elif status == "too_short": - debug_call_data["compression_metrics"].append(metrics) - logger.info("%s (no processing - content too short)", page_url) - else: - logger.warning("%s (no content to process)", page_url) - else: - if use_llm_processing and not auxiliary_available: - logger.warning("LLM processing requested but no auxiliary model available, returning raw content") - debug_call_data["processing_applied"].append("llm_processing_unavailable") - # Print summary of crawled pages for debugging (original behavior) - for result in response.get('results', []): - page_url = result.get('url', 'Unknown URL') - content_length = len(result.get('content', '')) - logger.info("%s (%d characters)", page_url, content_length) - - # Trim output to minimal fields per entry: title, content, error - trimmed_results = [ - { - "url": r.get("url", ""), - "title": r.get("title", ""), - "content": r.get("content", ""), - "error": r.get("error"), - **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {}), - } - for r in response.get("results", []) - ] - trimmed_response = {"results": trimmed_results} - - result_json = json.dumps(trimmed_response, indent=2, ensure_ascii=False) - # Clean base64 images from crawled content - cleaned_result = clean_base64_images(result_json) - - debug_call_data["final_response_size"] = len(cleaned_result) - debug_call_data["processing_applied"].append("base64_image_removal") - - # Log debug information - _debug.log_call("web_crawl_tool", debug_call_data) - _debug.save() - - return cleaned_result - except Exception as e: error_msg = f"Error crawling website: {str(e)}" logger.debug("%s", error_msg) @@ -2098,21 +1333,6 @@ async def process_single_crawl_result(result): # Convenience function to check Firecrawl credentials -def check_firecrawl_api_key() -> bool: - """ - Check whether the Firecrawl backend is available. - - Availability is true when either: - 1) direct Firecrawl config (`FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL`), or - 2) Firecrawl gateway origin + Nous Subscriber access token - (fallback when direct Firecrawl is not configured). - - Returns: - bool: True if direct Firecrawl or the tool-gateway can be used. - """ - return _has_direct_firecrawl_config() or _is_tool_gateway_ready() - - def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() diff --git a/toolsets.py b/toolsets.py index 5e34a0548c87..c664136c52a0 100644 --- a/toolsets.py +++ b/toolsets.py @@ -107,6 +107,17 @@ "includes": [] }, + "video_gen": { + "description": ( + "Video generation tools. Single ``video_generate`` tool covers " + "text-to-video (prompt only) and image-to-video (prompt + " + "image_url) — the active backend auto-routes. Configure via " + "``hermes tools`` → Video Generation." + ), + "tools": ["video_generate"], + "includes": [] + }, + "computer_use": { "description": ( "Background macOS desktop control via cua-driver — screenshots, " diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 41cbdd05e37b..230387ce23b0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5155,94 +5155,37 @@ def _(rid, params: dict) -> dict: @method("model.options") def _(rid, params: dict) -> dict: try: - from hermes_cli.model_switch import list_authenticated_providers - from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS + from hermes_cli.inventory import build_models_payload, load_picker_context session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None - cfg = _load_cfg() - current_provider = getattr(agent, "provider", "") or "" - current_model = getattr(agent, "model", "") or _resolve_model() - current_base_url = getattr(agent, "base_url", "") or "" - # list_authenticated_providers already populates each provider's - # "models" with the curated list (same source as `hermes model` and - # classic CLI's /model picker). Do NOT overwrite with live - # provider_model_ids() — that bypasses curation and pulls in - # non-agentic models (e.g. Nous /models returns ~400 IDs including - # TTS, embeddings, rerankers, image/video generators). - user_provs = ( - cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} - ) - custom_provs = ( - cfg.get("custom_providers") - if isinstance(cfg.get("custom_providers"), list) - else [] + # Layer agent-session state on top of disk config — once an agent + # is spawned, IT owns the live provider/model/base_url. Empty + # agent attributes must NOT clobber disk config (with_overrides + # is truthy-only). + ctx = load_picker_context().with_overrides( + current_provider=getattr(agent, "provider", "") if agent else "", + current_model=( + (getattr(agent, "model", "") if agent else "") or _resolve_model() + ), + current_base_url=getattr(agent, "base_url", "") if agent else "", ) - authenticated = list_authenticated_providers( - current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, - user_providers=user_provs, - custom_providers=custom_provs, + # picker_hints + canonical_order produce the TUI's required shape: + # `authenticated`/`auth_type`/`key_env`/`warning` per row, in + # CANONICAL_PROVIDERS declaration order. include_unconfigured=True + # so the picker can show the full provider universe (with the + # setup-hint warning attached) instead of only authed rows. + # Curated model lists are preserved — list_authenticated_providers + # populates `models` from the curated catalog, not provider_model_ids + # (which would pull non-agentic models like TTS/embeddings/etc.). + payload = build_models_payload( + ctx, + include_unconfigured=True, + picker_hints=True, + canonical_order=True, max_models=50, ) - - # Mark authenticated providers and build lookup by slug - authed_map: dict = {} - authed_extra: list = [] # user-defined/custom not in CANONICAL_PROVIDERS - canonical_slugs = {e.slug for e in CANONICAL_PROVIDERS} - for p in authenticated: - p["authenticated"] = True - authed_map[p["slug"]] = p - if p["slug"] not in canonical_slugs: - authed_extra.append(p) - - # Build final list in CANONICAL_PROVIDERS order, merging auth data - from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg - - ordered: list = [] - for entry in CANONICAL_PROVIDERS: - if entry.slug in authed_map: - ordered.append(authed_map[entry.slug]) - else: - pconfig = _auth_reg.get(entry.slug) - auth_type = pconfig.auth_type if pconfig else "api_key" - key_env = ( - pconfig.api_key_env_vars[0] - if (pconfig and pconfig.api_key_env_vars) - else "" - ) - if auth_type == "api_key" and key_env: - warning = f"paste {key_env} to activate" - else: - warning = f"run `hermes model` to configure ({auth_type})" - ordered.append( - { - "slug": entry.slug, - "name": _PROVIDER_LABELS.get(entry.slug, entry.label), - "is_current": entry.slug == current_provider, - "is_user_defined": False, - "models": [], - "total_models": 0, - "source": "built-in", - "authenticated": False, - "auth_type": auth_type, - "key_env": key_env, - "warning": warning, - } - ) - - # Append user-defined/custom providers not in canonical list - ordered.extend(authed_extra) - - return _ok( - rid, - { - "providers": ordered, - "model": current_model, - "provider": current_provider, - }, - ) + return _ok(rid, payload) except Exception as e: return _err(rid, 5033, str(e)) @@ -5261,7 +5204,7 @@ def _(rid, params: dict) -> dict: try: from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import is_managed, save_env_value - from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.inventory import build_models_payload, load_picker_context slug = (params.get("slug") or "").strip() api_key = (params.get("api_key") or "").strip() @@ -5287,43 +5230,32 @@ def _(rid, params: dict) -> dict: # Save the key to ~/.hermes/.env env_var = pconfig.api_key_env_vars[0] save_env_value(env_var, api_key) - # Also set in current process so list_authenticated_providers sees it + # Also set in current process so the refreshed inventory sees it. import os os.environ[env_var] = api_key - # Refresh provider data - cfg = _load_cfg() + # Refresh provider data via the shared inventory builder so this + # surface stays in lock-step with model.options + dashboard + # /api/model/options. picker_hints=True ensures the returned row + # carries `authenticated` for the TUI frontend. session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None - current_provider = getattr(agent, "provider", "") or "" - current_model = getattr(agent, "model", "") or _resolve_model() - current_base_url = getattr(agent, "base_url", "") or "" - - providers = list_authenticated_providers( - current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, - user_providers=( - cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} + ctx = load_picker_context().with_overrides( + current_provider=getattr(agent, "provider", "") if agent else "", + current_model=( + (getattr(agent, "model", "") if agent else "") or _resolve_model() ), - custom_providers=( - cfg.get("custom_providers") - if isinstance(cfg.get("custom_providers"), list) - else [] - ), - max_models=50, + current_base_url=getattr(agent, "base_url", "") if agent else "", ) - - # Find the newly-authenticated provider - provider_data = None - for p in providers: - if p["slug"] == slug: - provider_data = p - break - - if not provider_data: - # Key was saved but provider didn't appear — still return success + payload = build_models_payload( + ctx, picker_hints=True, max_models=50, + ) + provider_data = next( + (p for p in payload["providers"] if p["slug"] == slug), None + ) + if provider_data is None: + # Key was saved but provider didn't appear — still return success. provider_data = { "slug": slug, "name": pconfig.name, @@ -5332,7 +5264,8 @@ def _(rid, params: dict) -> dict: "total_models": 0, "authenticated": True, } - + # picker_hints sets `authenticated` from the row state, but the + # synthetic fallback above doesn't go through that path. provider_data["authenticated"] = True return _ok(rid, {"provider": provider_data}) except Exception as e: diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx index 71c491455893..6020d50bdab6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx @@ -1,53 +1,38 @@ import type { ReactNode } from 'react' import React from 'react' -import { c as _c } from 'react/compiler-runtime' - -import { supportsHyperlinks } from '../supports-hyperlinks.js' import Text from './Text.js' export type Props = { readonly children?: ReactNode readonly url: string + // Kept for backwards-compat: prior versions rendered `fallback` instead of + // the linked content on terminals where supportsHyperlinks() was false. We + // now always emit the hyperlink metadata so the in-process click/hover + // dispatcher can act on it regardless of the terminal's own OSC 8 support + // (see comment in the function body), so `fallback` is no longer wired up. + // Leaving the prop on the interface keeps existing call sites compiling. readonly fallback?: ReactNode } -export default function Link(t0: Props) { - const $ = _c(5) - - const { children, url, fallback } = t0 - +export default function Link({ children, url }: Props): React.ReactNode { + // Always emit : the renderer stores `hyperlink` per cell in the + // screen buffer, which the click dispatcher (Ink.getHyperlinkAt → + // onHyperlinkClick) reads on mouseup to open URLs externally. Gating this + // on supportsHyperlinks() broke clicks in Apple Terminal / any terminal + // not on the OSC 8 allowlist — the cell's hyperlink field stayed empty, + // so the click pipeline had nothing to open. + // + // The OSC 8 escape itself is emitted unconditionally by the renderer + // (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts). + // Terminals that don't understand OSC 8 silently strip it — including + // Apple Terminal, which is why hover/click affordance has to come from + // the in-process overlay (applyHyperlinkHoverHighlight) and not from the + // terminal's own link rendering. const content = children ?? url - if (supportsHyperlinks()) { - let t1 - - if ($[0] !== content || $[1] !== url) { - t1 = ( - - {content} - - ) - $[0] = content - $[1] = url - $[2] = t1 - } else { - t1 = $[2] - } - - return t1 - } - - const t1 = fallback ?? content - let t2 - - if ($[3] !== t1) { - t2 = {t1} - $[3] = t1 - $[4] = t2 - } else { - t2 = $[4] - } - - return t2 + return ( + + {content} + + ) } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts b/ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts new file mode 100644 index 000000000000..92a43eb06ad8 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts @@ -0,0 +1,52 @@ +import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js' + +/** + * Highlight every cell whose OSC 8 hyperlink matches `hoveredUrl` by inverting + * its style. This is the cursor-hover affordance for clickable links: terminal + * applications can't change the system mouse cursor, so we light up the link + * itself when the pointer is over it. Same overlay machinery as + * applySearchHighlight — post-layout, pure SGR, picked up by the diff. + * + * Returns true if any cell was highlighted. The caller decides whether to + * promote that into a full-frame damage request — for hover specifically, + * full damage is only useful on enter/leave/change transitions (so the + * previous frame's inverted cells get re-emitted), not on every steady-state + * frame the pointer sits on the link. + */ +export function applyHyperlinkHoverHighlight( + screen: Screen, + hoveredUrl: string | undefined, + stylePool: StylePool +): boolean { + if (!hoveredUrl) { + return false + } + + const w = screen.width + const height = screen.height + let applied = false + + for (let row = 0; row < height; row++) { + const rowOff = row * w + + for (let col = 0; col < w; col++) { + const cell = cellAtIndex(screen, rowOff + col) + + // Skip SpacerTail — the head cell at col-1 owns the hyperlink, and + // setCellStyleId on the tail would split the styling of a wide-char + // glyph mid-cell. The head's restyle covers both halves. + if (cell.width === CellWidth.SpacerTail) { + continue + } + + if (cell.hyperlink !== hoveredUrl) { + continue + } + + applied = true + setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId)) + } + } + + return applied +} diff --git a/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts new file mode 100644 index 000000000000..31039491f899 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events' +import React from 'react' +import { describe, expect, it } from 'vitest' + +import Text from './components/Text.js' +import Ink from './ink.js' +import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js' + +class FakeTty extends EventEmitter { + chunks: string[] = [] + columns = 20 + rows = 5 + isTTY = true + + write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean { + this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + cb?.() + return true + } +} + +const tick = () => new Promise(resolve => queueMicrotask(resolve)) + +describe('Ink resize healing', () => { + it('heals same-dimension alt-screen resize events with an erase before repaint', async () => { + const stdout = new FakeTty() + const stdin = new FakeTty() + const stderr = new FakeTty() + const ink = new Ink({ + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + ink.setAltScreenActive(true) + ink.render(React.createElement(Text, null, 'hello')) + ink.onRender() + stdout.chunks = [] + + stdout.emit('resize') + ink.onRender() + await tick() + + expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME) + + ink.unmount() + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index c4669847e68d..8cdfe7813958 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -24,6 +24,7 @@ import { KeyboardEvent } from './events/keyboard-event.js' import { FocusManager } from './focus.js' import { emptyFrame, type Frame, type FrameEvent } from './frame.js' import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js' +import { applyHyperlinkHoverHighlight } from './hyperlinkHover.js' import instances from './instances.js' import { LogUpdate } from './log-update.js' import { nodeCache } from './node-cache.js' @@ -150,6 +151,21 @@ export type Options = { patchConsole: boolean waitUntilExit?: () => Promise onFrame?: (event: FrameEvent) => void + /** + * Called when a click lands on a cell with an OSC 8 hyperlink (or a + * plain-text URL detected by findPlainTextUrlAt). The host is responsible + * for opening the URL — `child_process.spawn` with an argv array (NOT + * shell-mode) to the platform's native opener: `open` on macOS, + * `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid + * `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL + * through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or + * reinterpreted), which both breaks plain URLs with `&` in query + * strings and undermines any caller-side protocol allowlist. Without + * this wired up, links rendered by `` look underlined but do + * nothing on click in any terminal where mouse tracking is on + * (Cmd+click is consumed by the TUI, not Terminal.app). + */ + onHyperlinkClick?: (url: string) => void } export default class Ink { private readonly log: LogUpdate @@ -232,6 +248,19 @@ export default class Ink { // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. private readonly hoveredNodes = new Set() + + // The OSC 8 hyperlink URL under the pointer, or undefined when the cursor + // isn't on a link. Updated from dispatchHover; consumed by the render-pass + // overlay (applyHyperlinkHoverHighlight) to invert link cells under the + // pointer. This is the closest the TUI can get to the desktop's + // cursor-changes-on-hover affordance — terminals don't expose cursor + // shape control to applications. + private hoveredHyperlink: string | undefined = undefined + + // Last value of hoveredHyperlink that we actually painted. Compared in + // onRender so we can scope full-screen damage to enter/leave/change + // transitions, not every steady-state hover frame. + private lastRenderedHoveredHyperlink: string | undefined = undefined // Set by via setAltScreenActive(). Controls the // renderer's cursor.y clamping (keeps cursor in-viewport to avoid // LF-induced scroll when screen.height === terminalRows) and gates @@ -287,6 +316,14 @@ export default class Ink { this.restoreStderr = this.patchStderr() } + // Host-supplied hyperlink-open callback. The mouse-event pipeline + // (App.tsx → onOpenHyperlink → Ink.openHyperlink → onHyperlinkClick) + // is fully wired internally; without this assignment the optional + // chain in openHyperlink() bails silently and clicks on URLs do + // nothing. The field stays writable so tests / debug overlays can + // still rebind it after construction. + this.onHyperlinkClick = options.onHyperlinkClick + this.terminal = { stdout: options.stdout, stderr: options.stderr @@ -447,17 +484,22 @@ export default class Ink { private handleResize = () => { const cols = this.options.stdout.columns || 80 const rows = this.options.stdout.rows || 24 - - // Terminals often emit 2+ resize events for one user action (window - // settling). Same-dimension events are no-ops; skip to avoid redundant - // frame resets and renders. - if (cols === this.terminalColumns && rows === this.terminalRows) { + const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows + + // Terminals often emit 2+ resize events for one user action + // (window settling). Same-dimension events are usually no-ops, + // but in alt-screen mode a same-dimension resize can signal a + // terminal host reflow or buffer restore that leaves stale glyphs + // on the physical screen — treat it as a repaint signal. + if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) { return } - this.terminalColumns = cols - this.terminalRows = rows - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + if (dimsChanged) { + this.terminalColumns = cols + this.terminalRows = rows + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + } // Pending throttled/drain work captured stale dims — cancel so // the upcoming microtask owns the next frame. @@ -484,26 +526,7 @@ export default class Ink { // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { - if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - - // One last repaint after the resize burst settles closes any host-side - // reflow drift the normal diff path can't see. - this.resizeSettleTimer = setTimeout(() => { - this.resizeSettleTimer = null - - if (!this.canAltScreenRepaint()) { - return - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.render(this.currentNode!) - }, 160) + this.prepareAltScreenResizeRepaint() } // Already queued: later events in this burst updated dims/alt-screen @@ -536,6 +559,36 @@ export default class Ink { ) } + private prepareAltScreenResizeRepaint(): void { + // Clear any pending settle timer from a previous resize burst so + // rapid events don't stack redundant delayed repaints. (handleResize + // also clears this, but the defensive clear keeps the method safe + // if it's ever called from other code paths.) + if (this.resizeSettleTimer !== null) { + clearTimeout(this.resizeSettleTimer) + this.resizeSettleTimer = null + } + + if (this.altScreenMouseTracking) { + this.options.stdout.write(ENABLE_MOUSE_TRACKING) + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + + this.resizeSettleTimer = setTimeout(() => { + this.resizeSettleTimer = null + + if (!this.canAltScreenRepaint()) { + return + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode!) + }, 160) + } + resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} @@ -769,6 +822,26 @@ export default class Ink { // Position-highlight (below) overlays CURRENT (yellow) on top. hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool) + // Hyperlink hover overlay: inverts every cell of the link currently + // under the pointer. Cheap-ish (linear scan of the visible buffer), + // only fires when hoveredHyperlink is set. + // + // hlActive controls full-screen damage (used by selection/search to + // make sure the previous frame's inverted cells get re-diffed when + // the highlight set changes). For hover, the *transition* is what + // needs the full-damage hammer — enter / leave / change-to-other-link. + // During steady-state hover the painted cells don't change and the + // ordinary per-cell diff handles the no-op. Folding the steady-state + // case into hlActive would burn full-screen diffs every frame while + // the pointer just sits on the link. + const hoverApplied = applyHyperlinkHoverHighlight(frame.screen, this.hoveredHyperlink, this.stylePool) + const hoverTransition = this.hoveredHyperlink !== this.lastRenderedHoveredHyperlink + this.lastRenderedHoveredHyperlink = this.hoveredHyperlink + + if (hoverApplied && hoverTransition) { + hlActive = true + } + // Position-based CURRENT: write yellow at positions[currentIdx] + // rowOffset. No scanning — positions came from a prior scan when // the message first mounted. Message-relative + rowOffset = screen. @@ -862,8 +935,9 @@ export default class Ink { const optimized = optimize(diff) const optimizeMs = performance.now() - tOptimize const hasDiff = optimized.length > 0 + const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint - if (this.altScreenActive && hasDiff) { + if (this.altScreenActive && (hasDiff || needsAltScreenErase)) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR @@ -883,7 +957,7 @@ export default class Ink { // resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is // supported, the clear+paint lands atomically; otherwise the final state // is still healed even if the repaint is visible. - if (this.needsEraseBeforePaint) { + if (needsAltScreenErase) { this.needsEraseBeforePaint = false optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH) } else { @@ -1005,7 +1079,7 @@ export default class Ink { this.lastDrainMs = 0 // Only track drain on TTY. Piped/non-TTY stdout bypasses flow control. - const trackDrain = this.options.stdout.isTTY && hasDiff + const trackDrain = this.options.stdout.isTTY && optimized.length > 0 const drainStart = trackDrain ? tWrite : 0 if (trackDrain) { @@ -1182,6 +1256,16 @@ export default class Ink { this.altScreenActive = active this.altScreenMouseTracking = active && mouseTracking + // Hover state is alt-screen-scoped: dispatchHover is gated on + // altScreenActive, so once we leave the alt screen there's no path to + // clear it on our own. Without this reset, remounting + // would render a phantom hover highlight from the previous session + // until the next mouse-move event arrived. Clear both the live value + // and the last-rendered tracker so the next onRender sees no transition + // and no overlay. + this.hoveredHyperlink = undefined + this.lastRenderedHoveredHyperlink = undefined + if (active) { this.resetFramesForAltScreen() } else { @@ -1770,6 +1854,34 @@ export default class Ink { } dispatchHover(this.rootNode, col, row, this.hoveredNodes) + + // Hover affordance for hyperlinks: read the cell at the pointer, store + // its URL (or clear when the pointer leaves a link), and request a + // repaint when the value changes. The render-pass overlay paints the + // highlight; we just track which URL is "hot". + // + // IMPORTANT: bypass getHyperlinkAt() here — its plain-text URL fallback + // (findPlainTextUrlAt) would return URLs for cells whose `cell.hyperlink` + // is undefined, which the overlay (applyHyperlinkHoverHighlight) + // wouldn't match. That'd burn re-renders without ever producing an + // affordance. Read the OSC 8 hyperlink directly off the cell so the + // hover state is a 1:1 fit for what the overlay can paint. The + // plain-text URL fallback still works for clicks; hover is a strictly + // weaker signal and OK to skip on plain-text URLs. + const screen = this.frontFrame.screen + const cell = cellAt(screen, col, row) + let next = cell?.hyperlink + + // SpacerTail (second half of a wide-char / emoji glyph) stores the + // hyperlink on the head cell at col-1. Same logic as getHyperlinkAt. + if (!next && cell?.width === CellWidth.SpacerTail && col > 0) { + next = cellAt(screen, col - 1, row)?.hyperlink + } + + if (next !== this.hoveredHyperlink) { + this.hoveredHyperlink = next + this.scheduleRender() + } } dispatchKeyboardEvent(parsedKey: ParsedKey): void { const target = this.focusManager.activeElement ?? this.rootNode @@ -1814,8 +1926,13 @@ export default class Ink { } /** - * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen - * mode. Set by FullscreenLayout via useLayoutEffect. + * Optional callback fired when clicking a cell that has an associated URL + * in fullscreen mode. `url` may be either an OSC 8 hyperlink (from a + * `` render or external OSC 8 escape that landed in the buffer) or + * a plain-text URL detected on the clicked row by findPlainTextUrlAt + * (App.tsx routes both into the same callback). Set from the host via + * the `onHyperlinkClick` Render/Ink option, or directly on the instance + * for late-bound test scenarios. */ onHyperlinkClick: ((url: string) => void) | undefined diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts index 1d7af3803b4f..41d02d52a0d1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/root.ts +++ b/ui-tui/packages/hermes-ink/src/ink/root.ts @@ -44,6 +44,22 @@ export type RenderOptions = { * Called after each frame render with timing and flicker information. */ onFrame?: (event: FrameEvent) => void + + /** + * Called when a click lands on a cell with an OSC 8 hyperlink (or a + * plain-text URL the renderer detects on the same row). The host owns + * the actual open — `child_process.spawn` with an argv array (NOT + * shell-mode) to the platform's native opener: `open` on macOS, + * `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid + * `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL + * through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or + * reinterpreted as command syntax), which both breaks plain URLs with + * `&` in query strings and undermines any protocol allowlist on the + * caller side. Hermes wires this in `entry.tsx`; library users who + * don't pass it will see clickable underline styling but no action on + * click in any terminal where mouse tracking is on. + */ + onHyperlinkClick?: (url: string) => void } export type Instance = { @@ -138,7 +154,8 @@ export async function createRoot({ stderr = process.stderr, exitOnCtrlC = true, patchConsole = true, - onFrame + onFrame, + onHyperlinkClick }: RenderOptions = {}): Promise { // See wrappedRender — preserve microtask boundary from the old WASM await. await Promise.resolve() @@ -149,7 +166,8 @@ export async function createRoot({ stderr, exitOnCtrlC, patchConsole, - onFrame + onFrame, + onHyperlinkClick }) // Register in the instances map so that code that looks up the Ink diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index cfb0cd2f3f0f..bfd56fa19d6c 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -9,6 +9,7 @@ import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js' +import { openExternalUrl } from './lib/openExternalUrl.js' import { resetTerminalModes } from './lib/terminalModes.js' if (!process.stdin.isTTY) { @@ -85,4 +86,14 @@ const onFrame = } : undefined -ink.render(, { exitOnCtrlC: false, onFrame }) +ink.render(, { + exitOnCtrlC: false, + onFrame, + // Open URLs in the user's default browser when a link cell is clicked. + // The TUI's mouse tracking captures click events before Terminal.app's + // own URL detection can fire, so without this hook clicks on `` + // do nothing in any terminal where mouseTracking is on. + onHyperlinkClick: url => { + openExternalUrl(url) + } +}) diff --git a/ui-tui/src/lib/openExternalUrl.test.ts b/ui-tui/src/lib/openExternalUrl.test.ts new file mode 100644 index 000000000000..3d280da36870 --- /dev/null +++ b/ui-tui/src/lib/openExternalUrl.test.ts @@ -0,0 +1,217 @@ +import type { ChildProcess, spawn as SpawnFn } from 'node:child_process' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { openCommand, openExternalUrl, parseSafeUrl } from './openExternalUrl.js' + +type SpawnLike = typeof SpawnFn + +describe('parseSafeUrl', () => { + it('accepts http and https URLs', () => { + expect(parseSafeUrl('https://example.com')?.href).toBe('https://example.com/') + expect(parseSafeUrl('http://example.com/path?q=1')?.href).toBe('http://example.com/path?q=1') + }) + + it('rejects file: URLs (would let a hostile model trigger arbitrary local handlers)', () => { + expect(parseSafeUrl('file:///etc/passwd')).toBeNull() + }) + + it('rejects javascript:, data:, and vbscript: URLs', () => { + expect(parseSafeUrl('javascript:alert(1)')).toBeNull() + expect(parseSafeUrl('data:text/html,')).toBeNull() + expect(parseSafeUrl('vbscript:msgbox')).toBeNull() + }) + + it('rejects mailto:, ftp:, and other non-web protocols', () => { + expect(parseSafeUrl('mailto:test@example.com')).toBeNull() + expect(parseSafeUrl('ftp://example.com')).toBeNull() + expect(parseSafeUrl('ssh://example.com')).toBeNull() + }) + + it('rejects unparseable strings', () => { + expect(parseSafeUrl('not a url')).toBeNull() + expect(parseSafeUrl('')).toBeNull() + }) + + it('rejects non-string inputs defensively', () => { + expect(parseSafeUrl(undefined as unknown as string)).toBeNull() + expect(parseSafeUrl(null as unknown as string)).toBeNull() + expect(parseSafeUrl(123 as unknown as string)).toBeNull() + }) +}) + +describe('openCommand', () => { + it('returns macOS open(1) on darwin', () => { + expect(openCommand('darwin')).toEqual({ command: 'open', args: [] }) + }) + + it('routes through explorer.exe on win32 — not cmd.exe — so URLs with & | ^ < > stay safe', () => { + // win32 must not route through cmd.exe — see comment in openCommand. + // Test pins the contract that we use explorer.exe (non-shell) so URLs + // with `&`/`|`/`^`/`<`/`>` aren't reparsed by cmd's tokenizer. + const cmd = openCommand('win32') + expect(cmd?.command).toBe('explorer.exe') + expect(cmd?.args).toEqual([]) + }) + + it('falls back to xdg-open on linux/bsd', () => { + expect(openCommand('linux')).toEqual({ command: 'xdg-open', args: [] }) + expect(openCommand('freebsd')).toEqual({ command: 'xdg-open', args: [] }) + expect(openCommand('openbsd')).toEqual({ command: 'xdg-open', args: [] }) + }) + + it('returns null for unknown platforms (aix, sunos, cygwin, etc.)', () => { + // Avoid optimistically dispatching xdg-open on platforms where it + // probably isn't installed — the caller's `if (!command) return false` + // path surfaces "no opener" honestly instead. + expect(openCommand('aix')).toBeNull() + expect(openCommand('sunos')).toBeNull() + expect(openCommand('cygwin')).toBeNull() + expect(openCommand('haiku')).toBeNull() + expect(openCommand('')).toBeNull() + }) +}) + +describe('openExternalUrl on unsupported platforms', () => { + it('returns false without spawning when the platform has no known opener', () => { + const spawn = vi.fn() as unknown as SpawnLike + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'aix' })).toBe(false) + expect(spawn).not.toHaveBeenCalled() + }) +}) + +describe('openExternalUrl', () => { + // Tracks the most recent fake child so tests can inspect its 'error' + // handlers and emit on it. Use a loose EventEmitter alias rather than + // ChildProcess — the latter's `unref` signature is strictly `() => void` + // and doesn't accept `vi.fn()` without a generic. + type FakeChild = EventEmitter & { unref: () => void } + + function mockSpawn(): { + spawn: SpawnLike + calls: Array<{ command: string; args: readonly string[] }> + lastChild: () => FakeChild | undefined + } { + const calls: Array<{ command: string; args: readonly string[] }> = [] + let lastChild: FakeChild | undefined + + const spawn = vi.fn((command: string, args: readonly string[]) => { + calls.push({ command, args }) + + // Use a real EventEmitter so .once('error', cb) wires up correctly + // and we can synthesize async failures by emitting 'error' from the + // test. The cast is the same one Node uses internally — ChildProcess + // extends EventEmitter. + const child = new EventEmitter() as FakeChild + + child.unref = () => {} + lastChild = child + + return child as unknown as ChildProcess + }) as unknown as SpawnLike + + return { spawn, calls, lastChild: () => lastChild } + } + + it('opens a normal https URL via the platform command', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('https://example.com/foo', { spawn, platform: () => 'darwin' })).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.command).toBe('open') + expect(calls[0]!.args).toEqual(['https://example.com/foo']) + }) + + it('uses xdg-open on linux', () => { + const { spawn, calls } = mockSpawn() + + openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' }) + expect(calls[0]!.command).toBe('xdg-open') + }) + + it('refuses to open file: URLs and does not spawn', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('file:///etc/passwd', { spawn, platform: () => 'darwin' })).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('refuses to open javascript: URLs and does not spawn', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('javascript:alert(1)', { spawn, platform: () => 'darwin' })).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('passes URLs containing shell metacharacters as plain args (no shell interpolation)', () => { + const { spawn, calls } = mockSpawn() + + // A URL with `; & ` plus URL-encoded backticks. spawn(..., args) without + // shell:true means the OS receives these as a single argv element. + const hostile = 'https://example.com/path%3Bevil%20%26%20rm%20-rf' + + openExternalUrl(hostile, { spawn, platform: () => 'darwin' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.args[calls[0]!.args.length - 1]).toBe(hostile) + }) + + it('on win32, a URL with & | ^ < > is forwarded as a single argv element via explorer.exe', () => { + const { spawn, calls } = mockSpawn() + + // Plain http URL with & in query (very common, e.g. analytics params) + // plus other cmd metacharacters that would split or reinterpret the + // command if win32 routed through cmd.exe /c start. Note that the URL + // parser percent-encodes `<` and `>` (which is fine — encoded forms + // can't be reinterpreted by any shell), but `&`, `|`, `^` survive + // and would tokenize cmd.exe if we ever regressed back to it. + const meta = 'https://example.com/q?a=1&b=2|c^df' + + expect(openExternalUrl(meta, { spawn, platform: () => 'win32' })).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.command).toBe('explorer.exe') + // The URL must arrive as exactly one argv element — not split on &/|/^/etc. + const forwarded = calls[0]!.args[0]! + expect(calls[0]!.args).toHaveLength(1) + expect(forwarded).toContain('a=1&b=2') + expect(forwarded).toContain('|c^d') + }) + + it('on win32, common http URLs with & query params are forwarded intact', () => { + const { spawn, calls } = mockSpawn() + const url = 'https://example.com/search?q=foo&page=2&utm_source=hermes' + + openExternalUrl(url, { spawn, platform: () => 'win32' }) + expect(calls[0]!.args).toEqual([url]) + }) + + it('returns false on synchronous spawn failure', () => { + const spawn = vi.fn(() => { + throw new Error('ENOENT') + }) as unknown as SpawnLike + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(false) + }) + + it('does not crash the host when the spawned process emits an async error', () => { + // Real-world case: `xdg-open` / `explorer.exe` missing on PATH. spawn() + // returns a ChildProcess synchronously, then emits 'error' once the + // exec actually fails. Without a registered 'error' listener, Node + // re-throws the event as an uncaught exception → TUI dies. We attach + // a no-op listener inside openExternalUrl; this test pins that contract. + const { spawn, lastChild } = mockSpawn() + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(true) + + const child = lastChild() + expect(child).toBeDefined() + // Must have a listener registered BEFORE we emit, or EventEmitter will + // throw synchronously here (which is exactly the crash we're preventing). + expect(child!.listenerCount('error')).toBeGreaterThan(0) + + // Emit and assert it doesn't throw. If the listener weren't attached, + // this would throw 'Unhandled error' and fail the test. + expect(() => child!.emit('error', new Error('ENOENT: xdg-open not found'))).not.toThrow() + }) +}) diff --git a/ui-tui/src/lib/openExternalUrl.ts b/ui-tui/src/lib/openExternalUrl.ts new file mode 100644 index 000000000000..6c095a8d16f7 --- /dev/null +++ b/ui-tui/src/lib/openExternalUrl.ts @@ -0,0 +1,158 @@ +import { spawn, type SpawnOptions } from 'node:child_process' +import { platform } from 'node:os' + +/** + * Opens an external URL in the user's default browser/handler. + * + * Wired into the Ink instance via `onHyperlinkClick` in entry.tsx, so any + * mouse click on a `` cell (or a row containing a plain-text URL the + * renderer detected) goes here. Mouse tracking inside the TUI prevents + * Terminal.app's native Cmd+click from firing — the click is captured + * before the terminal application sees it — so we have to handle the open + * ourselves. + * + * Safety: + * - http(s) only. Anything else (`file:`, `data:`, `javascript:`, etc.) is + * rejected — a hostile model could otherwise emit `` + * and trick a click into running an arbitrary local handler. + * - Hostname is parsed via `URL`; only well-formed URLs are forwarded. + * - Spawned via `child_process.spawn` with arg array (no shell), so a URL + * containing shell metacharacters (`;`, `&`, backticks) cannot be + * interpreted as a command. + * + * Returns `true` if the spawn was attempted, `false` if the open could + * not proceed — covers (a) URL rejected by `parseSafeUrl` (non-http(s), + * malformed, etc.), (b) no known opener for the current platform + * (`openCommand` returned null), or (c) `spawn()` threw synchronously + * before the child was created. Async failures after spawn (`'error'` + * event because the binary couldn't exec) still return `true` because + * the spawn was attempted — the no-op error listener absorbs the event + * so the TUI doesn't crash, and the user just doesn't see their browser + * pop. + */ +export function openExternalUrl(rawUrl: string, dependencies: OpenDependencies = {}): boolean { + const url = parseSafeUrl(rawUrl) + + if (!url) { + return false + } + + const spawnFn = dependencies.spawn ?? spawn + const platformId = dependencies.platform?.() ?? platform() + + const command = openCommand(platformId) + + if (!command) { + return false + } + + try { + const child = spawnFn(command.command, [...command.args, url.toString()], { + // Detach so closing the TUI later doesn't kill the browser process, + // and ignore stdio so we don't leak FDs into our raw-mode terminal. + // Without `ignore` here, Chrome's stderr can land in the alt screen. + detached: true, + stdio: 'ignore' + } satisfies SpawnOptions) + + // Async failure path: spawn returns a ChildProcess synchronously even + // when the binary is missing (ENOENT on `xdg-open` / `explorer.exe`), + // unreachable (EACCES), or otherwise unusable — the failure surfaces + // later as an 'error' event. Without a handler, an unhandled 'error' + // on an EventEmitter crashes Node, which would tear down the whole + // TUI. Attach a no-op listener BEFORE unref() so the event has a + // consumer; we already returned `true` synchronously, so the user + // just won't see their browser open — same as if the URL had been + // rejected upstream. + child.once('error', () => { + // Intentional no-op. The TUI keeps running; user gets no browser + // pop, which is the failure mode we promised in the doc comment. + }) + + child.unref() + + return true + } catch { + // spawn can also throw synchronously on argv-validation failures + // (e.g. NUL in the path). Treat it as a no-op rather than crashing. + return false + } +} + +export type OpenDependencies = { + spawn?: typeof spawn + platform?: () => string +} + +/** + * Validate and normalize a URL for opening externally. + * Exported for testing. + */ +export function parseSafeUrl(value: string): null | URL { + if (!value || typeof value !== 'string') { + return null + } + + let parsed: URL + + try { + parsed = new URL(value) + } catch { + return null + } + + // http(s) only — opening file://, data:, javascript:, vbscript:, etc. + // would let a malicious model run a local handler with attacker-controlled + // input on a single click. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + + // Reject empty or all-whitespace hostnames defensively. URL parsing + // accepts URLs like 'http:///foo' on some Node versions; we don't want + // to forward those to `open`. + if (!parsed.hostname.trim()) { + return null + } + + return parsed +} + +type OpenCommand = { command: string; args: readonly string[] } + +/** + * Per-platform open command. We deliberately avoid `cmd.exe /c start` on + * Windows even though it's the canonical example, because `start` is a cmd + * builtin: the URL string is reparsed by cmd's command-line tokenizer and + * characters like `&`, `|`, `^`, `<`, `>` either break the command or get + * interpreted as additional commands. That undermines the protocol + * allowlist's safety story and also breaks plain http(s) URLs with `&` in + * query strings. `explorer.exe ` is the safe, non-shell alternative — + * it invokes the registered protocol handler for http(s) without going + * through cmd. Linux/BSD use `xdg-open` directly with no shell wrapping. + * + * Returns null for platforms where we don't know a safe opener (e.g. `aix`, + * `sunos`, `cygwin`). The caller's `if (!command) return false` path then + * surfaces "no opener" instead of optimistically trying `xdg-open` on a + * platform that probably doesn't have it. + */ +export function openCommand(platformId: string): OpenCommand | null { + if (platformId === 'darwin') { + return { command: 'open', args: [] } + } + + if (platformId === 'win32') { + return { command: 'explorer.exe', args: [] } + } + + // Linux + the BSD family ship xdg-open via xdg-utils. Everything else + // (aix, sunos, cygwin, haiku, etc.) returns null so openExternalUrl's + // command-not-found fallback fires honestly. + const XDG_OPEN_PLATFORMS = new Set(['linux', 'freebsd', 'openbsd', 'netbsd', 'dragonfly']) + + if (XDG_OPEN_PLATFORMS.has(platformId)) { + return { command: 'xdg-open', args: [] } + } + + return null +} diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index c8038576d3a8..b84f843d322f 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -66,6 +66,7 @@ declare module '@hermes/ink' { readonly exitOnCtrlC?: boolean readonly patchConsole?: boolean readonly onFrame?: (event: FrameEvent) => void + readonly onHyperlinkClick?: (url: string) => void } export type Instance = { diff --git a/uv.lock b/uv.lock index 713cd588fd64..a519cc2b1948 100644 --- a/uv.lock +++ b/uv.lock @@ -2092,6 +2092,7 @@ rl = [ { name = "wandb" }, ] slack = [ + { name = "aiohttp" }, { name = "slack-bolt" }, { name = "slack-sdk" }, ] @@ -2149,6 +2150,7 @@ requires-dist = [ { 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 == 'slack'", 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" }, diff --git a/web/package.json b/web/package.json index e1df1e132056..50456076b643 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "sync-assets": "rm -rf public/fonts public/ds-assets && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets", + "sync-assets": "node scripts/sync-assets.mjs", "predev": "npm run sync-assets", "prebuild": "npm run sync-assets", "dev": "vite", diff --git a/web/scripts/sync-assets.mjs b/web/scripts/sync-assets.mjs new file mode 100644 index 000000000000..19b0bafb6aab --- /dev/null +++ b/web/scripts/sync-assets.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Cross-platform replacement for the previous shell pipeline: +// +// rm -rf public/fonts public/ds-assets +// && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts +// && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets +// +// `rm -rf` / `cp -r` don't exist on Windows cmd.exe, so `npm run build` +// (invoked from Python via subprocess → cmd.exe) failed before Vite ran. +// Using Node's stdlib fs keeps this dependency-free and platform-neutral. + +import { cpSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const uiDist = resolve(webRoot, "node_modules", "@nous-research", "ui", "dist"); + +const targets = [ + { from: resolve(uiDist, "fonts"), to: resolve(webRoot, "public", "fonts") }, + { from: resolve(uiDist, "assets"), to: resolve(webRoot, "public", "ds-assets") }, +]; + +for (const { from, to } of targets) { + rmSync(to, { recursive: true, force: true }); + cpSync(from, to, { recursive: true }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d7239c2ad11e..71a97113c24a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -75,6 +75,7 @@ import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; +import { api } from "@/lib/api"; function RootRedirect() { return ; @@ -316,6 +317,21 @@ export default function App() { const isChatRoute = normalizedPath === "/chat"; const embeddedChat = isDashboardEmbeddedChatEnabled(); + // `dashboard.show_token_analytics` gates the Analytics nav item. The + // page itself remains reachable by URL (it renders an explanation when + // the flag is off — see AnalyticsPage), but hiding the nav entry avoids + // surfacing misleading token/cost numbers in the sidebar. Default off. + const [showTokenAnalytics, setShowTokenAnalytics] = useState(false); + useEffect(() => { + api + .getConfig() + .then((cfg) => { + const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; + setShowTokenAnalytics(dash.show_token_analytics === true); + }) + .catch(() => setShowTokenAnalytics(false)); + }, []); + // A plugin can replace the built-in /chat page via `tab.override: "/chat"` // in its manifest. When one does, `buildRoutes` already swaps the route // element for — but we also have to suppress the @@ -346,11 +362,12 @@ export default function App() { [embeddedChat], ); - const builtinNav = useMemo( - () => - embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST, - [embeddedChat], - ); + const builtinNav = useMemo(() => { + const base = embeddedChat + ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] + : BUILTIN_NAV_REST; + return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics"); + }, [embeddedChat, showTokenAnalytics]); const sidebarNav = useMemo( () => partitionSidebarNav(builtinNav, manifests), diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 57943eba6f2b..4896e760636d 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -397,10 +397,26 @@ export default function AnalyticsPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Gated on `dashboard.show_token_analytics` (default off). When off the + // page renders an explanation card instead of fetching analytics — the + // local token counts exclude auxiliary calls and provider retries, so + // they diverge from provider billing in ways that mislead users. + const [showTokens, setShowTokens] = useState(null); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); + useEffect(() => { + api + .getConfig() + .then((cfg) => { + const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; + setShowTokens(dash.show_token_analytics === true); + }) + .catch(() => setShowTokens(false)); + }, []); + const load = useCallback(() => { + if (!showTokens) return; setLoading(true); setError(null); api @@ -408,7 +424,7 @@ export default function AnalyticsPage() { .then(setData) .catch((err) => setError(String(err))) .finally(() => setLoading(false)); - }, [days]); + }, [days, showTokens]); useLayoutEffect(() => { const periodLabel = @@ -422,37 +438,39 @@ export default function AnalyticsPage() { , ); setEnd( -
-
- {PERIODS.map((p) => ( - - ))} + showTokens === false ? null : ( +
+
+ {PERIODS.map((p) => ( + + ))} +
+
- -
, + ), ); return () => { setAfterTitle(null); setEnd(null); }; - }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]); + }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]); useEffect(() => { load(); @@ -461,13 +479,51 @@ export default function AnalyticsPage() { return (
- {loading && !data && ( + + {showTokens === false && ( + + +
+

+ Token analytics hidden +

+

+ The token, cost, and per-day analytics on this page are a + local debug estimate. They only count successful main-agent + responses with a usable usage{" "} + block, and silently exclude auxiliary calls (context + compression, title generation, vision, session search, web + extract, smart approvals, MCP routing, plugin LLM access) + plus provider-side retries and fallback attempts. Cache + writes are missing entirely. +

+

+ On models with heavy auxiliary traffic (Kimi K2.6, MiniMax + M2.7) the local total can be 10x–100x lower than what your + provider bills. Hiding these numbers is safer than letting + them look authoritative. +

+

+ Check your provider dashboard (OpenRouter, Anthropic, etc.) + for actual usage and billing. To re-enable the local debug + estimate anyway, set{" "} + + dashboard.show_token_analytics: true + {" "} + in Config. +

+
+
+
+ )} + + {showTokens && loading && !data && (
)} - {error && ( + {showTokens && error && (

{error}

@@ -475,7 +531,7 @@ export default function AnalyticsPage() {
)} - {data && ( + {showTokens && data && ( <>
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 01c239d7034f..f09104d42416 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -310,12 +310,14 @@ function ModelCard({ main, aux, onAssigned, + showTokens, }: { entry: ModelsAnalyticsModelEntry; rank: number; main: { provider: string; model: string } | null; aux: AuxiliaryTaskAssignment[]; onAssigned(): void; + showTokens: boolean; }) { const { t } = useI18n(); const provider = entry.provider || modelVendor(entry.model); @@ -375,14 +377,27 @@ function ModelCard({
-
-
- {formatTokens(totalTokens)} -
-
- {t.models.tokens} + {showTokens ? ( +
+
+ {formatTokens(totalTokens)} +
+
+ {t.models.tokens} +
-
+ ) : ( + entry.sessions > 0 && ( +
+
+ {entry.sessions} +
+
+ {t.models.sessions} +
+
+ ) + )} - + {showTokens && ( + <> + -
-
-
{entry.sessions}
-
- {t.models.sessions} -
-
-
-
- {formatTokens(entry.avg_tokens_per_session)} -
-
- {t.models.avgPerSession} -
-
-
-
- {entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"} -
-
- {t.models.apiCalls} +
+
+
{entry.sessions}
+
+ {t.models.sessions} +
+
+
+
+ {formatTokens(entry.avg_tokens_per_session)} +
+
+ {t.models.avgPerSession} +
+
+
+
+ {entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"} +
+
+ {t.models.apiCalls} +
+
-
-
+ + )}
- {entry.estimated_cost > 0 && ( + {showTokens && entry.estimated_cost > 0 && ( {formatCost(entry.estimated_cost)} )} - {entry.tool_calls > 0 && ( + {showTokens && entry.tool_calls > 0 && ( {entry.tool_calls} {t.models.toolCalls} @@ -752,9 +771,26 @@ export default function ModelsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveKey, setSaveKey] = useState(0); + // Gate the token/cost UI on `dashboard.show_token_analytics`. See + // hermes_cli/config.py for the rationale: the numbers exclude auxiliary + // calls and retries, so they're misleading next to provider billing. + const [showTokens, setShowTokens] = useState(false); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); + useEffect(() => { + api + .getConfig() + .then((cfg) => { + const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; + setShowTokens(dash.show_token_analytics === true); + }) + .catch(() => { + // Default to hidden on any failure — safer than showing wrong numbers. + setShowTokens(false); + }); + }, []); + const load = useCallback(() => { setLoading(true); setError(null); @@ -842,35 +878,59 @@ export default function ModelsPage() { + {!showTokens && ( +

+ Token & cost analytics are hidden because the local counts + exclude auxiliary calls (compression, vision, web extract, + …) and provider retries, so they diverge from your provider + bill. Enable{" "} + dashboard.show_token_analytics{" "} + in Config to + show the local debug estimate anyway. +

+ )}
)} @@ -902,6 +962,7 @@ export default function ModelsPage() { main={aux?.main ?? null} aux={aux?.tasks ?? []} onAssigned={onAssigned} + showTokens={showTokens} /> ))}
diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index f3597dfca396..a8433fcacddc 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -182,6 +182,7 @@ When you call `ctx.register_platform()`, the following integration points are ha | Connected platform validation | Registry `validate_config()` called | | User authorization | `allowed_users_env` / `allow_all_env` checked | | Env-only auto-enable | `env_enablement_fn` seeds `PlatformConfig.extra` + `home_channel` | +| YAML config bridge | `apply_yaml_config_fn` translates `config.yaml` keys into env vars / extras | | Cron delivery | `cron_deliver_env_var` makes `deliver=` work | | `hermes config` UI entries | `requires_env` / `optional_env` in `plugin.yaml` auto-populate | | send_message tool | Routes through live gateway adapter | @@ -239,6 +240,46 @@ def register(ctx): ) ``` + +## YAML→env Config Bridge + +Some users prefer setting `config.yaml` keys (`my_platform.require_mention`, `my_platform.allowed_channels`, etc.) over env vars. The `apply_yaml_config_fn` hook lets your plugin own this translation instead of forcing core `gateway/config.py` to know your platform's YAML schema. + +```python +import os + +def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None: + """Translate config.yaml `my_platform:` keys into env vars / extras. + + yaml_cfg — the full top-level parsed config.yaml dict + platform_cfg — the platform's own sub-dict (yaml_cfg.get("my_platform", {})) + + May mutate os.environ directly (use `not os.getenv(...)` guards to + preserve env > YAML precedence) and/or return a dict to merge into + PlatformConfig.extra. Return None or {} for no extras. + """ + if "require_mention" in platform_cfg and not os.getenv("MY_PLATFORM_REQUIRE_MENTION"): + os.environ["MY_PLATFORM_REQUIRE_MENTION"] = str(platform_cfg["require_mention"]).lower() + allowed = platform_cfg.get("allowed_channels") + if allowed is not None and not os.getenv("MY_PLATFORM_ALLOWED_CHANNELS"): + if isinstance(allowed, list): + allowed = ",".join(str(v) for v in allowed) + os.environ["MY_PLATFORM_ALLOWED_CHANNELS"] = str(allowed) + return None # nothing extra to merge into PlatformConfig.extra + +def register(ctx): + ctx.register_platform( + name="my_platform", + ..., + apply_yaml_config_fn=_apply_yaml_config, + ) +``` + +The hook is invoked during `load_gateway_config()` after the generic shared-key loop (which handles common keys like `unauthorized_dm_behavior`, `notice_delivery`, `reply_prefix`, `require_mention`, etc.) and before `_apply_env_overrides()`, so your plugin only needs to bridge **platform-specific** keys. + +Exceptions raised by the hook are swallowed and logged at debug level — a misbehaving plugin never aborts gateway config load. + + ## Cron Delivery To let `deliver=my_platform` cron jobs route to a configured home channel, set `cron_deliver_env_var` to the env var name that holds the default chat/room/channel ID: diff --git a/website/docs/developer-guide/video-gen-provider-plugin.md b/website/docs/developer-guide/video-gen-provider-plugin.md new file mode 100644 index 000000000000..611c662621ca --- /dev/null +++ b/website/docs/developer-guide/video-gen-provider-plugin.md @@ -0,0 +1,231 @@ +--- +sidebar_position: 12 +title: "Video Generation Provider Plugins" +description: "How to build a video-generation backend plugin for Hermes Agent" +--- + +# Building a Video Generation Provider Plugin + +Video-gen provider plugins register a backend that services every `video_generate` tool call. Built-in providers (xAI, FAL) ship as plugins. Add a new one, or override a bundled one, by dropping a directory into `plugins/video_gen//`. + +:::tip +Video-gen mirrors [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) almost line-for-line — if you've built an image-gen backend, you already know the shape. The main differences: a `capabilities()` method advertising modalities/aspect-ratios/durations, and a routing convention (pass `image_url` to use image-to-video, omit it to use text-to-video — the provider picks the right endpoint internally). +::: + +## The unified surface (one tool, two modalities) + +The `video_generate` tool exposes two modalities through one parameter: + +- **Text-to-video** — call with `prompt` only. The provider routes to its text-to-video endpoint. +- **Image-to-video** — call with `prompt` + `image_url`. The provider routes to its image-to-video endpoint. + +Edit and extend are intentionally out of scope. Most backends don't support them and the inconsistency would force per-backend prose into the agent's tool description. + +## How discovery works + +Hermes scans for video-gen backends in three places: + +1. **Bundled** — `/plugins/video_gen//` (auto-loaded with `kind: backend`) +2. **User** — `~/.hermes/plugins/video_gen//` (opt-in via `plugins.enabled`) +3. **Pip** — packages declaring a `hermes_agent.plugins` entry point + +Each plugin's `register(ctx)` function calls `ctx.register_video_gen_provider(...)`. The active provider is picked by `video_gen.provider` in `config.yaml`; `hermes tools` → Video Generation walks users through selection. Unlike `image_generate`, there is no in-tree legacy backend — every provider is a plugin. + +## Directory structure + +``` +plugins/video_gen/my-backend/ +├── __init__.py # VideoGenProvider subclass + register() +└── plugin.yaml # Manifest with kind: backend +``` + +## The VideoGenProvider ABC + +Subclass `agent.video_gen_provider.VideoGenProvider`. Required: `name` property and `generate()` method. + +```python +# plugins/video_gen/my-backend/__init__.py +from typing import Any, Dict, List, Optional +import os + +from agent.video_gen_provider import ( + VideoGenProvider, + error_response, + success_response, +) + + +class MyVideoGenProvider(VideoGenProvider): + @property + def name(self) -> str: + return "my-backend" + + @property + def display_name(self) -> str: + return "My Backend" + + def is_available(self) -> bool: + return bool(os.environ.get("MY_API_KEY")) + + def list_models(self) -> List[Dict[str, Any]]: + # Each entry is a model FAMILY — a name the user picks once. + # Your provider's generate() routes within the family based on + # whether image_url was passed. + return [ + { + "id": "fast", + "display": "Fast", + "speed": "~30s", + "strengths": "Cheapest tier", + "price": "$0.05/s", + "modalities": ["text", "image"], # advisory + }, + ] + + def default_model(self) -> Optional[str]: + return "fast" + + def capabilities(self) -> Dict[str, Any]: + return { + "modalities": ["text", "image"], + "aspect_ratios": ["16:9", "9:16"], + "resolutions": ["720p", "1080p"], + "min_duration": 1, + "max_duration": 10, + "supports_audio": False, + "supports_negative_prompt": True, + "max_reference_images": 0, + } + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "My Backend", + "badge": "paid", + "tag": "Short description shown in `hermes tools`", + "env_vars": [ + { + "key": "MY_API_KEY", + "prompt": "My Backend API key", + "url": "https://mybackend.example.com/keys", + }, + ], + } + + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = "16:9", + resolution: str = "720p", + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, # always ignore unknown kwargs for forward-compat + ) -> Dict[str, Any]: + # ROUTE: image_url presence picks the endpoint. + if image_url: + endpoint = "my-backend/image-to-video" + modality_used = "image" + else: + endpoint = "my-backend/text-to-video" + modality_used = "text" + + # ... call your API ... + + return success_response( + video="https://your-cdn/output.mp4", + model=model or "fast", + prompt=prompt, + modality=modality_used, + aspect_ratio=aspect_ratio, + duration=duration or 5, + provider=self.name, + ) + + +def register(ctx) -> None: + ctx.register_video_gen_provider(MyVideoGenProvider()) +``` + +## The plugin manifest + +```yaml +# plugins/video_gen/my-backend/plugin.yaml +name: my-backend +version: 1.0.0 +description: "My video generation backend" +author: Your Name +kind: backend +requires_env: + - MY_API_KEY +``` + +## The `video_generate` schema + +The tool exposes one schema across every backend. Providers ignore parameters they don't support. + +| Parameter | What it does | +|---|---| +| `prompt` | Text instruction (required) | +| `image_url` | When set → image-to-video; when omitted → text-to-video | +| `reference_image_urls` | Style/character refs (provider-dependent) | +| `duration` | Seconds — provider clamps | +| `aspect_ratio` | `"16:9"`, `"9:16"`, `"1:1"`, ... — provider clamps | +| `resolution` | `"480p"` / `"540p"` / `"720p"` / `"1080p"` — provider clamps | +| `negative_prompt` | Content to avoid (Pixverse/Kling only) | +| `audio` | Native audio (Veo3 / Pixverse pricing tier) | +| `seed` | Reproducibility | +| `model` | Override the active model/family | + +The provider's `capabilities()` advertises which of these are honored. The agent sees the active backend's capabilities in the tool description, dynamically rebuilt when the user changes backend via `hermes tools`. + +## Model families and endpoint routing (the FAL pattern) + +When your backend has multiple endpoints per "model" — like FAL, where every family (Veo 3.1, Pixverse v6, Kling O3) has both a `/text-to-video` and an `/image-to-video` URL — represent each **family** as one catalog entry. Your `generate()` picks the right endpoint based on whether `image_url` was passed: + +```python +FAMILIES = { + "veo3.1": { + "text_endpoint": "fal-ai/veo3.1", + "image_endpoint": "fal-ai/veo3.1/image-to-video", + # ... family-specific capability flags ... + }, +} + +def generate(self, prompt, *, image_url=None, model=None, **kwargs): + family_id, family = _resolve_family(model) + endpoint = family["image_endpoint"] if image_url else family["text_endpoint"] + # ... build payload from family's declared capability flags, call endpoint ... +``` + +The user picks `veo3.1` once in `hermes tools`. The agent never thinks about endpoints — it just passes (or doesn't pass) `image_url`. + +## Selection precedence + +For per-instance model knobs (see `plugins/video_gen/fal/__init__.py`): + +1. `model=` keyword from the tool call +2. `_VIDEO_MODEL` env var +3. `video_gen..model` in `config.yaml` +4. `video_gen.model` in `config.yaml` (when it's one of your IDs) +5. Provider's `default_model()` + +## Response shape + +`success_response()` and `error_response()` produce the dict shape every backend returns. Use them — don't hand-roll the dict. + +Success keys: `success`, `video` (URL or absolute path), `model`, `prompt`, `modality` (`"text"` or `"image"`), `aspect_ratio`, `duration`, `provider`, plus `extra`. + +Error keys: `success`, `video` (None), `error`, `error_type`, `model`, `prompt`, `aspect_ratio`, `provider`. + +## Where to save artifacts + +If your backend returns base64, use `save_b64_video()` to write under `$HERMES_HOME/cache/videos/`. For raw bytes from a follow-up HTTP fetch, use `save_bytes_video()`. Otherwise return the upstream URL directly — the gateway resolves remote URLs on delivery. + +## Testing + +Drop a smoke test under `tests/plugins/video_gen/test__plugin.py`. The xAI and FAL tests show the pattern — register, verify catalog, exercise routing both with and without `image_url`, assert clean error responses on missing auth. diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 102f044d501f..c8db40a9137b 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -132,6 +132,43 @@ If you want to clone the repo and install from source — for contributing, runn --- +## Non-Sudo / System Service User Installs + +Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service account, or any user without `sudo` access) is supported. The only thing on the install path that genuinely needs root is Playwright's `--with-deps` step, which `apt`-installs shared libraries (`libnss3`, `libxkbcommon`, etc.) used by Chromium. The installer detects whether sudo is available and gracefully degrades when it isn't — it will install the Chromium binary into the service user's own Playwright cache and print the exact command an administrator needs to run separately. + +**Recommended split (Debian/Ubuntu):** + +1. **One time, as an admin user with sudo**, install the system libraries Chromium needs: + ```bash + sudo npx playwright install-deps chromium + ``` + (You can run this from anywhere — `npx` will fetch Playwright on the fly.) + +2. **As the unprivileged service user**, run the regular installer. It will detect the missing sudo, skip `--with-deps`, and install Chromium into the user's local Playwright cache: + ```bash + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + ``` + + If you want to skip the Playwright step entirely — for example because you're running headless and don't need browser automation — pass `--skip-browser`: + ```bash + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + ``` + +3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location: + ```bash + # Option A — add to the service user's profile + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + + # Option B — symlink system-wide (run as an admin) + sudo ln -s /home/hermes/.hermes/hermes-agent/venv/bin/hermes /usr/local/bin/hermes + ``` + +4. **Verify:** `hermes doctor` should now run cleanly. If you get `ModuleNotFoundError: No module named 'dotenv'`, you're invoking the repo source `hermes` file (`~/.hermes/hermes-agent/hermes`) with system Python instead of the venv launcher (`~/.hermes/hermes-agent/venv/bin/hermes`) — fix step 3. + +The same pattern works on Arch (the installer uses pacman with the same sudo-detection logic), Fedora/RHEL, and openSUSE — those distros don't support `--with-deps` at all, so an administrator always installs the system libraries separately. The relevant `dnf`/`zypper` commands are printed by the installer. + +--- + ## Troubleshooting | Problem | Solution | diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f5a089ee7240..03872a00eaf2 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -91,6 +91,7 @@ Good defaults: | **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` (or the Kimi-Coding-specific `KIMI_CODING_API_KEY`) | | **Kimi / Moonshot China** | China-region Moonshot endpoint | Set `KIMI_CN_API_KEY` | | **Arcee AI** | Trinity models | Set `ARCEEAI_API_KEY` | +| **Auriko** | Multi-LLM inference gateway | Set `AURIKO_API_KEY` | | **GMI Cloud** | Multi-model direct API | Set `GMI_API_KEY` | | **MiniMax (OAuth)** | MiniMax-M2.7 via browser OAuth — no API key needed | `hermes model` → MiniMax (OAuth) | | **MiniMax** | International MiniMax endpoint | Set `MINIMAX_API_KEY` | diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index 45ad3622ea5d..ee74e23ac5e9 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -20,6 +20,7 @@ Hermes has several distinct pluggable interfaces — some use Python `register_* | A **memory backend** (Honcho/Mem0/Supermemory/etc.) | [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) | | A **context-compression engine** | [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) | | An **image-generation backend** | [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) | +| A **video-generation backend** | [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin) | | A **TTS backend** (any CLI — Piper, VoxCPM, Kokoro, voice cloning, …) | [TTS custom command providers](/docs/user-guide/features/tts#custom-command-providers) — config-driven, no Python needed | | An **STT backend** (custom whisper / ASR CLI) | [Voice Message Transcription](/docs/user-guide/features/tts#voice-message-transcription-stt) — set `HERMES_LOCAL_STT_COMMAND` to a shell template | | **External tools via MCP** (filesystem, GitHub, Linear, any MCP server) | [MCP](/docs/user-guide/features/mcp) — declare `mcp_servers.` in `config.yaml` | diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 93e4ba630d3c..d36bfc349ef8 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -20,11 +20,13 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) | | **Anthropic** | `hermes model` (Claude Max + extra usage credits via OAuth; also supports Anthropic API key or manual setup-token — see note below) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | +| **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 200+ models, Model API, Agent Sandbox, GPU Cloud) | | **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) | | **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) | | **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) | | **Kimi / Moonshot (China)** | `KIMI_CN_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding-cn`; aliases: `kimi-cn`, `moonshot-cn`) | | **Arcee AI** | `ARCEEAI_API_KEY` in `~/.hermes/.env` (provider: `arcee`; aliases: `arcee-ai`, `arceeai`) | +| **Auriko** | `AURIKO_API_KEY` in `~/.hermes/.env` (provider: `auriko`; aliases: `auriko-ai`) | | **GMI Cloud** | `GMI_API_KEY` in `~/.hermes/.env` (provider: `gmi`; aliases: `gmi-cloud`, `gmicloud`) | | **MiniMax** | `MINIMAX_API_KEY` in `~/.hermes/.env` (provider: `minimax`) | | **MiniMax China** | `MINIMAX_CN_API_KEY` in `~/.hermes/.env` (provider: `minimax-cn`) | @@ -267,6 +269,10 @@ model: These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select: ```bash +# NovitaAI Model API +hermes chat --provider novita --model moonshotai/kimi-k2.5 +# Requires: NOVITA_API_KEY in ~/.hermes/.env + # z.ai / ZhipuAI GLM hermes chat --provider zai --model glm-5 # Requires: GLM_API_KEY in ~/.hermes/.env @@ -316,7 +322,7 @@ model: default: "zai-org/GLM-5.1-FP8" ``` -Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, `GMI_BASE_URL`, or `TOKENHUB_BASE_URL` environment variables. +Base URLs can be overridden with `NOVITA_BASE_URL`, `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, `GMI_BASE_URL`, or `TOKENHUB_BASE_URL` environment variables. :::note Z.AI Endpoint Auto-Detection When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoints (global, China, coding variants) to find one that accepts your API key. You don't need to set `GLM_BASE_URL` manually — the working endpoint is detected and cached automatically. @@ -332,6 +338,29 @@ No configuration is needed — caching activates automatically when an xAI endpo xAI also ships a dedicated TTS endpoint (`/v1/tts`). Select **xAI TTS** in `hermes tools` → Voice & TTS, or see the [Voice & TTS](../user-guide/features/tts.md#text-to-speech) page for config. +### NovitaAI + +[NovitaAI](https://novita.ai) is the AI-native cloud for builders and agents. Its three product lines are Model API for 200+ models, Agent Sandbox for building and running AI agents, and GPU Cloud for scalable compute, all available from one platform. + +```bash +# Use any available model +hermes chat --provider novita --model moonshotai/kimi-k2.5 +# Requires: NOVITA_API_KEY in ~/.hermes/.env + +# Short alias +hermes chat --provider novita-ai --model deepseek/deepseek-v3-0324 +``` + +Or set it permanently in `config.yaml`: +```yaml +model: + provider: "novita" + default: "moonshotai/kimi-k2.5" + base_url: "https://api.novita.ai/openai/v1" +``` + +Get your API key at [novita.ai/settings/key-management](https://novita.ai/settings/key-management). The base URL can be overridden with `NOVITA_BASE_URL`. + ### Ollama Cloud — Managed Ollama Models, OAuth + API Key [Ollama Cloud](https://ollama.com/cloud) hosts the same open-weight catalog as local Ollama but without the GPU requirement. Pick it in `hermes model` as **Ollama Cloud**, paste your API key from [ollama.com/settings/keys](https://ollama.com/settings/keys), and Hermes auto-discovers the available models. @@ -1417,7 +1446,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session. -Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `auriko`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. :::tip Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4ce8a331a940..4b3affa12977 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -40,6 +40,7 @@ hermes [global-options] [subcommand/options] | `hermes model` | Interactively choose the default provider and model. | | `hermes fallback` | Manage fallback providers tried when the primary model errors. | | `hermes gateway` | Run or manage the messaging gateway service. | +| `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). | | `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | @@ -91,7 +92,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `auriko`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | | `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b17036ade44f..dd5ce9dbac3b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -40,6 +40,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `ARCEE_BASE_URL` | Override Arcee base URL (default: `https://api.arcee.ai/api/v1`) | | `GMI_API_KEY` | GMI Cloud API key ([gmicloud.ai](https://www.gmicloud.ai/)) | | `GMI_BASE_URL` | Override GMI Cloud base URL (default: `https://api.gmi-serving.com/v1`) | +| `AURIKO_API_KEY` | Auriko API key ([auriko.ai](https://auriko.ai/)) | +| `AURIKO_BASE_URL` | Override Auriko base URL (default: `https://api.auriko.ai/v1`) | | `MINIMAX_API_KEY` | MiniMax API key — global endpoint ([minimax.io](https://www.minimax.io)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). | | `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/anthropic` — Hermes uses MiniMax's Anthropic Messages-compatible endpoint). **Not used by `minimax-oauth`**. | | `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)). **Not used by `minimax-oauth`** (OAuth path uses browser login instead). | @@ -67,6 +69,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `DASHSCOPE_BASE_URL` | Custom DashScope base URL (default: `https://dashscope-intl.aliyuncs.com/compatible-mode/v1`; use `https://dashscope.aliyuncs.com/compatible-mode/v1` for mainland-China region) | | `DEEPSEEK_API_KEY` | DeepSeek API key for direct DeepSeek access ([platform.deepseek.com](https://platform.deepseek.com/api_keys)) | | `DEEPSEEK_BASE_URL` | Custom DeepSeek API base URL | +| `NOVITA_API_KEY` | NovitaAI API key — AI-native cloud for Model API, Agent Sandbox, and GPU Cloud ([novita.ai/settings/key-management](https://novita.ai/settings/key-management)) | +| `NOVITA_BASE_URL` | Override NovitaAI base URL (default: `https://api.novita.ai/openai/v1`) | | `NVIDIA_API_KEY` | NVIDIA NIM API key — Nemotron and open models ([build.nvidia.com](https://build.nvidia.com)) | | `NVIDIA_BASE_URL` | Override NVIDIA base URL (default: `https://integrate.api.nvidia.com/v1`; set to `http://localhost:8000/v1` for a local NIM endpoint) | | `STEPFUN_API_KEY` | StepFun API key — Step-series models ([platform.stepfun.com](https://platform.stepfun.com)) | @@ -103,7 +107,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `novita`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `auriko`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | @@ -133,6 +137,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `CAMOFOX_SESSION_KEY` | Optional Camofox session key used when creating tabs for `CAMOFOX_USER_ID` | | `CAMOFOX_ADOPT_EXISTING_TAB` | Set to `true` to reuse an existing Camofox tab before creating a new one | | `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds | +| `AGENT_BROWSER_ARGS` | Extra Chromium launch flags (comma- or newline-separated). Hermes auto-injects `--no-sandbox,--disable-dev-shm-usage` when running as root or on AppArmor-restricted unprivileged user namespaces (Ubuntu 23.10+, DGX Spark, many container images); set this manually only to override or add other flags. | | `FAL_KEY` | Image generation ([fal.ai](https://fal.ai/)) | | `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) | | `ELEVENLABS_API_KEY` | ElevenLabs premium TTS voices ([elevenlabs.io](https://elevenlabs.io/)) | @@ -515,6 +520,8 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_HUMAN_DELAY_MIN_MS` | Custom delay range minimum (ms) | | `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) | | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) | +| `CODEX_HOME` | When [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) is enabled, override the directory Codex CLI reads its config + auth from (default: `~/.codex`). Hermes' migration writes the managed block to `/config.toml`. | +| `HERMES_KANBAN_TASK` | Set by the kanban dispatcher when spawning a worker (task UUID). Workers and the spawned `hermes-tools` MCP subprocess inherit it so kanban tools gate correctly. Don't set manually. | | `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `1800`) | | `HERMES_API_CALL_STALE_TIMEOUT` | Non-streaming stale-call timeout in seconds (default: `300`). Auto-disabled for local providers when left unset. Also configurable via `providers..stale_timeout_seconds` or `providers..models..stale_timeout_seconds` in `config.yaml`. | | `HERMES_STREAM_READ_TIMEOUT` | Streaming socket read timeout in seconds (default: `120`). Auto-increased to `HERMES_API_TIMEOUT` for local providers. Increase if local LLMs time out during long code generation. | diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 1cedabe4ff26..40f9c5539c8c 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -38,7 +38,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| -| [**base**](/docs/user-guide/skills/optional/blockchain/blockchain-base) | Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required. | +| [**evm**](/docs/user-guide/skills/optional/blockchain/blockchain-evm) | Read-only EVM client: wallets, tokens, gas across 8 chains. | | [**solana**](/docs/user-guide/skills/optional/blockchain/blockchain-solana) | Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. | ## communication diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 718da1350aaf..377c31c4477e 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -50,6 +50,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in |---------|-------------| | `/config` | Show current configuration | | `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. | +| `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) for OpenAI/Codex models. `auto` (default) uses Hermes' standard chat completions; `codex_app_server` hands turns to a `codex app-server` subprocess for native shell, apply_patch, ChatGPT subscription auth, and migrated Codex plugins. Effective on next session. | | `/personality` | Set a predefined personality | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. | @@ -180,6 +181,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/status` | Show session info. | | `/stop` | Kill all running background processes and interrupt the running agent. | | `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), auto-detect (`/model custom`), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). | +| `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime). Persists to `model.openai_runtime` in config.yaml and evicts the cached agent so the next message picks up the new runtime. Effective on next session. | | `/personality [name]` | Set a personality overlay for the session. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. | | `/retry` | Retry the last message. | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 37bd5aae1d8f..ce11d86cb416 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -66,6 +66,7 @@ Or in-session: | `homeassistant` | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` | Smart home control via Home Assistant. Only available when `HASS_TOKEN` is set. | | `computer_use` | `computer_use` | Background macOS desktop control via cua-driver — does not steal cursor/focus. Works with any tool-capable model. macOS only; requires `cua-driver` on `$PATH`. | | `image_gen` | `image_generate` | Text-to-image generation via FAL.ai (with opt-in OpenAI / xAI backends). | +| `video_gen` | `video_generate` | Text-to-video and image-to-video via plugin-registered backends (xAI Grok-Imagine, FAL.ai Veo 3.1 / Pixverse v6 / Kling O3). Pass `image_url` to animate an image; omit it for text-to-video. | | `kanban` | `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_show` | Multi-agent coordination tools — only registered when the agent is spawned by the kanban dispatcher (`HERMES_KANBAN_TASK` env set). Lets workers mark tasks done with structured handoffs, block for human input, heartbeat during long ops, comment on threads, and (for orchestrators) fan out into child tasks. | | `memory` | `memory` | Persistent cross-session memory management. | | `messaging` | `send_message` | Send messages to other platforms (Telegram, Discord, etc.) from within a session. | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 5ea0c0b1779c..5eb0533f2606 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -813,7 +813,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). +Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/docs/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `auriko`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). :::tip MiniMax OAuth `minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md). @@ -1588,7 +1588,7 @@ security: ``` - `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **Off by default** — enable if you commonly work with real credentials in tool output and want a safety net. Set to `true` explicitly to turn on. -- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/StackGuardian/tirith) before execution to detect potentially dangerous operations. +- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/sheeki03/tirith) before execution to detect potentially dangerous operations. - `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location. - `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out. - `tirith_fail_open` — when `true` (default), commands are allowed to execute if tirith is unavailable or fails. Set to `false` to block commands when tirith cannot verify them. diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index e27101a64725..1da4a8f2a36d 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -368,6 +368,13 @@ BROWSERBASE_SESSION_TIMEOUT=600000 # Inactivity timeout before auto-cleanup in seconds (default: 120) BROWSER_INACTIVITY_TIMEOUT=120 + +# Extra Chromium launch flags (comma- or newline-separated). Hermes auto-injects +# `--no-sandbox,--disable-dev-shm-usage` when it detects root or AppArmor-restricted +# unprivileged user namespaces (Ubuntu 23.10+, DGX Spark, many container images), +# so most users don't need to set this. Set it manually only if you need a flag +# Hermes doesn't add automatically; setting it disables the auto-injection. +AGENT_BROWSER_ARGS=--no-sandbox ``` ### Install agent-browser CLI diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md new file mode 100644 index 000000000000..a1aa6a0776eb --- /dev/null +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -0,0 +1,444 @@ +--- +title: Codex App-Server Runtime (optional) +sidebar_label: Codex App-Server Runtime +--- + +# Codex App-Server Runtime + +Hermes can optionally hand `openai/*` and `openai-codex/*` turns to the [Codex CLI app-server](https://github.com/openai/codex) instead of running its own tool loop. When enabled, terminal commands, file edits, sandboxing, and MCP tool calls all execute inside Codex's runtime — Hermes becomes the shell around it (sessions DB, slash commands, gateway, memory and skill review). + +This is **opt-in only**. Default Hermes behavior is unchanged unless you flip the flag. Hermes never auto-routes you onto this runtime. + +## Why + +- Run OpenAI agent turns against your **ChatGPT subscription** (no API key required) using the same auth flow Codex CLI uses. +- Use **Codex's own toolset and sandbox** — `shell` for terminal/read/write/search, `apply_patch` for structured edits, `update_plan` for planning, all running inside seatbelt/landlock sandboxing. +- **Native Codex plugins** — Linear, GitHub, Gmail, Calendar, Canva, etc. — installed via `codex plugin` are auto-migrated and active in your Hermes session. +- **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback. Codex calls back into Hermes for tools it doesn't have built in. +- **Memory and skill nudges keep working** — Codex's events are projected into Hermes' message shape so the self-improvement loop sees a normal-looking transcript. + +## What tools the model actually has + +This is the part most users want to know up front. When this runtime is on, the model running your turn has three independent sources of tools: + +### 1. Codex's built-in toolset (always on) + +These ship with `codex app-server` itself — no Hermes involvement, no MCP, no plugins. All five are available the moment the runtime starts: + +- **`shell`** — runs arbitrary shell commands inside the sandbox. This is how the model reads files (`cat`, `head`, `tail`), writes them (`echo > foo`, heredocs), searches them (`find`, `rg`, `grep`), navigates directories (`ls`, `cd`), runs builds, manages processes, and anything else you'd do in bash. +- **`apply_patch`** — applies a structured multi-file diff in Codex's patch format. The model uses this for non-trivial code edits (adding a function, refactoring across files); shell heredocs are still available for one-off writes. +- **`update_plan`** — codex's internal todo / plan tracker. Equivalent of Hermes' `todo` tool, but managed entirely inside codex's runtime. +- **`view_image`** — load a local image file into the conversation so the model can see it. +- **`web_search`** — codex has its own built-in web search when configured. Hermes also exposes `web_search` (Firecrawl-backed) via the callback below; the model picks whichever it prefers. + +So **anything you'd do via terminal — read/write/search/find/run — codex does natively**. The sandbox profile (`:workspace` by default when you enable the runtime) controls what's writable. + +### 2. Native Codex plugins (auto-migrated from your `codex plugin` install) + +When you enable the runtime, Hermes queries codex's `plugin/list` RPC and writes a `[plugins."@openai-curated"]` entry for every plugin you have installed. The plugins themselves are managed by codex and authorized once via codex's own UI. + +Examples (the ones the OpenClaw thread highlighted as "YouTube-video-worthy"): + +- **Linear** — find/update issues +- **GitHub** — search code, view PRs, comment +- **Gmail** — read/send mail +- **Google Calendar** — create/find events +- **Outlook calendar/email** — same shape via the Microsoft connector +- **Canva** — design generation +- ...whatever else you've installed via `codex plugin marketplace add openai-curated` + `codex plugin install ...` + +What's NOT migrated: +- Plugins you haven't installed yet — install them in Codex first. +- ChatGPT app marketplace entries (`app/list`) — these are already enabled inside codex by virtue of your account auth. + +### 3. Hermes tool callback (MCP server, registered in `~/.codex/config.toml`) + +Hermes registers itself as an MCP server so codex can call back for tools codex doesn't ship with. Available via the callback: + +- **`web_search`** / **`web_extract`** — Firecrawl-backed; tends to be cleaner than scraping for structured content. +- **`browser_navigate` / `browser_click` / `browser_type` / `browser_press` / `browser_snapshot` / `browser_scroll` / `browser_back` / `browser_get_images` / `browser_console` / `browser_vision`** — full browser automation via Camofox or Browserbase. +- **`vision_analyze`** — call a separate vision model to inspect an image (different from codex's `view_image` which loads it into the conversation). +- **`image_generate`** — image generation through Hermes' image_gen plugin chain. +- **`skill_view` / `skills_list`** — read from Hermes' skill library. +- **`text_to_speech`** — TTS through Hermes' configured provider. + +When the model wants one of these, codex spawns the `hermes_tools_mcp_server` subprocess via stdio MCP, the call is dispatched through `model_tools.handle_function_call()` (same code path as Hermes' default runtime), and the result is returned to codex like any other MCP response. + +### What's NOT available on this runtime + +These four Hermes tools require the running AIAgent context (mid-loop state) to dispatch, and a stateless MCP callback can't drive them. Switch back to the default runtime (`/codex-runtime auto`) when you need any of them: + +- **`delegate_task`** — spawn subagents +- **`memory`** — Hermes' persistent memory store +- **`session_search`** — cross-session search +- **`todo`** — Hermes' todo store (codex's `update_plan` is the in-runtime equivalent) + +## Workflow features (`/goal`, kanban, cron) + +### `/goal` (the Ralph loop) + +**Works on this runtime.** Goals persist in `state_meta` keyed by session id, the continuation prompt feeds back as a normal user message through `run_conversation()`, and codex executes the next turn natively. The goal judge runs via the auxiliary client (configured via `auxiliary.goal_judge` in config.yaml), independent of which runtime is active. The judge's "blocked, needs user input" verdict is a clean escape if codex stalls on approvals. + +**One thing to be aware of:** each continuation prompt is a fresh codex turn, which means codex re-evaluates command approval policy from scratch. If you're doing a long-running goal with lots of writes, expect more approval prompts than you'd see on a single in-session task. Set `default_permissions = ":workspace"` (which Hermes does automatically when you enable the runtime) so simple workspace writes don't require prompting. + +### Kanban (multi-agent worktree dispatch) + +**Works on this runtime, with one subtle dependency.** The kanban dispatcher spawns each worker as a separate `hermes chat -q` subprocess that reads the user's config — which means if `model.openai_runtime: codex_app_server` is set globally, workers also come up on the codex runtime. + +What works inside a codex-runtime worker: +- Codex's full toolset (shell, apply_patch, update_plan, view_image, web_search) — the worker does its actual task work natively +- The migrated codex plugins — Linear, GitHub, etc. +- The Hermes tool callback for browser_*, vision, image_gen, skills, TTS + +What also works because the MCP callback exposes them: +- **`kanban_complete` / `kanban_block` / `kanban_comment` / `kanban_heartbeat`** — the worker handoff tools. These read `HERMES_KANBAN_TASK` from env (set by the dispatcher), gate access correctly, and write to `~/.hermes/kanban.db`. Without these in the callback, a worker on this runtime could do its task but couldn't report back, hanging until the dispatcher's timeout. +- **`kanban_show` / `kanban_list`** — read-only board queries for the worker to check its own context. +- **`kanban_create` / `kanban_unblock` / `kanban_link`** — orchestrator-only operations. Available for orchestrator agents running on the codex runtime that need to dispatch new tasks. + +The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets — that var is propagated to the codex subprocess (codex inherits env) and from there to the spawned `hermes-tools` MCP server subprocess. So the tools see the right task id and gate correctly. + +### Cron jobs + +**Not specifically tested.** Cron jobs run via `cronjob` → `AIAgent.run_conversation`, the same code path as the CLI. If the cron job's config has `openai_runtime: codex_app_server` it'll run on codex. The same tool-availability rules apply — codex built-ins + plugins + MCP callback work, agent-loop tools (delegate_task, memory, session_search, todo) don't. If your cron job relies on those, scope the cron to a profile that uses the default runtime. + +## Trade-offs + +| | Hermes default runtime | Codex app-server (opt-in) | +|---|---|---| +| `delegate_task` subagents | yes | not available — needs agent loop context | +| `memory`, `session_search`, `todo` | yes | not available — needs agent loop context | +| `web_search`, `web_extract` | yes | yes (via MCP callback) | +| Browser automation (Camofox/Browserbase) | yes | yes (via MCP callback) | +| `vision_analyze`, `image_generate` | yes | yes (via MCP callback) | +| `skill_view`, `skills_list` | yes | yes (via MCP callback) | +| `text_to_speech` | yes | yes (via MCP callback) | +| Codex `shell` (terminal/read/write/search/find/run) | — | yes (Codex built-in) | +| Codex `apply_patch` (structured multi-file edits) | — | yes (Codex built-in) | +| Codex `update_plan` (in-runtime todo) | — | yes (Codex built-in) | +| Codex `view_image` (load image into conversation) | — | yes (Codex built-in) | +| Codex sandbox (seatbelt/landlock, profiles) | — | yes (Codex built-in) | +| ChatGPT subscription auth | — | yes (via `openai-codex` provider) | +| Native Codex plugins (Linear, GitHub, etc.) | — | yes (auto-migrated) | +| User MCP servers | yes | yes (auto-migrated to codex) | +| Memory + skill review (background) | yes | yes (via item projection) | +| Multi-turn conversations | yes | yes | +| `/goal` (Ralph loop) | yes | yes | +| Kanban worker dispatch | yes | yes (via callback) | +| Kanban orchestrator tools | yes | yes (via callback) | +| All gateway platforms | yes | yes | +| Non-OpenAI providers | yes | n/a — OpenAI/Codex-scoped | + +## Prerequisites + +1. **Codex CLI installed:** + ```bash + npm i -g @openai/codex + codex --version # 0.130.0 or newer + ``` +2. **Codex OAuth login.** The codex subprocess reads `~/.codex/auth.json`. Two ways to populate it: + ```bash + codex login # writes tokens to ~/.codex/auth.json + ``` + Hermes' own `hermes auth login codex` writes to `~/.hermes/auth.json` — that's a separate session. **Run `codex login` separately** if you haven't. + +3. **(Optional) Install the Codex plugins you want.** When you enable the runtime, Hermes auto-migrates whichever curated plugins you've already installed via Codex CLI: + ```bash + codex plugin marketplace add openai-curated + # then via codex's TUI, install Linear / GitHub / Gmail / etc. + ``` + Hermes will discover them and write `[plugins."@openai-curated"]` entries to `~/.codex/config.toml` automatically. + +## Enabling + +In a Hermes session: + +``` +/codex-runtime codex_app_server +``` + +That command: +- Verifies the `codex` CLI is installed (blocks with an install hint if not). +- Persists `model.openai_runtime: codex_app_server` to your config.yaml. +- Migrates user MCP servers from `~/.hermes/config.yaml` to `~/.codex/config.toml`. +- **Discovers and migrates installed native Codex plugins** (Linear, GitHub, Gmail, Calendar, Canva, etc.) by querying Codex's `plugin/list` RPC. +- **Registers Hermes' own tools as an MCP server** so the codex subprocess can call back for tools codex doesn't ship with. +- **Writes `default_permissions = ":workspace"`** so the sandbox allows writes within the workspace without prompting for every operation. +- Tells you what was migrated. Takes effect on the **next** session — the current cached agent keeps the prior runtime so prompt caches stay valid. + +Synonyms: `/codex-runtime on`, `/codex-runtime off`, `/codex-runtime auto`. + +To check current state without changing anything: +``` +/codex-runtime +``` + +You can also set it manually in `~/.hermes/config.yaml`: +```yaml +model: + openai_runtime: codex_app_server # default is "auto" (= Hermes runtime) +``` + +## Self-improvement loop (memory + skill nudges) + +Hermes' background self-improvement fires on counter thresholds: + +- Every 10 user prompts → a forked review agent looks at the conversation and decides whether anything should be saved to memory. +- Every 10 tool iterations within a single turn → same idea but for skills (`skill_manage` writes). + +**Both keep working on the codex runtime.** The codex path projects each completed `commandExecution` / `fileChange` / `mcpToolCall` / `dynamicToolCall` item into a synthetic `assistant tool_call` + `tool` result message, so by the time the review runs it sees the same shape it sees on the default Hermes runtime. + +How the wiring stays equivalent: + +| | Default runtime | Codex runtime | +|---|---|---| +| `_turns_since_memory` increments | per user prompt, in run_conversation pre-loop | same code path, before the early-return | +| `_iters_since_skill` increments | per tool iteration in the chat-completions loop | by `turn.tool_iterations` after the codex turn returns | +| Memory trigger (`_turns_since_memory >= _memory_nudge_interval`) | computed in pre-loop, fires after response | computed in pre-loop, passed through to codex helper | +| Skill trigger (`_iters_since_skill >= _skill_nudge_interval`) | computed after the loop | computed after the codex turn | +| `_spawn_background_review(messages_snapshot=..., review_memory=..., review_skills=...)` | called when either trigger fires | called identically when either trigger fires | + +One detail: the review fork itself needs to call Hermes' agent-loop tools (`memory`, `skill_manage`), which require Hermes' own dispatch. So when the parent agent is on `codex_app_server`, the review fork is **downgraded to `codex_responses`** — same OAuth credentials, same `openai-codex` provider, but talks to OpenAI's Responses API directly so Hermes owns the loop and the agent-loop tools work. This is invisible to the user. + +Net effect: enable the codex runtime and your memory + skill nudges keep firing exactly as they would otherwise. + +## How approvals work + +Codex requests approval before executing commands or applying patches. These get translated into Hermes' standard "Dangerous Command" prompt: + +``` +╭───────────────────────────────────────╮ +│ Dangerous Command │ +│ │ +│ /bin/bash -lc 'echo hello > foo.txt' │ +│ │ +│ ❯ 1. Allow once │ +│ 2. Allow for this session │ +│ 3. Deny │ +│ │ +│ Codex requests exec in /your/cwd │ +╰───────────────────────────────────────╯ +``` + +- **Allow once** → approve this single command. +- **Allow for this session** → Codex won't re-prompt for similar commands. +- **Deny** → command is rejected; Codex continues in read-only mode. + +For `apply_patch` (file edit) approvals, Hermes shows a summary of what changed (`1 add, 1 update: /tmp/new.py, /tmp/old.py`) when codex provides the data via the corresponding `fileChange` item. + +## Permission profiles + +Codex has three built-in permission profiles: +- `:read-only` — no writes; every shell command requires approval +- `:workspace` — writes within the current workspace allowed without prompts (Hermes' default when you enable the runtime) +- `:danger-no-sandbox` — no sandbox at all (don't use this unless you understand it) + +You can override the default in `~/.codex/config.toml` outside Hermes' managed block: + +```toml +default_permissions = ":read-only" +``` + +(Hermes will preserve your override on re-migration as long as it lives outside the `# managed by hermes-agent` markers.) + +## Auxiliary tasks and ChatGPT subscription token cost + +When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. + +This isn't specific to `codex_app_server` — it's true for the existing `codex_responses` path too — but it's more visible here because you're explicitly opting in for the subscription billing. + +To route specific aux tasks to a cheaper / different model, set explicit overrides in `~/.hermes/config.yaml`: + +```yaml +auxiliary: + title_generation: + provider: openrouter + model: google/gemini-3-flash-preview + context_compression: + provider: openrouter + model: google/gemini-3-flash-preview + vision_detect: + provider: openrouter + model: google/gemini-3-flash-preview + session_search: + provider: openrouter + model: google/gemini-3-flash-preview + goal_judge: + provider: openrouter + model: google/gemini-3-flash-preview +``` + +The self-improvement review fork inherits the main runtime via `_current_main_runtime()` and Hermes downgrades it from `codex_app_server` to `codex_responses` automatically (so the fork can actually call `memory` and `skill_manage` — Hermes' own agent-loop tools). That fork still uses your subscription auth unless you've routed aux tasks elsewhere. + +## Editing `~/.codex/config.toml` safely + +Hermes wraps everything it manages between two marker comments: + +```toml +# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section +default_permissions = ":workspace" +[mcp_servers.filesystem] +... +[plugins."github@openai-curated"] +... +# end hermes-agent managed section +``` + +Anything **outside** that block is yours. Re-running migration (via `/codex-runtime codex_app_server` or whenever you toggle the runtime on) replaces the managed block in place but preserves user content above and below it verbatim. This means you can: + +- Add your own MCP servers Hermes doesn't know about +- Override `default_permissions` to `:read-only` if you prefer to be prompted +- Configure codex-only options (model, providers, otel, etc.) +- Add user-defined permission profiles in `[permissions.]` tables + +Anything you add **inside** the managed block will get clobbered on the next migration. If you need a tweak that requires editing the managed block, file an issue and we'll add the knob. + +## Multi-profile / multi-tenant setups + +By default, Hermes points the codex subprocess at `~/.codex/` regardless of which Hermes profile is active. This means `hermes -p work` and `hermes -p personal` share the same Codex auth, plugins, and config. For most users this is the right behavior — it matches what running `codex` CLI directly would do. + +If you want per-profile Codex isolation (separate auth, separate installed plugins, separate config), set `CODEX_HOME` explicitly per profile. The cleanest way is to point at a directory under your `HERMES_HOME`: + +```bash +# Inside the work profile, you might wrap hermes: +CODEX_HOME=~/.hermes/profiles/work/codex hermes chat +``` + +You'll need to re-run `codex login` once with that `CODEX_HOME` set so the OAuth tokens land in the profile-scoped location. After that, `hermes -p work` will operate on isolated Codex state. + +We don't auto-scope this because moving an existing user's `~/.codex/` would silently invalidate their Codex CLI auth — anyone who already ran `codex login` would have to re-authenticate. Opt-in feels safer than surprising users. + +## HOME environment variable passthrough + +Hermes does NOT rewrite `HOME` when spawning the codex app-server subprocess (we use `os.environ.copy()` and only overlay `CODEX_HOME` and `RUST_LOG`). This means: + +- Commands codex runs via its `shell` tool see the real user `HOME` and find `~/.gitconfig`, `~/.gh/`, `~/.aws/`, `~/.npmrc`, etc. correctly. +- Codex's internal state stays isolated through `CODEX_HOME` (which points at `~/.codex/` by default). + +This matches the boundary OpenClaw arrived at after some early experimentation: isolate Codex's state, leave the user's home alone. (Cf. openclaw/openclaw#81562.) + +## MCP server migration + +Hermes' `mcp_servers` config is auto-translated to the TOML format Codex expects. The migration runs every time you enable the runtime and is idempotent — re-runs replace the managed section but preserve any user-edited Codex config. + +What translates: + +| Hermes (`config.yaml`) | Codex (`config.toml`) | +|---|---| +| `command` + `args` + `env` | stdio transport | +| `url` + `headers` | streamable_http transport | +| `timeout` | `tool_timeout_sec` | +| `connect_timeout` | `startup_timeout_sec` | +| `enabled: false` | `enabled = false` | + +What's not migrated: +- Hermes-specific keys like `sampling` (Codex's MCP client has no equivalent — these are dropped with a per-server warning). + +## Native Codex plugin migration + +Plugins installed via `codex plugin` (Linear, GitHub, Gmail, Calendar, Canva, etc.) are discovered through Codex's `plugin/list` RPC. For each plugin where `installed: true`, Hermes writes a `[plugins."@openai-curated"]` block enabling it in your Hermes session. + +This means: when your friend says "I have Calendar and GitHub set up in my Codex CLI" and they enable Hermes' codex runtime, Hermes activates those automatically. No re-configuration needed. + +What's NOT migrated: +- Plugins you haven't installed yet — install them in Codex first. +- Plugins where codex reports `availability != AVAILABLE` (broken install, expired OAuth, removed from marketplace, etc.). These are skipped to avoid writing config that would fail at activation time. +- ChatGPT app marketplace entries (the per-account `app/list` results — these are already enabled inside codex by virtue of your account auth). +- Plugin OAuth — you authorize each plugin once in Codex itself; Hermes doesn't touch credentials. + +## Hermes tool callback (the new MCP server) + +Codex's built-in toolset covers shell/file ops/patches but doesn't have web search, browser automation, vision, image generation, etc. To keep those usable in a codex turn, Hermes registers itself as an MCP server in `~/.codex/config.toml`: + +```toml +[mcp_servers.hermes-tools] +command = "/path/to/python" +args = ["-m", "agent.transports.hermes_tools_mcp_server"] +env = { HERMES_HOME = "/your/.hermes", PYTHONPATH = "...", HERMES_QUIET = "1" } +startup_timeout_sec = 30.0 +tool_timeout_sec = 600.0 +``` + +When the model calls `web_search` (or another exposed Hermes tool), codex spawns the `hermes_tools_mcp_server` subprocess via stdio, the request is dispatched through `model_tools.handle_function_call()`, and the result is projected back to codex like any other MCP response. + +**Tools available via the callback:** `web_search`, `web_extract`, `browser_navigate`, `browser_click`, `browser_type`, `browser_press`, `browser_snapshot`, `browser_scroll`, `browser_back`, `browser_get_images`, `browser_console`, `browser_vision`, `vision_analyze`, `image_generate`, `skill_view`, `skills_list`, `text_to_speech`. + +**Tools NOT available:** `delegate_task`, `memory`, `session_search`, `todo`. These need the running AIAgent context to dispatch (mid-loop state) and a stateless MCP callback can't drive them. Use the default Hermes runtime (`/codex-runtime auto`) when you need these. + +## Disabling + +Switch back at any time: + +``` +/codex-runtime auto +``` + +Effective on the next session. The Codex managed block stays in `~/.codex/config.toml` so you can re-enable later without losing config — or remove it manually if you prefer. + +## Limitations + +This runtime is **opt-in beta**. Working as of Hermes Agent 2026.5 + Codex CLI 0.130.0: + +- Multi-turn conversations +- `commandExecution` and `fileChange` (apply_patch) approvals via Hermes UI +- MCP tool calls (verified against `@modelcontextprotocol/server-filesystem` and the new `hermes-tools` callback) +- Native Codex plugin migration (verified against Linear / GitHub / Calendar inventory) +- Deny/cancel paths +- Toggle on/off cycle +- Memory and skill nudge counters (verified live via integration tests) +- Hermes web_search through codex (verified live: "OpenAI Codex CLI – Getting Started" returned end-to-end) + +Known limitations: + +- **Hermes auth and codex auth are separate sessions.** You need both `codex login` AND `hermes auth login codex` for the cleanest UX (the runtime uses codex's session for the LLM call). This is a deliberate design choice in Hermes' `_import_codex_cli_tokens` — Hermes won't share OAuth state with codex CLI to avoid clobbering each other on token refresh. +- **`delegate_task`, `memory`, `session_search`, `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need these. +- **No inline patch preview in approval prompts when codex doesn't track the changeset.** Codex's `fileChange` approval params don't always carry the changeset. Hermes caches the data from the corresponding `item/started` notification when possible, but if approval arrives before the item has streamed, the prompt falls back to whatever `reason` codex provides. +- **Sub-second cancellation isn't guaranteed.** Mid-stream interrupts (Ctrl+C while codex is responding) are sent via `turn/interrupt`, but if codex has already flushed the final message, you get the response anyway. + +If you find a bug, [open an issue](https://github.com/NousResearch/hermes-agent/issues) with the output of `hermes logs --since 5m`. Mention `codex-runtime` in the title so it's easy to triage. + +## Architecture + +``` + ┌─── Hermes shell (CLI / TUI / gateway) ───┐ + │ sessions DB · slash commands · memory │ + │ & skill review · cron · session pickers │ + └──┬──────────────────────────────────────┬┘ + │ user_message final │ + ▼ text + │ + ┌──────────────────────────────────┐ projected │ + │ AIAgent.run_conversation() │ messages │ + │ if api_mode == codex_app_server │ │ + │ → CodexAppServerSession │ │ + │ else: chat_completions / codex_responses (default) + └────┬─────────────────────────────┘ │ + │ JSON-RPC over stdio │ + ▼ │ + ┌──────────────────────────────────┐ │ + │ codex app-server (subprocess) │──────────────┘ + │ thread/start, turn/start │ + │ item/* notifications │ + │ shell + apply_patch + update_plan│ + │ view_image + sandbox │ + │ ┌─────────────────────────┐ │ + │ │ MCP client │ │ + │ │ ├─ user MCP servers │ │ + │ │ ├─ native plugins │ │ + │ │ │ (linear, github, │ │ + │ │ │ gmail, calendar, │ │ + │ │ │ canva, ...) │ │ + │ │ └─ hermes-tools ───────┼─────────────────┐ + │ │ (callback to │ │ │ + │ │ Hermes' richer │ │ │ + │ │ tools) │ │ │ + │ └─────────────────────────┘ │ │ + └──────────────────────────────────┘ │ + │ + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ hermes_tools_mcp_server.py (subprocess on demand) │ + │ web_search, web_extract, browser_*, vision_analyze, │ + │ image_generate, skill_view, skills_list, text_to_speech│ + └──────────────────────────────────────────────────────────┘ +``` + +For implementation details, see [PR #24182](https://github.com/NousResearch/hermes-agent/pull/24182) and the [Codex app-server protocol README](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md). diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index cd002ae689e1..fa0cd91ab075 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -74,6 +74,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | Kilo Code | `kilocode` | `KILOCODE_API_KEY` | | Xiaomi MiMo | `xiaomi` | `XIAOMI_API_KEY` | | Arcee AI | `arcee` | `ARCEEAI_API_KEY` | +| Auriko | `auriko` | `AURIKO_API_KEY` (optional: `AURIKO_BASE_URL`) | | GMI Cloud | `gmi` | `GMI_API_KEY` | | Alibaba / DashScope | `alibaba` | `DASHSCOPE_API_KEY` | | Alibaba Coding Plan | `alibaba-coding-plan` | `ALIBABA_CODING_PLAN_API_KEY` (falls back to `DASHSCOPE_API_KEY`) | diff --git a/website/docs/user-guide/features/lsp.md b/website/docs/user-guide/features/lsp.md index bb54003b11ae..c0ed863f7dc1 100644 --- a/website/docs/user-guide/features/lsp.md +++ b/website/docs/user-guide/features/lsp.md @@ -21,7 +21,7 @@ install, no separate daemon to manage. ## When LSP runs LSP is gated on **git workspace detection**. When the agent's working -directory (or the file being edited) is inside a git worktree, LSP +directory (or the file being edited) is inside a git repository, LSP runs against that workspace. When neither is in a git repo, LSP stays dormant — useful for messaging gateways where the cwd is the user's home directory and there's no project to diagnose. @@ -249,5 +249,6 @@ the next edit re-spawns. **Editing a file outside any git repo** -By design, LSP only runs inside git worktrees. Run `git init` in the -project, or accept the in-process syntax-only fallback. +By design, LSP only runs inside a git repository. If the project isn't +yet initialized, run `git init` to enable LSP diagnostics. Otherwise the +in-process syntax-only fallback applies. diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 8bab522f9dd5..e9dc29108896 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -109,6 +109,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Distribute via pip | `[project.entry-points."hermes_agent.plugins"]` | | Register a gateway platform (Discord, Telegram, IRC, …) | `ctx.register_platform(name, label, adapter_factory, check_fn, ...)` — see [Adding Platform Adapters](/docs/developer-guide/adding-platform-adapters) | | Register an image-generation backend | `ctx.register_image_gen_provider(provider)` — see [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) | +| Register a video-generation backend | `ctx.register_video_gen_provider(provider)` — see [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin) | | Register a context-compression engine | `ctx.register_context_engine(engine)` — see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) | | Register a memory backend | Subclass `MemoryProvider` in `plugins/memory//__init__.py` — see [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) (uses a separate discovery system) | | Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/docs/developer-guide/plugin-llm-access) | @@ -230,6 +231,7 @@ The table above shows the four plugin categories, but within "General plugins" t | A **memory backend** (Honcho, Mem0, Supermemory, …) | Memory plugin — subclass `MemoryProvider` in `plugins/memory//` | [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) | | A **context-compression strategy** | Context-engine plugin — `ctx.register_context_engine()` | [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) | | An **image-generation backend** (DALL·E, SDXL, …) | Backend plugin — `ctx.register_image_gen_provider()` | [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) | +| A **video-generation backend** (Veo, Kling, Pixverse, Grok-Imagine, Runway, …) | Backend plugin — `ctx.register_video_gen_provider()` | [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin) | | A **TTS backend** (any CLI — Piper, VoxCPM, Kokoro, xtts, voice-cloning scripts, …) | Config-driven — declare under `tts.providers.` with `type: command` in `config.yaml` | [TTS setup](/docs/user-guide/features/tts#custom-command-providers) | | An **STT backend** (custom whisper binary, local ASR CLI) | Config-driven — set `HERMES_LOCAL_STT_COMMAND` env var to a shell template | [Voice Message Transcription (STT)](/docs/user-guide/features/tts#voice-message-transcription-stt) | | **External tools via MCP** (filesystem, GitHub, Linear, Notion, any MCP server) | Config-driven — declare `mcp_servers.` with `command:` / `url:` in `config.yaml`. Hermes auto-discovers the server's tools and registers them alongside built-ins. | [MCP](/docs/user-guide/features/mcp) | diff --git a/website/docs/user-guide/features/subscription-proxy.md b/website/docs/user-guide/features/subscription-proxy.md new file mode 100644 index 000000000000..8f0fe31f9ca8 --- /dev/null +++ b/website/docs/user-guide/features/subscription-proxy.md @@ -0,0 +1,203 @@ +--- +sidebar_position: 15 +title: "Subscription Proxy" +description: "Use your Nous Portal subscription (or other OAuth provider) as an OpenAI-compatible endpoint for external apps" +--- + +# Subscription Proxy + +The subscription proxy is a local HTTP server that lets external apps — +OpenViking, Karakeep, Open WebUI, anything that speaks OpenAI-compatible +chat completions — use your Hermes-managed provider subscription as their +LLM endpoint. The proxy attaches the right credentials (refreshing them +automatically) so the app never needs a static API key. + +This is different from the [API server](./api-server.md): + +| | API server | Subscription proxy | +|---|---|---| +| What it serves | Your agent (full toolset, memory, skills) | Raw model inference | +| Use case | "Use Hermes as a chat backend" | "Use my Portal sub from another app" | +| Auth | Your `API_SERVER_KEY` | Any bearer (proxy attaches the real one) | +| Tool calls | Yes — the agent runs tools | No — passthrough only | + +Use the API server when you want the **agent** as a backend. Use the +proxy when you just want **the model** through your subscription. + +## Quick Start + +### 1. Log into your provider (one-time) + +```bash +hermes login nous +``` + +This opens your browser for the Nous Portal OAuth flow. Hermes stores +the refresh token in `~/.hermes/auth.json` — the same place all Hermes +provider logins live. + +### 2. Start the proxy + +```bash +hermes proxy start +``` + +``` +Starting Hermes proxy for Nous Portal + Listening on: http://127.0.0.1:8645/v1 + Forwarding to: (resolved per-request from your subscription) + Use any bearer token in the client — the proxy attaches your real credential. +``` + +Leave this running in the foreground. Use `tmux`, `nohup`, or a systemd +unit if you want it to survive logout. + +### 3. Point your app at it + +Any OpenAI-compatible app config takes the same triple: + +``` +Base URL: http://127.0.0.1:8645/v1 +API key: anything (e.g. "sk-unused") +Model: Hermes-4-70B # or Hermes-4.3-36B, Hermes-4-405B +``` + +The proxy ignores the `Authorization` header from your app and attaches +your real Portal credential to the upstream request. Refreshes happen +automatically when the bearer approaches expiry. + +## Available providers + +```bash +hermes proxy providers +``` + +Currently shipped: `nous` (Nous Portal). More OAuth providers can be +added by implementing the `UpstreamAdapter` interface in +`hermes_cli/proxy/adapters/`. + +## Check status + +```bash +hermes proxy status +``` + +``` +Hermes proxy upstream adapters + + [nous ] Nous Portal — ready (bearer expires 2026-05-15T06:43:21Z) +``` + +If you see `not logged in`, run `hermes login nous`. If you see +`credentials need attention`, your refresh token was revoked (rare — +happens if you signed out from the Portal web UI) — just re-run +`hermes login nous`. + +## Allowed paths + +The proxy only forwards paths the upstream actually serves. For Nous +Portal: + +| Path | Purpose | +|------|---------| +| `/v1/chat/completions` | Chat completions (streaming + non-streaming) | +| `/v1/completions` | Legacy text completions | +| `/v1/embeddings` | Embeddings | +| `/v1/models` | Model list | + +Other paths (`/v1/images/generations`, `/v1/audio/speech`, etc.) return +404 with a clear error pointing at the allowed paths. This keeps stray +clients from leaking weird requests to the upstream. + +## Configuring OpenViking to use Portal + +[OpenViking](https://github.com/volcengine/OpenViking) is a context +database that needs an LLM provider for its VLM (vision/language model +used to extract memories) and embedding model. With the proxy, you can +point its `vlm.api_base` at your local proxy: + +Edit `~/.openviking/ov.conf`: + +```json +{ + "vlm": { + "provider": "openai", + "model": "Hermes-4-70B", + "api_base": "http://127.0.0.1:8645/v1", + "api_key": "unused-proxy-attaches-real-creds" + } +} +``` + +Then start your proxy in a terminal alongside `openviking-server`: + +```bash +# Terminal 1 +hermes proxy start + +# Terminal 2 +openviking-server +``` + +OpenViking's VLM calls now flow through your Portal subscription. The +embedding model side still needs its own provider — Portal does serve +`/v1/embeddings` but the model selection depends on what your tier +supports; check `portal.nousresearch.com/models`. + +## Configuring Karakeep (or any bookmark/summarizer app) + +[Karakeep](https://karakeep.app/) takes an OpenAI-compatible API for +bookmark summarization. In its config: + +```bash +# Karakeep .env +OPENAI_API_BASE_URL=http://127.0.0.1:8645/v1 +OPENAI_API_KEY=any-non-empty-string +INFERENCE_TEXT_MODEL=Hermes-4-70B +``` + +Same pattern works for Open WebUI, LobeChat, NextChat, or any other +OpenAI-compatible client. + +## Exposing on LAN + +By default the proxy binds `127.0.0.1` (localhost only). To let other +machines on your network use it: + +```bash +hermes proxy start --host 0.0.0.0 --port 8645 +``` + +⚠ **Be aware:** anyone on your network can now use your Portal +subscription. The proxy has no auth of its own — it accepts any bearer. +Use a firewall, VPN, or reverse proxy with proper auth if you expose +this beyond your trusted network. + +## Rate limits + +Your Portal tier's RPM/TPM limits apply across the whole proxy. The +proxy doesn't fan out or pool — it's a single bearer with your full +subscription quota. Monitor usage at +[portal.nousresearch.com](https://portal.nousresearch.com). + +## Architecture + +The proxy is intentionally minimal. Per request: + +1. Receive `POST /v1/chat/completions` from your app +2. Look up the adapter's current credential (refresh if expiring) +3. Forward the request body verbatim, with `Authorization: Bearer ` +4. Stream the response back unchanged (SSE preserved) + +No transformation. No logging of request bodies. No agent loop. The +proxy is a credential-attaching pass-through. + +## Future: more OAuth providers + +The adapter system is pluggable. Adding a new provider (e.g. +HuggingFace, GitHub Copilot's chat endpoint, Anthropic via OAuth) +requires implementing `UpstreamAdapter` in +`hermes_cli/proxy/adapters/.py` and registering it in +`adapters/__init__.py`. Providers that aren't OpenAI-compatible at the +protocol level (Anthropic Messages API, for example) would need a +transformation layer, which is out of scope for the current shape. diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 375d682f92d0..50f1641f0933 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -277,6 +277,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. | | `DISCORD_COMMAND_SYNC_POLICY` | No | `"safe"` | Controls native slash-command startup sync. `"safe"` diffs existing global commands and only updates what changed, recreating commands when Discord metadata changes cannot be applied via patch. `"bulk"` preserves the old `tree.sync()` behavior. `"off"` skips startup sync entirely. | | `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. | +| `DISCORD_THREAD_REQUIRE_MENTION` | No | `false` | When `true`, the in-thread mention shortcut is disabled — threads are gated the same as channels, requiring `@mention` even after the bot has already participated. Use this when multiple bots share a thread and you want each to fire only on explicit `@mention`. | | `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. | | `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. | | `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. | @@ -285,6 +286,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. | | `DISCORD_ALLOWED_CHANNELS` | No | — | Comma-separated channel IDs. When set, the bot **only** responds in these channels (plus DMs if allowed). Overrides `config.yaml` `discord.allowed_channels`. Combine with `DISCORD_IGNORED_CHANNELS` to express allow/deny rules. | | `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. | +| `DISCORD_HISTORY_BACKFILL` | No | `true` | When `true`, prepend recent channel scrollback (since the bot's last response) to the user message when the bot is mentioned. Recovers context the bot would otherwise miss with `require_mention`. Skipped in DMs and free-response channels. Set to `false` to disable. | +| `DISCORD_HISTORY_BACKFILL_LIMIT` | No | `50` | Maximum number of messages to scan backwards when assembling the backfill block. In practice the scan usually stops earlier — at the bot's own last message in the channel. | | `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. | | `DISCORD_ALLOW_MENTION_EVERYONE` | No | `false` | When `false` (default), the bot cannot ping `@everyone` or `@here` even if its response contains those tokens. Set to `true` to opt back in. See [Mention Control](#mention-control) below. | | `DISCORD_ALLOW_MENTION_ROLES` | No | `false` | When `false` (default), the bot cannot ping `@role` mentions. Set to `true` to allow. | @@ -302,11 +305,14 @@ The `discord` section in `~/.hermes/config.yaml` mirrors the env vars above. Con # Discord-specific settings discord: require_mention: true # Require @mention in server channels + thread_require_mention: false # If true, require @mention in threads too (multi-bot threads) free_response_channels: "" # Comma-separated channel IDs (or YAML list) auto_thread: true # Auto-create threads on @mention reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading + history_backfill: true # Prepend recent channel scrollback on mention (default: true) + history_backfill_limit: 50 # Max messages to scan backwards (default: 50) channel_prompts: {} # Per-channel ephemeral system prompts allow_mentions: # What the bot is allowed to ping (safe defaults) everyone: false # @everyone / @here pings (default: false) @@ -324,6 +330,20 @@ group_sessions_per_user: true # Isolate sessions per user in shared channels When enabled, the bot only responds in server channels when directly `@mentioned`. DMs always get a response regardless of this setting. +#### `discord.thread_require_mention` + +**Type:** boolean — **Default:** `false` + +By default, once the bot has participated in a thread (auto-created on `@mention` or replied in once), it keeps responding to every subsequent message in that thread without needing to be `@mentioned` again. That's the right default for one-on-one conversations. + +In **multi-bot threads** where users address one bot per turn, this default becomes a footgun — every other bot in the thread also fires on every message, burning credits and spamming the channel. Set `thread_require_mention: true` to disable the in-thread shortcut and gate threads the same way channels are gated. Explicit `@mentions` still work as before. + +```yaml +discord: + require_mention: true + thread_require_mention: true # multi-bot setup +``` + #### `discord.free_response_channels` **Type:** string or list — **Default:** `""` @@ -350,7 +370,7 @@ Free-response channels also **skip auto-threading** — the bot replies inline r **Type:** boolean — **Default:** `true` -When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. +When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. Set [`thread_require_mention`](#discordthread_require_mention) to `true` to disable this in-thread shortcut for multi-bot setups. Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead. @@ -421,6 +441,47 @@ Behavior: - If a message arrives inside a thread or forum post and that thread has no explicit entry, Hermes falls back to the parent channel/forum ID. - Prompts are applied ephemerally at runtime, so changing them affects future turns immediately without rewriting past session history. +#### `discord.history_backfill` + +**Type:** boolean — **Default:** `true` + +When enabled, the bot recovers missed channel messages on each `@mention`. With `require_mention: true`, the bot only processes messages that tag it directly — everything else in the channel is invisible to the session transcript. History backfill scans backwards through recent channel history when triggered, collecting messages between the bot's last response and the current mention, and includes them as context. + +Behavior by surface: + +- **Server channels** (with `require_mention: true`): backfill scans the channel since the bot's last response. Useful when other participants posted while the bot wasn't addressed. +- **Threads**: backfill scans the thread only — Discord's `channel.history()` on a thread returns only that thread's messages, not the parent channel. This is the right scope because threads are usually self-contained conversations. +- **DMs**: skipped. Every DM message triggers the bot, so the session transcript is already complete — there's no mention gap to fill. +- **Free-response channels** and **bot's own auto-created threads**: skipped for the same reason — no mention gating means no gap. + +Per-user sessions (`group_sessions_per_user: true`, the default) also benefit: a user's session is missing the context posted by other channel participants and the user's own messages from before they tagged the bot. Backfill fills both gaps. + +```yaml +discord: + history_backfill: true # default +``` + +To turn it off: + +```yaml +discord: + history_backfill: false +``` + +> **Note:** Messages that arrive *while* the bot is processing (between a trigger and its response) are not captured. This is an accepted simplification — the user can re-send or tag again. + +#### `discord.history_backfill_limit` + +**Type:** integer — **Default:** `50` + +Maximum number of messages to scan backwards when recovering channel context. In practice the scan usually stops much earlier — at the bot's own last message in the channel, which is the natural boundary between turns. This limit is a safety cap for cold starts and long gaps where no prior bot message exists in recent history. + +```yaml +discord: + history_backfill: true + history_backfill_limit: 50 +``` + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true` diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index f5b29c9d132a..b5a64fb84f45 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -264,6 +264,22 @@ For backward compatibility with older manifests, you can still type run the tests`. Free-form questions also work: `/hermes what's the weather?` is treated as a regular message. +### Using commands inside threads (the `!cmd` prefix) + +Slack itself blocks native slash commands inside thread replies — try +`/queue` in a thread and Slack responds with *"/queue is not supported +in threads. Sorry!"* There is no app-side setting that re-enables them; +Slack never delivers them to Hermes. + +As a workaround, Hermes recognises a leading `!` as an alternate +command prefix that works in threads (and anywhere else). Type +`!queue`, `!stop`, `!model gpt-5.4`, etc. as a regular thread reply — +Hermes treats it identically to the slash form and replies in the same +thread. + +Only the first token is checked against the known command list, so +casual messages like `!nice work` pass through to the agent unchanged. + ### Advanced: emit only the slash-commands array If you maintain your Slack manifest by hand and just want the slash diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index b455ea92e373..e90c3f60bcb0 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -25,6 +25,43 @@ The SQLite database stores: - Timestamps (started_at, ended_at) - Parent session ID (for compression-triggered session splitting) +### What Counts Toward Context + +Hermes stores session history so it can resume conversations, but it does not +keep re-sending every byte it has ever handled. On each turn, the model sees +the selected system prompt, the current conversation window, and any content +Hermes explicitly injects for that turn. + +Media attachments are handled as turn-scoped inputs: + +- Images may be attached natively to the next model call, or pre-analyzed into + a text description when the active model does not support native vision. +- Audio is transcribed into text when speech-to-text is configured. +- Text documents can have their extracted text included; other document types + are usually represented by a saved local path and a short note. +- Attachment paths and extracted/derived text can appear in the transcript, but + the raw image, audio, or binary file bytes are not repeatedly copied into + future prompts. + +For example, if a user sends an image and asks Hermes to make a meme from it, +Hermes may inspect that image once with vision and run an image-processing +script. Future turns do not automatically carry the original JPEG in context. +They carry only whatever was written into the conversation, such as the user's +request, a short image description, a local cache path, or the final assistant +response. + +The most common cause of context growth is not the media file itself. It is +verbose text: pasted transcripts, full logs, large tool outputs, long diffs, +repeated status reports, and detailed proof dumps. Prefer summaries, file +paths, focused excerpts, and tool-backed lookups over copying large artifacts +into chat. + +:::tip +Use `/compress` when a session gets long, `/new` for a fresh thread, and +`hermes sessions prune` only when you want to delete old ended sessions from +storage. Compression reduces the active context; it is not a privacy delete. +::: + ### Session Sources Each session is tagged with its source platform: diff --git a/website/docs/user-guide/skills/optional/blockchain/blockchain-base.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-base.md deleted file mode 100644 index a9d9cb8c6c1c..000000000000 --- a/website/docs/user-guide/skills/optional/blockchain/blockchain-base.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -title: "Base" -sidebar_label: "Base" -description: "Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detect..." ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Base - -Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required. - -## Skill metadata - -| | | -|---|---| -| Source | Optional — install with `hermes skills install official/blockchain/base` | -| Path | `optional-skills/blockchain/base` | -| Version | `0.1.0` | -| Author | youssefea | -| License | MIT | -| Platforms | linux, macos, windows | -| Tags | `Base`, `Blockchain`, `Crypto`, `Web3`, `RPC`, `DeFi`, `EVM`, `L2`, `Ethereum` | - -## Reference: full SKILL.md - -:::info -The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. -::: - -# Base Blockchain Skill - -Query Base (Ethereum L2) on-chain data enriched with USD pricing via CoinGecko. -8 commands: wallet portfolio, token info, transactions, gas analysis, -contract inspection, whale detection, network stats, and price lookup. - -No API key needed. Uses only Python standard library (urllib, json, argparse). - ---- - -## When to Use - -- User asks for a Base wallet balance, token holdings, or portfolio value -- User wants to inspect a specific transaction by hash -- User wants ERC-20 token metadata, price, supply, or market cap -- User wants to understand Base gas costs and L1 data fees -- User wants to inspect a contract (ERC type detection, proxy resolution) -- User wants to find large ETH transfers (whale detection) -- User wants Base network health, gas price, or ETH price -- User asks "what's the price of USDC/AERO/DEGEN/ETH?" - ---- - -## Prerequisites - -The helper script uses only Python standard library (urllib, json, argparse). -No external packages required. - -Pricing data comes from CoinGecko's free API (no key needed, rate-limited -to ~10-30 requests/minute). For faster lookups, use `--no-prices` flag. - ---- - -## Quick Reference - -RPC endpoint (default): https://mainnet.base.org -Override: export BASE_RPC_URL=https://your-private-rpc.com - -Helper script path: ~/.hermes/skills/blockchain/base/scripts/base_client.py - -``` -python3 base_client.py wallet
[--limit N] [--all] [--no-prices] -python3 base_client.py tx -python3 base_client.py token -python3 base_client.py gas -python3 base_client.py contract
-python3 base_client.py whales [--min-eth N] -python3 base_client.py stats -python3 base_client.py price -``` - ---- - -## Procedure - -### 0. Setup Check - -```bash -python3 --version - -# Optional: set a private RPC for better rate limits -export BASE_RPC_URL="https://mainnet.base.org" - -# Confirm connectivity -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` - -### 1. Wallet Portfolio - -Get ETH balance and ERC-20 token holdings with USD values. -Checks ~15 well-known Base tokens (USDC, WETH, AERO, DEGEN, etc.) -via on-chain `balanceOf` calls. Tokens sorted by value, dust filtered. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -``` - -Flags: -- `--limit N` — show top N tokens (default: 20) -- `--all` — show all tokens, no dust filter, no limit -- `--no-prices` — skip CoinGecko price lookups (faster, RPC-only) - -Output includes: ETH balance + USD value, token list with prices sorted -by value, dust count, total portfolio value in USD. - -Note: Only checks known tokens. Unknown ERC-20s are not discovered. -Use the `token` command with a specific contract address for any token. - -### 2. Transaction Details - -Inspect a full transaction by its hash. Shows ETH value transferred, -gas used, fee in ETH/USD, status, and decoded ERC-20/ERC-721 transfers. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - tx 0xabc123...your_tx_hash_here -``` - -Output: hash, block, from, to, value (ETH + USD), gas price, gas used, -fee, status, contract creation address (if any), token transfers. - -### 3. Token Info - -Get ERC-20 token metadata: name, symbol, decimals, total supply, price, -market cap, and contract code size. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Output: name, symbol, decimals, total supply, price, market cap. -Reads name/symbol/decimals directly from the contract via eth_call. - -### 4. Gas Analysis - -Detailed gas analysis with cost estimates for common operations. -Shows current gas price, base fee trends over 10 blocks, block -utilization, and estimated costs for ETH transfers, ERC-20 transfers, -and swaps. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py gas -``` - -Output: current gas price, base fee, block utilization, 10-block trend, -cost estimates in ETH and USD. - -Note: Base is an L2 — actual transaction costs include an L1 data -posting fee that depends on calldata size and L1 gas prices. The -estimates shown are for L2 execution only. - -### 5. Contract Inspection - -Inspect an address: determine if it's an EOA or contract, detect -ERC-20/ERC-721/ERC-1155 interfaces, resolve EIP-1967 proxy -implementation addresses. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Output: is_contract, code size, ETH balance, detected interfaces -(ERC-20, ERC-721, ERC-1155), ERC-20 metadata, proxy implementation -address. - -### 6. Whale Detector - -Scan the most recent block for large ETH transfers with USD values. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py \ - whales --min-eth 1.0 -``` - -Note: scans the latest block only — point-in-time snapshot, not historical. -Default threshold is 1.0 ETH (lower than Solana's default since ETH -values are higher). - -### 7. Network Stats - -Live Base network health: latest block, chain ID, gas price, base fee, -block utilization, transaction count, and ETH price. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` - -### 8. Price Lookup - -Quick price check for any token by contract address or known symbol. - -```bash -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price ETH -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price USDC -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price AERO -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price DEGEN -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py price 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -``` - -Known symbols: ETH, WETH, USDC, cbETH, AERO, DEGEN, TOSHI, BRETT, -WELL, wstETH, rETH, cbBTC. - ---- - -## Pitfalls - -- **CoinGecko rate-limits** — free tier allows ~10-30 requests/minute. - Price lookups use 1 request per token. Use `--no-prices` for speed. -- **Public RPC rate-limits** — Base's public RPC limits requests. - For production use, set BASE_RPC_URL to a private endpoint - (Alchemy, QuickNode, Infura). -- **Wallet shows known tokens only** — unlike Solana, EVM chains have no - built-in "get all tokens" RPC. The wallet command checks ~15 popular - Base tokens via `balanceOf`. Unknown ERC-20s won't appear. Use the - `token` command for any specific contract. -- **Token names read from contract** — if a contract doesn't implement - `name()` or `symbol()`, these fields may be empty. Known tokens have - hardcoded labels as fallback. -- **Gas estimates are L2 only** — Base transaction costs include an L1 - data posting fee (depends on calldata size and L1 gas prices). The gas - command estimates L2 execution cost only. -- **Whale detector scans latest block only** — not historical. Results - vary by the moment you query. Default threshold is 1.0 ETH. -- **Proxy detection** — only EIP-1967 proxies are detected. Other proxy - patterns (EIP-1167 minimal proxy, custom storage slots) are not checked. -- **Retry on 429** — both RPC and CoinGecko calls retry up to 2 times - with exponential backoff on rate-limit errors. - ---- - -## Verification - -```bash -# Should print Base chain ID (8453), latest block, gas price, and ETH price -python3 ~/.hermes/skills/blockchain/base/scripts/base_client.py stats -``` diff --git a/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md new file mode 100644 index 000000000000..01006870ee42 --- /dev/null +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md @@ -0,0 +1,227 @@ +--- +title: "Evm — Read-only EVM client: wallets, tokens, gas across 8 chains" +sidebar_label: "Evm" +description: "Read-only EVM client: wallets, tokens, gas across 8 chains" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Evm + +Read-only EVM client: wallets, tokens, gas across 8 chains. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/blockchain/evm` | +| Path | `optional-skills/blockchain/evm` | +| Version | `1.0.0` | +| Author | Mibayy (@Mibayy), youssefea (@youssefea), ethernet8023 (@ethernet8023), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `EVM`, `Ethereum`, `BNB`, `BSC`, `Base`, `Arbitrum`, `Polygon`, `Optimism`, `Avalanche`, `zkSync`, `Blockchain`, `Crypto`, `Web3`, `DeFi`, `NFT`, `ENS`, `Whale`, `Security` | +| Related skills | [`solana`](/docs/user-guide/skills/optional/blockchain/blockchain-solana) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# EVM Blockchain Skill + +Query EVM-compatible blockchain data across 8 chains with USD pricing. +14 commands: wallet portfolio, token info, transactions, activity, gas tracker, +network stats, price lookup, multi-chain scan, whale detection, ENS resolution, +allowance checker, contract inspector, and transaction decoder. + +Supports 8 chains: Ethereum, BNB Chain (BSC), Base, Arbitrum One, Polygon, +Optimism, Avalanche (C-Chain), zkSync Era. + +No API key needed. Zero external dependencies — Python standard library only +(urllib, json, argparse, threading). + +> **Supersedes the standalone `base` skill.** Base-specific tokens (AERO, DEGEN, +> TOSHI, BRETT, WELL, cbETH, cbBTC, wstETH, rETH) and all Base RPC functionality +> previously living under `optional-skills/blockchain/base/` have been folded +> into this skill. Pass `--chain base` to any command for Base coverage. + +--- + +## When to Use +- User asks for a wallet balance or portfolio on any EVM chain +- User wants to check the same wallet across ALL chains at once +- User wants to inspect a transaction by hash (or decode what it did) +- User wants ERC-20 token metadata, price, supply, or market cap +- User wants recent transaction history for an address +- User wants current gas prices or to compare fees across chains +- User wants to find large whale transfers in recent blocks +- User asks to resolve an ENS name (vitalik.eth) or reverse-lookup an address +- User wants to check if a contract has dangerous token approvals +- User wants to inspect a smart contract (proxy? ERC-20? ERC-721? bytecode size?) +- User wants to compare gas costs across chains before a transaction + +--- + +## Prerequisites +Python 3.8+ standard library only. No pip installs required. +Pricing: CoinGecko free API (rate-limited, ~10-30 req/min). +ENS: ensideas.com public API. +Tx decoding: 4byte.directory public API. + +Override RPC endpoint: `export EVM_RPC_URL=https://your-rpc.com` + +Helper script path: `~/.hermes/skills/blockchain/evm/scripts/evm_client.py` + +--- + +## Quick Reference + +``` +SCRIPT=~/.hermes/skills/blockchain/evm/scripts/evm_client.py + +# Network & prices +python3 $SCRIPT stats # Ethereum stats +python3 $SCRIPT stats --chain arbitrum # Arbitrum stats +python3 $SCRIPT compare # Gas + prices ALL 8 chains + +# Wallet +python3 $SCRIPT wallet 0xd8dA...96045 # Portfolio (ETH + ERC-20) +python3 $SCRIPT wallet 0xd8dA...96045 --chain bsc +python3 $SCRIPT multichain 0xd8dA...96045 # Same wallet on ALL chains + +# Tokens & prices +python3 $SCRIPT price ETH +python3 $SCRIPT price 0xdAC1...1ec7 # By contract address +python3 $SCRIPT token 0xdAC1...1ec7 # ERC-20 metadata + market cap + +# Transactions +python3 $SCRIPT tx 0x5c50...f060 # Transaction details +python3 $SCRIPT decode 0x5c50...f060 # Decode input data (4byte.directory) +python3 $SCRIPT activity 0xd8dA...96045 # Recent transactions + +# Gas +python3 $SCRIPT gas # Gas prices + cost estimates +python3 $SCRIPT gas --chain optimism + +# Security +python3 $SCRIPT allowance 0xd8dA...96045 # Dangerous ERC-20 approvals +python3 $SCRIPT contract 0xdAC1...1ec7 # Contract inspection (proxy? standards?) + +# ENS +python3 $SCRIPT ens vitalik.eth # Name -> address + profile +python3 $SCRIPT ens 0xd8dA...96045 # Address -> ENS name + +# Whale detection +python3 $SCRIPT whale # Large transfers (last 20 blocks, >$10k) +python3 $SCRIPT whale --blocks 50 --min-usd 100000 --chain arbitrum +``` + +--- + +## Procedure + +### 0. Setup Check +```bash +python3 --version # 3.8+ required +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats +``` + +### 1. Wallet Portfolio +Native balance + known ERC-20 tokens, sorted by USD value. +```bash +python3 $SCRIPT wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +python3 $SCRIPT wallet 0xd8dA... --chain bsc --no-prices # faster +``` + +### 2. Multi-Chain Scan +Scans all 8 chains simultaneously for the same address using threads. +```bash +python3 $SCRIPT multichain 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` +Output: per-chain native balance + token holdings + grand total USD. + +### 3. Compare (Gas + Prices) +All 8 chains queried in parallel. Shows cheapest/most expensive chain. +```bash +python3 $SCRIPT compare +``` + +### 4. Transaction Details & Decode +```bash +python3 $SCRIPT tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 +python3 $SCRIPT decode 0x5c504ed... # Shows human-readable function signature +``` +Decode uses 4byte.directory to translate 0xa9059cbb -> transfer(address,uint256). + +### 5. ENS Resolution +```bash +python3 $SCRIPT ens vitalik.eth # -> 0xd8dA... + avatar + social links +python3 $SCRIPT ens 0xd8dA...96045 # -> vitalik.eth +``` + +### 6. Allowance Checker (Security) +Checks ERC-20 approvals granted to known DEX/bridge contracts. +```bash +python3 $SCRIPT allowance 0xYourWallet +``` +Flags UNLIMITED approvals as HIGH risk. + +### 7. Contract Inspector +```bash +python3 $SCRIPT contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # USDC (proxy) +python3 $SCRIPT contract 0xdAC17F958D2ee523a2206206994597C13D831ec7 # USDT (ERC-20) +``` +Detects: proxy (EIP-1967/EIP-1167), ERC-20, ERC-721, ERC-165. Shows bytecode size and implementation address for proxies. + +### 8. Whale Detection +```bash +python3 $SCRIPT whale # ETH, last 20 blocks, >$10k +python3 $SCRIPT whale --blocks 50 --min-usd 50000 --chain bsc +``` + +### 9. Gas Tracker +```bash +python3 $SCRIPT gas +python3 $SCRIPT gas --chain polygon +``` +Shows gwei price + USD cost for: transfer, ERC-20 transfer, approve, swap, NFT mint, NFT transfer. + +--- + +## Supported Chains +| Key | Name | Native | Chain ID | +|-----------|----------------|--------|----------| +| ethereum | Ethereum | ETH | 1 | +| bsc | BNB Chain | BNB | 56 | +| base | Base | ETH | 8453 | +| arbitrum | Arbitrum One | ETH | 42161 | +| polygon | Polygon | POL | 137 | +| optimism | Optimism | ETH | 10 | +| avalanche | Avalanche C | AVAX | 43114 | +| zksync | zkSync Era | ETH | 324 | + +--- + +## Pitfalls +- CoinGecko free tier: ~10-30 req/min. Use `--no-prices` for faster wallet scans. +- Public RPCs may throttle. Set EVM_RPC_URL to a private endpoint for production. +- `wallet` and `allowance` only check known token list (~30 tokens per chain). Use a block explorer for complete token discovery. +- `activity` scans recent blocks only (max 200). For full history, use Etherscan API. +- `multichain` runs 8 parallel threads — can trigger rate limits on public RPCs. +- ENS resolution depends on a single public endpoint (ensideas.com / ens.vitalik.ca) with no fallback. If that endpoint is down, `ens` will fail — re-run later or use a block explorer. +- Tx decoding depends on a single public endpoint (4byte.directory) with no fallback. Selectors not in their database show up as `unknown`. +- **L2 gas estimates are L2-execution only.** On rollups like Base, Arbitrum, Optimism, and zkSync, the actual transaction cost also includes an L1 data-posting fee that depends on calldata size and current L1 gas prices. The `gas` command does not estimate that L1 component. For Base specifically, see the network's L1 fee oracle (contract `0x420000000000000000000000000000000000000F`). +- Address / tx-hash inputs are validated for 0x-prefix + correct length + hex, but EIP-55 checksum casing is **not** enforced (RPC endpoints accept any-case hex). + +--- + +## Verification +```bash +# Should print current block, gas price, ETH price +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats + +# Should resolve vitalik.eth to 0xd8dA... +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py ens vitalik.eth +``` diff --git a/website/sidebars.ts b/website/sidebars.ts index 67a256bcc094..37557df8d118 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -68,6 +68,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/cron', 'user-guide/features/delegation', 'user-guide/features/kanban', + 'user-guide/features/codex-app-server-runtime', 'user-guide/features/kanban-tutorial', 'user-guide/features/kanban-worker-lanes', 'user-guide/features/goals', @@ -95,6 +96,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/web-dashboard', 'user-guide/features/extending-the-dashboard', + 'user-guide/features/subscription-proxy', ], }, { @@ -223,6 +225,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/context-engine-plugin', 'developer-guide/model-provider-plugin', 'developer-guide/image-gen-provider-plugin', + 'developer-guide/video-gen-provider-plugin', 'developer-guide/plugin-llm-access', 'developer-guide/creating-skills', 'developer-guide/extending-the-cli',
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.