From 71c6dd0dcf97721656056e5d5b99f4a0b62b8846 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 12 May 2026 19:13:57 -0700 Subject: [PATCH 001/214] fix(cli): add 'lsp' to _BUILTIN_SUBCOMMANDS so plugin discovery is skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lsp` is registered as a top-level subparser in `main()` (lines 9539-9545) via `agent.lsp.cli.register_subparser`, so it shows up in `hermes --help` output alongside the other built-ins. The `_BUILTIN_SUBCOMMANDS` set used by `_plugin_cli_discovery_needed` to short-circuit the ~500-650ms plugin import pass did not list it, so every `hermes lsp ...` invocation paid the full discovery cost despite being a fully-built-in command. This is also caught by the parity guard added in #22120: `tests/hermes_cli/test_startup_plugin_gating.py::test_builtin_set_covers_every_registered_subcommand` has been failing on clean origin/main with: AssertionError: _BUILTIN_SUBCOMMANDS is missing these live subcommands: ['lsp']. Add them to hermes_cli/main.py::_BUILTIN_SUBCOMMANDS so plugin discovery can be skipped when the user targets them. Fix: add `"lsp"` to the frozenset (alphabetical position between `logs` and `mcp`). The accompanying `test_builtin_set_has_no_phantom_entries` guard still passes because `lsp` is genuinely live — registered via the guarded `try/except Exception` in main() since #24168. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 64310dc6af1..e8aa0d761c4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9188,10 +9188,10 @@ _BUILTIN_SUBCOMMANDS = frozenset( "computer-use", "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", - "kanban", "login", "logout", "logs", "mcp", "memory", "model", - "pairing", "plugins", "profile", "sessions", "setup", "skills", - "slack", "status", "tools", "uninstall", "update", "version", - "webhook", "whatsapp", "chat", + "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", + "model", "pairing", "plugins", "profile", "sessions", "setup", + "skills", "slack", "status", "tools", "uninstall", "update", + "version", "webhook", "whatsapp", "chat", # Help-ish invocations — plugin commands not being listed in # top-level --help is an acceptable trade-off for skipping an # expensive eager import of every bundled plugin module. From da0ddbf88af3c5aef75caca63eee2d5e01b89895 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Wed, 13 May 2026 11:36:07 +0200 Subject: [PATCH 002/214] fix: classify landed file mutations with diagnostics --- agent/display.py | 3 ++ agent/tool_guardrails.py | 3 ++ agent/tool_result_classification.py | 26 ++++++++++ run_agent.py | 4 +- tests/agent/test_display.py | 22 ++++++++ tests/agent/test_tool_guardrails.py | 16 ++++++ .../agent/test_tool_result_classification.py | 30 +++++++++++ .../run_agent/test_file_mutation_verifier.py | 50 +++++++++++++++++++ 8 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 agent/tool_result_classification.py create mode 100644 tests/agent/test_tool_result_classification.py diff --git a/agent/display.py b/agent/display.py index e9a19ff6192..6c5c970aeff 100644 --- a/agent/display.py +++ b/agent/display.py @@ -14,6 +14,7 @@ from difflib import unified_diff from pathlib import Path from utils import safe_json_loads +from agent.tool_result_classification import file_mutation_result_landed # ANSI escape codes for coloring tool failure indicators _RED = "\033[31m" @@ -810,6 +811,8 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] """ if result is None: return False, "" + if file_mutation_result_landed(tool_name, result): + return False, "" if tool_name == "terminal": data = safe_json_loads(result) diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index 3c85d782090..5a9ddd507ba 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from typing import Any, Mapping from utils import safe_json_loads +from agent.tool_result_classification import file_mutation_result_landed IDEMPOTENT_TOOL_NAMES = frozenset( @@ -196,6 +197,8 @@ def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str """ if result is None: return False, "" + if file_mutation_result_landed(tool_name, result): + return False, "" if tool_name == "terminal": data = safe_json_loads(result) diff --git a/agent/tool_result_classification.py b/agent/tool_result_classification.py new file mode 100644 index 00000000000..e136e2964da --- /dev/null +++ b/agent/tool_result_classification.py @@ -0,0 +1,26 @@ +"""Shared helpers for classifying tool result payloads.""" + +from __future__ import annotations + +import json +from typing import Any + + +FILE_MUTATING_TOOL_NAMES = frozenset({"write_file", "patch"}) + + +def file_mutation_result_landed(tool_name: str, result: Any) -> bool: + """Return True when a file mutation result proves the write landed.""" + if tool_name not in FILE_MUTATING_TOOL_NAMES or not isinstance(result, str): + return False + try: + data = json.loads(result.strip()) + except Exception: + return False + if not isinstance(data, dict) or data.get("error"): + return False + if tool_name == "write_file": + return "bytes_written" in data + if tool_name == "patch": + return data.get("success") is True + return False diff --git a/run_agent.py b/run_agent.py index f0597c90880..a2185300931 100644 --- a/run_agent.py +++ b/run_agent.py @@ -181,6 +181,7 @@ from agent.tool_guardrails import ( append_toolguard_guidance, toolguard_synthetic_result, ) +from agent.tool_result_classification import file_mutation_result_landed from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, @@ -5347,7 +5348,8 @@ class AIAgent: targets = _extract_file_mutation_targets(tool_name, args) if not targets: return - if is_error: + landed = file_mutation_result_landed(tool_name, result) + if is_error and not landed: preview = _extract_error_preview(result) for path in targets: # Keep the FIRST error we saw for a given path unless we diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index c6ad837af97..5e18fa17e0c 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -1,6 +1,7 @@ """Tests for agent/display.py — build_tool_preview() and inline diff previews.""" import os +import json import pytest from unittest.mock import MagicMock, patch @@ -149,6 +150,27 @@ class TestCuteToolMessagePreviewLength: assert path in line assert "..." not in line + def test_write_file_lint_error_result_is_not_marked_failed(self): + result = json.dumps({ + "bytes_written": 12, + "lint": {"status": "error", "output": "SyntaxError: invalid syntax"}, + }) + + line = get_cute_tool_message("write_file", {"path": "/tmp/a.py"}, 0.1, result=result) + + assert "[error]" not in line + + def test_patch_lsp_diagnostics_result_is_not_marked_failed(self): + result = json.dumps({ + "success": True, + "diff": "--- a/tmp.py\n+++ b/tmp.py\n", + "lsp_diagnostics": "ERROR [1:1] type mismatch", + }) + + line = get_cute_tool_message("patch", {"path": "/tmp/a.py"}, 0.1, result=result) + + assert "[error]" not in line + class TestEditDiffPreview: def test_extract_edit_diff_for_patch(self): diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index c50be56f43e..26593b7ef62 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -7,6 +7,7 @@ from agent.tool_guardrails import ( ToolCallGuardrailController, ToolCallSignature, canonical_tool_args, + classify_tool_failure, ) @@ -131,6 +132,21 @@ def test_success_resets_exact_signature_failure_streak(): assert controller.before_call("web_search", args).action == "allow" +def test_file_mutation_lint_error_result_is_not_a_tool_failure(): + write_result = json.dumps({ + "bytes_written": 12, + "lint": {"status": "error", "output": "SyntaxError: invalid syntax"}, + }) + patch_result = json.dumps({ + "success": True, + "diff": "--- a/tmp.py\n+++ b/tmp.py\n", + "lsp_diagnostics": "ERROR [1:1] type mismatch", + }) + + assert classify_tool_failure("write_file", write_result) == (False, "") + assert classify_tool_failure("patch", patch_result) == (False, "") + + def test_same_tool_varying_args_warns_by_default_without_halting(): controller = ToolCallGuardrailController( ToolCallGuardrailConfig(same_tool_failure_warn_after=2, same_tool_failure_halt_after=3) diff --git a/tests/agent/test_tool_result_classification.py b/tests/agent/test_tool_result_classification.py new file mode 100644 index 00000000000..2b4b5b150cf --- /dev/null +++ b/tests/agent/test_tool_result_classification.py @@ -0,0 +1,30 @@ +"""Tests for shared tool result classification helpers.""" + +import json + +from agent.tool_result_classification import file_mutation_result_landed + + +def test_write_file_with_nested_lint_error_counts_as_landed(): + result = json.dumps({ + "bytes_written": 12, + "lint": {"status": "error", "output": "SyntaxError: invalid syntax"}, + }) + + assert file_mutation_result_landed("write_file", result) is True + + +def test_patch_with_nested_lsp_diagnostics_counts_as_landed(): + result = json.dumps({ + "success": True, + "diff": "--- a/tmp.py\n+++ b/tmp.py\n", + "lsp_diagnostics": "ERROR [1:1] type mismatch", + }) + + assert file_mutation_result_landed("patch", result) is True + + +def test_top_level_file_mutation_error_does_not_count_as_landed(): + result = json.dumps({"success": True, "error": "post-write verification failed"}) + + assert file_mutation_result_landed("patch", result) is False diff --git a/tests/run_agent/test_file_mutation_verifier.py b/tests/run_agent/test_file_mutation_verifier.py index fca002d2314..73684ad1c2e 100644 --- a/tests/run_agent/test_file_mutation_verifier.py +++ b/tests/run_agent/test_file_mutation_verifier.py @@ -166,6 +166,56 @@ class TestRecordFileMutationResult: ) assert agent._turn_failed_file_mutations == {} + def test_write_file_with_lint_error_counts_as_landed(self): + agent = _bare_agent() + agent._record_file_mutation_result( + "write_file", + {"path": "/tmp/a.py", "content": "bad"}, + json.dumps({"error": "write failed"}), + is_error=True, + ) + assert "/tmp/a.py" in agent._turn_failed_file_mutations + + result = json.dumps({ + "bytes_written": 24, + "lint": {"status": "error", "output": "SyntaxError: invalid syntax"}, + }) + + agent._record_file_mutation_result( + "write_file", + {"path": "/tmp/a.py", "content": "def nope(:\n"}, + result, + is_error=True, + ) + + assert agent._turn_failed_file_mutations == {} + + def test_patch_with_lsp_diagnostics_counts_as_landed(self): + agent = _bare_agent() + agent._record_file_mutation_result( + "patch", + {"mode": "replace", "path": "/tmp/a.py", "old_string": "x", "new_string": "y"}, + json.dumps({"error": "Could not find old_string"}), + is_error=True, + ) + assert "/tmp/a.py" in agent._turn_failed_file_mutations + + result = json.dumps({ + "success": True, + "diff": "--- a/tmp.py\n+++ b/tmp.py\n", + "files_modified": ["/tmp/a.py"], + "lsp_diagnostics": "ERROR [1:1] type mismatch", + }) + + agent._record_file_mutation_result( + "patch", + {"mode": "replace", "path": "/tmp/a.py", "old_string": "x", "new_string": "y"}, + result, + is_error=True, + ) + + assert agent._turn_failed_file_mutations == {} + def test_repeated_failure_keeps_first_error(self): agent = _bare_agent() agent._record_file_mutation_result( From c3094b46e9a12a8fa19dd0fe4db4bae2f9ff5ef2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 18:59:03 +0530 Subject: [PATCH 003/214] refactor: import FILE_MUTATING_TOOL_NAMES from shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the duplicate _FILE_MUTATING_TOOLS frozenset in run_agent.py and imports the canonical FILE_MUTATING_TOOL_NAMES from agent/tool_result_classification.py (aliased as _FILE_MUTATING_TOOLS to avoid renaming the existing call sites). Prevents future drift if another file-mutating tool is added — only one set needs updating. No behavior change: same frozenset({'write_file', 'patch'}), and the 117 PR-scoped tests still pass. --- run_agent.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index a2185300931..f2f3379e0d7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -181,7 +181,10 @@ from agent.tool_guardrails import ( append_toolguard_guidance, toolguard_synthetic_result, ) -from agent.tool_result_classification import file_mutation_result_landed +from agent.tool_result_classification import ( + FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, + file_mutation_result_landed, +) from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, @@ -350,7 +353,7 @@ _PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"}) # Tools that mutate files on disk. Used by the per-turn verifier that # surfaces silently-failed file edits so the model can't over-claim success. -_FILE_MUTATING_TOOLS = frozenset({"write_file", "patch"}) +# Imported above as `_FILE_MUTATING_TOOLS` from `agent.tool_result_classification`. # Maximum number of concurrent worker threads for parallel tool execution. _MAX_TOOL_WORKERS = 8 From 5d90386baab5cc6355d7e73e30571466c9223a6d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Wed, 13 May 2026 19:28:50 +0530 Subject: [PATCH 004/214] fix(gateway): add lazy_deps.ensure() to slack, matrix, dingtalk, feishu adapters (#25014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Discord and Telegram had lazy-install hooks in their check_*_requirements() functions. The remaining four platforms that were moved to lazy_deps (Slack, Matrix, DingTalk, Feishu) would just return False immediately if their packages weren't pre-installed — no attempt to install them at runtime. This means even with the .venv permissions fix (#24841), these four platforms would still fail to load in Docker (or any fresh install) unless the user manually ran pip install. Add the same lazy_deps.ensure() pattern to all four, matching the existing Discord/Telegram implementation. --- gateway/platforms/dingtalk.py | 28 ++++++++++++++++++++++++++-- gateway/platforms/feishu.py | 21 +++++++++++++++++++-- gateway/platforms/matrix.py | 19 ++++++++++++++----- gateway/platforms/slack.py | 26 ++++++++++++++++++++++++-- tools/lazy_deps.py | 1 + 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index 579c382c704..06b30db7b04 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -111,9 +111,33 @@ DINGTALK_TYPE_MAPPING = { def check_dingtalk_requirements() -> bool: - """Check if DingTalk dependencies are available and configured.""" + """Check if DingTalk dependencies are available and configured. + + Lazy-installs dingtalk-stream via ``tools.lazy_deps.ensure("platform.dingtalk")`` + on first call if not present. + """ + global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage + global HTTPX_AVAILABLE, httpx if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE: - return False + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.dingtalk", prompt=False) + except Exception: + return False + try: + import dingtalk_stream as _ds + from dingtalk_stream import ChatbotMessage as _CM + from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM + import httpx as _httpx + except ImportError: + return False + dingtalk_stream = _ds + ChatbotMessage = _CM + CallbackMessage = _CBM + AckMessage = _AM + httpx = _httpx + DINGTALK_STREAM_AVAILABLE = True + HTTPX_AVAILABLE = True if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"): return False return True diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index ae3f7075104..e7be062e84c 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -1343,8 +1343,25 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: def check_feishu_requirements() -> bool: - """Check if Feishu/Lark dependencies are available.""" - return FEISHU_AVAILABLE + """Check if Feishu/Lark dependencies are available. + + Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")`` + on first call if not present. + """ + 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 class FeishuAdapter(BasePlatformAdapter): diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 0133dc2dac7..12075e67837 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -224,7 +224,11 @@ def _check_e2ee_deps() -> bool: def check_matrix_requirements() -> bool: - """Return True if the Matrix adapter can be used.""" + """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. + """ token = os.getenv("MATRIX_ACCESS_TOKEN", "") password = os.getenv("MATRIX_PASSWORD", "") homeserver = os.getenv("MATRIX_HOMESERVER", "") @@ -238,10 +242,15 @@ def check_matrix_requirements() -> bool: try: import mautrix # noqa: F401 except ImportError: - logger.warning( - "Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'" - ) - return False + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.matrix", prompt=False) + import mautrix # noqa: F401, F811 + except Exception: + logger.warning( + "Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'" + ) + return False # If encryption is requested, verify E2EE deps are available at startup # rather than silently degrading to plaintext-only at connect time. diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 7fbefd446ca..432b01d80bf 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -73,8 +73,30 @@ class _ThreadContextCache: def check_slack_requirements() -> bool: - """Check if Slack dependencies are available.""" - return SLACK_AVAILABLE + """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. + """ + 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 _extract_text_from_slack_blocks(blocks: list) -> str: diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index d086d117307..6e298c23320 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -120,6 +120,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "platform.slack": ( "slack-bolt==1.27.0", "slack-sdk==3.40.1", + "aiohttp==3.13.3", ), "platform.matrix": ( "mautrix[encryption]==0.21.0", From 1149e75db20f4f3afe7b0ead23e115abcc4b9b11 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 13 May 2026 10:30:42 -0400 Subject: [PATCH 005/214] ci(docker): split :latest (releases only) from :main (main HEAD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously :latest tracked the tip of main, which meant pulling :latest got you whatever was last merged — fine for development, surprising for users who expect :latest to mean 'the most recent stable release'. Reshape the publish flow so the floating tags carry their conventional meaning: - :sha- every main commit (unchanged, immutable) - :main tip of main (NEW; what :latest used to do) - : every published release, e.g. :v1.2.3 (unchanged) - :latest most recent release (CHANGED; release-only now) Implementation: - Rename the move-latest job to move-main; it still gates on push to main, still ancestor-checks the existing :main label before retagging, still uses cancel-in-progress: false so queued moves run serially. - Add a new move-latest job gated on release: published. Reads the OCI revision label off the existing :latest and only advances if the release commit is a strict descendant. This keeps backport releases on older branches (e.g. patching v1.1.5 after v1.2.3 has already shipped) from dragging :latest backwards. - merge job exposes pushed_release_tag and release_tag outputs so move-latest knows when to fire and what to retag from. --- .github/workflows/docker-publish.yml | 195 ++++++++++++++++++++++----- 1 file changed, 161 insertions(+), 34 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 551e5514d49..cccb8f3b452 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,9 +28,10 @@ permissions: contents: read # Concurrency: push/release runs are NEVER cancelled so every merge gets its -# own SHA-tagged image; :latest is guarded separately by the move-latest job. -# PR runs reuse a PR-scoped group with cancel-in-progress: true so rapid -# pushes to the same PR collapse to the latest commit. +# own SHA-tagged image; :main and :latest are guarded separately by the +# move-main and move-latest jobs. PR runs reuse a PR-scoped group with +# cancel-in-progress: true so rapid pushes to the same PR collapse to the +# latest commit. concurrency: group: docker-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -91,10 +92,10 @@ jobs: # pattern for multi-runner multi-platform builds. # # We apply the OCI revision label here (and again on arm64) because - # the move-latest job reads it off the linux/amd64 sub-manifest config - # of `:latest` to decide whether it's safe to advance. The label must - # be on each per-arch image — manifest lists themselves don't carry - # image config labels. + # the move-main / move-latest jobs read it off the linux/amd64 + # sub-manifest config of the floating tag to decide whether it's safe + # to advance. The label must be on each per-arch image — manifest + # lists themselves don't carry image config labels. - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -217,6 +218,8 @@ jobs: timeout-minutes: 10 outputs: pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} + pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }} + release_tag: ${{ steps.tag.outputs.tag }} steps: - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -271,33 +274,43 @@ jobs: IMAGE_NAME: ${{ env.IMAGE_NAME }} TAG: ${{ steps.tag.outputs.tag }} - # Signal to move-latest that the SHA tag is live. Only on main pushes; - # releases don't trigger move-latest (they use their own release tag). + # Signal to move-main that the SHA tag is live. Only on main pushes; + # releases set pushed_release_tag instead. - name: Mark SHA tag pushed id: mark_pushed if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: echo "pushed=true" >> "$GITHUB_OUTPUT" + # Signal to move-latest that the release tag is live. + - name: Mark release tag pushed + id: mark_release_pushed + if: github.event_name == 'release' + run: echo "pushed=true" >> "$GITHUB_OUTPUT" + # --------------------------------------------------------------------------- - # Move :latest to point at the SHA tag the merge job pushed. + # Move :main to point at the SHA tag the merge job pushed. + # + # :main is the floating tag that tracks the tip of the main branch. Every + # merge to main retags :main forward. Users who want "latest dev build" + # pull :main; users who want stable releases pull :latest. # # The real serialization guarantee comes from the top-level concurrency # group (`docker-${{ github.ref }}` with `cancel-in-progress: false`), # which ensures at most one workflow run for this ref executes at a time. - # That means two move-latest steps for the same ref cannot overlap. + # That means two move-main steps for the same ref cannot overlap. # # This job has its own concurrency group as defense-in-depth: if the - # top-level group is ever loosened, queued move-latests will run serially + # top-level group is ever loosened, queued move-mains will run serially # in arrival order, each one running the ancestor check below and either - # advancing :latest or skipping. `cancel-in-progress: false` matches the + # advancing :main or skipping. `cancel-in-progress: false` matches the # top-level setting — we don't want rapid pushes to cancel a queued - # move-latest, because the ancestor check is the real safety mechanism - # and queueing is cheap (move-latest is a ~30s registry op). + # move-main, because the ancestor check is the real safety mechanism + # and queueing is cheap (move-main is a ~30s registry op). # - # Combined with the ancestor check, this means :latest only ever moves + # Combined with the ancestor check, this means :main only ever moves # forward in git history. # --------------------------------------------------------------------------- - move-latest: + move-main: if: | github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' @@ -307,7 +320,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 concurrency: - group: docker-move-latest-${{ github.ref }} + group: docker-move-main-${{ github.ref }} cancel-in-progress: false steps: - name: Checkout code @@ -324,13 +337,13 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Read the git revision label off the current :latest manifest, then + # Read the git revision label off the current :main manifest, then # use `git merge-base --is-ancestor` to check whether our commit is a - # descendant of it. If :latest doesn't exist yet, or its label is + # descendant of it. If :main doesn't exist yet, or its label is # missing, we treat that as "safe to publish". If another run already - # advanced :latest past us (or diverged), we skip and leave it alone. - - name: Decide whether to move :latest - id: latest_check + # advanced :main past us (or diverged), we skip and leave it alone. + - name: Decide whether to move :main + id: main_check run: | set -euo pipefail image=nousresearch/hermes-agent @@ -338,6 +351,119 @@ jobs: # Pull the JSON for the linux/amd64 sub-manifest's config and extract # the OCI revision label with jq — Go template field access can't # handle dots in map keys, so using json+jq is the robust route. + image_json=$( + docker buildx imagetools inspect "${image}:main" \ + --format '{{ json (index .Image "linux/amd64") }}' \ + 2>/dev/null || true + ) + + if [ -z "${image_json}" ]; then + echo "No existing :main (or inspect failed) — safe to publish." + echo "push_main=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + current_sha=$( + printf '%s' "${image_json}" \ + | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' + ) + + if [ -z "${current_sha}" ]; then + echo "Registry :main has no revision label — safe to publish." + echo "push_main=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Registry :main is at ${current_sha}" + echo "This run is at ${GITHUB_SHA}" + + if [ "${current_sha}" = "${GITHUB_SHA}" ]; then + echo ":main already points at our SHA — nothing to do." + echo "push_main=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Make sure we have the :main commit locally for merge-base. + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --prune origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + || true + fi + + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite." + echo "push_main=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Our SHA must be a descendant of the current :main to be safe. + if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then + echo "Our commit is a descendant of :main — safe to advance." + echo "push_main=true" >> "$GITHUB_OUTPUT" + else + echo "Another run advanced :main past us (or diverged) — leaving it alone." + echo "push_main=false" >> "$GITHUB_OUTPUT" + fi + + # Retag the already-pushed SHA manifest as :main. This is a registry- + # side operation — no rebuild, no layer re-push — so it's quick and + # atomic per-tag. The ancestor check above plus the cancel-in-progress + # concurrency on this job together guarantee we only ever move :main + # forward in git history. + - name: Move :main to this SHA + if: steps.main_check.outputs.push_main == 'true' + run: | + set -euo pipefail + image=nousresearch/hermes-agent + docker buildx imagetools create \ + --tag "${image}:main" \ + "${image}:sha-${GITHUB_SHA}" + + # --------------------------------------------------------------------------- + # Move :latest to point at the release tag the merge job pushed. + # + # :latest is the floating tag that tracks the most recent stable release. + # Only `release: published` events advance it — never main pushes. + # + # We still run an ancestor check against the existing :latest so that a + # backport release on an older branch (e.g. patching v1.1.5 after v1.2.3 + # is out) doesn't drag :latest backwards. The check is the same shape as + # move-main: read the OCI revision label off the current :latest, look up + # that commit in git, and only advance if our release commit is a strict + # descendant. + # --------------------------------------------------------------------------- + move-latest: + if: | + github.repository == 'NousResearch/hermes-agent' + && github.event_name == 'release' + && needs.merge.outputs.pushed_release_tag == 'true' + needs: merge + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: docker-move-latest + cancel-in-progress: false + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1000 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Decide whether to move :latest + id: latest_check + run: | + set -euo pipefail + image=nousresearch/hermes-agent + image_json=$( docker buildx imagetools inspect "${image}:latest" \ --format '{{ json (index .Image "linux/amd64") }}' \ @@ -362,7 +488,7 @@ jobs: fi echo "Registry :latest is at ${current_sha}" - echo "This run is at ${GITHUB_SHA}" + echo "This release is at ${GITHUB_SHA}" if [ "${current_sha}" = "${GITHUB_SHA}" ]; then echo ":latest already points at our SHA — nothing to do." @@ -371,6 +497,7 @@ jobs: fi # Make sure we have the :latest commit locally for merge-base. + # Releases can be cut from any branch, so fetch broadly. if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then git fetch --no-tags --prune origin \ "+refs/heads/main:refs/remotes/origin/main" \ @@ -383,25 +510,25 @@ jobs: exit 0 fi - # Our SHA must be a descendant of the current :latest to be safe. + # Our release SHA must be a descendant of the current :latest. + # Backport releases on older branches won't satisfy this and will + # be left alone — :latest stays on the newer release. if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our commit is a descendant of :latest — safe to advance." + echo "Our release commit is a descendant of :latest — safe to advance." echo "push_latest=true" >> "$GITHUB_OUTPUT" else - echo "Another run advanced :latest past us (or diverged) — leaving it alone." + echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." echo "push_latest=false" >> "$GITHUB_OUTPUT" fi - # Retag the already-pushed SHA manifest as :latest. This is a registry- - # side operation — no rebuild, no layer re-push — so it's quick and - # atomic per-tag. The ancestor check above plus the cancel-in-progress - # concurrency on this job together guarantee we only ever move :latest - # forward in git history. - - name: Move :latest to this SHA + # Retag the already-pushed release manifest as :latest. + - name: Move :latest to this release tag if: steps.latest_check.outputs.push_latest == 'true' + env: + RELEASE_TAG: ${{ needs.merge.outputs.release_tag }} run: | set -euo pipefail image=nousresearch/hermes-agent docker buildx imagetools create \ --tag "${image}:latest" \ - "${image}:sha-${GITHUB_SHA}" + "${image}:${RELEASE_TAG}" From 8c4bec61557a5a02d25956c316c33f7527cbf4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20K=C3=BCnzi?= Date: Fri, 17 Apr 2026 10:58:08 +0200 Subject: [PATCH 006/214] fix(cli): repair broken zsh completion generation --- hermes_cli/completion.py | 8 ++++---- tests/hermes_cli/test_completion.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index 591ffecc62f..efedad5eb78 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -216,9 +216,9 @@ _hermes() {{ typeset -A opt_args _arguments -C \\ - '(-)'{{-h,--help}}'[Show help and exit]' \\ - '(-)'{{-V,--version}}'[Show version and exit]' \\ - '(-)'{{-p,--profile}}'[Profile name]:profile:_hermes_profiles' \\ + "(-h --help)"{{-h,--help}}"[Show help and exit]" \\ + "(-V --version)"{{-V,--version}}"[Show version and exit]" \\ + "(-p --profile)"{{-p,--profile}}"[Profile name]:profile:_hermes_profiles" \\ '1:command:->commands' \\ '*::arg:->args' @@ -238,7 +238,7 @@ _hermes() {{ esac }} -_hermes "$@" +compdef _hermes hermes """ diff --git a/tests/hermes_cli/test_completion.py b/tests/hermes_cli/test_completion.py index 20bde059f2e..1e85653290a 100644 --- a/tests/hermes_cli/test_completion.py +++ b/tests/hermes_cli/test_completion.py @@ -140,6 +140,36 @@ class TestGenerateZsh: # gateway has subcommands so a _cmds array must be generated assert "gateway_cmds" in out + def test_registers_compdef_instead_of_invoking_completion_function(self): + out = generate_zsh(_make_parser()) + assert 'compdef _hermes hermes' in out + assert '_hermes "$@"' not in out + + def test_uses_valid_zsh_arguments_alias_syntax(self): + out = generate_zsh(_make_parser()) + assert '"(-h --help)"{-h,--help}"[Show help and exit]"' in out + assert '"(-V --version)"{-V,--version}"[Show version and exit]"' in out + assert '"(-p --profile)"{-p,--profile}"[Profile name]:profile:_hermes_profiles"' in out + assert "'(-h --help){-h,--help}[Show help and exit]'" not in out + + def test_valid_zsh_syntax_when_sourced_after_compinit(self): + if not shutil.which("zsh"): + pytest.skip("zsh not installed") + out = generate_zsh(_make_parser()) + with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f: + f.write(out) + path = f.name + try: + result = subprocess.run( + ["zsh", "-fc", f"autoload -Uz compinit && compinit; source {path}"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + finally: + os.unlink(path) + # --------------------------------------------------------------------------- # 4. Fish output From 6d30b4a7e32561483619145fb083bafe88aa4460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20K=C3=BCnzi?= Date: Sun, 10 May 2026 08:22:02 +0200 Subject: [PATCH 007/214] test(cli): strengthen zsh completion regression coverage --- hermes_cli/completion.py | 6 +++--- tests/hermes_cli/test_completion.py | 30 +++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index efedad5eb78..389cf2419cb 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -216,9 +216,9 @@ _hermes() {{ typeset -A opt_args _arguments -C \\ - "(-h --help)"{{-h,--help}}"[Show help and exit]" \\ - "(-V --version)"{{-V,--version}}"[Show version and exit]" \\ - "(-p --profile)"{{-p,--profile}}"[Profile name]:profile:_hermes_profiles" \\ + '(-)'{{-h,--help}}'[Show help and exit]' \\ + '(-)'{{-V,--version}}'[Show version and exit]' \\ + '(-)'{{-p,--profile}}'[Profile name]:profile:_hermes_profiles' \\ '1:command:->commands' \\ '*::arg:->args' diff --git a/tests/hermes_cli/test_completion.py b/tests/hermes_cli/test_completion.py index 1e85653290a..2c4e6592c62 100644 --- a/tests/hermes_cli/test_completion.py +++ b/tests/hermes_cli/test_completion.py @@ -145,14 +145,28 @@ class TestGenerateZsh: assert 'compdef _hermes hermes' in out assert '_hermes "$@"' not in out - def test_uses_valid_zsh_arguments_alias_syntax(self): + def test_preserves_valid_zsh_arguments_alias_syntax(self): out = generate_zsh(_make_parser()) - assert '"(-h --help)"{-h,--help}"[Show help and exit]"' in out - assert '"(-V --version)"{-V,--version}"[Show version and exit]"' in out - assert '"(-p --profile)"{-p,--profile}"[Profile name]:profile:_hermes_profiles"' in out + assert "'(-)'{-h,--help}'[Show help and exit]'" in out + assert "'(-)'{-V,--version}'[Show version and exit]'" in out + assert "'(-)'{-p,--profile}'[Profile name]:profile:_hermes_profiles'" in out assert "'(-h --help){-h,--help}[Show help and exit]'" not in out + assert '"(-h --help)"{-h,--help}"[Show help and exit]"' not in out - def test_valid_zsh_syntax_when_sourced_after_compinit(self): + def test_valid_zsh_syntax(self): + if not shutil.which("zsh"): + pytest.skip("zsh not installed") + out = generate_zsh(_make_parser()) + with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f: + f.write(out) + path = f.name + try: + result = subprocess.run(["zsh", "-n", path], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + finally: + os.unlink(path) + + def test_zsh_eval_style_source_registers_after_compinit(self): if not shutil.which("zsh"): pytest.skip("zsh not installed") out = generate_zsh(_make_parser()) @@ -161,7 +175,11 @@ class TestGenerateZsh: path = f.name try: result = subprocess.run( - ["zsh", "-fc", f"autoload -Uz compinit && compinit; source {path}"], + [ + "zsh", + "-fc", + f"autoload -Uz compinit && compinit -D; source {path}; [[ ${{_comps[hermes]}} == _hermes ]]", + ], capture_output=True, text=True, ) From a43d7e67b4e7234b94320963ca1811fcc3a9b5d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 09:00:43 -0700 Subject: [PATCH 008/214] refactor(profiles): remove dead generate_bash_completion / generate_zsh_completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two functions in hermes_cli/profiles.py have no callers — the live `hermes completion {bash,zsh}` command uses hermes_cli/completion.py's generate_bash() / generate_zsh() instead. Multiple PRs (incl. #6141) tried to fix the trailing-`_hermes "$@"` zsh bug here, only to discover the patch never reached users. Delete the dead code so future contributors patch the right file. The actual user-facing fix lives in the preceding cherry-picked commits to hermes_cli/completion.py. --- hermes_cli/profiles.py | 85 ------------------------------- tests/hermes_cli/test_profiles.py | 28 ---------- 2 files changed, 113 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 468a4599f84..de555caf9be 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1295,91 +1295,6 @@ def rename_profile(old_name: str, new_name: str) -> Path: return new_dir -# --------------------------------------------------------------------------- -# Tab completion -# --------------------------------------------------------------------------- - -def generate_bash_completion() -> str: - """Generate a bash completion script for hermes profile names.""" - return '''# Hermes Agent profile completion -# Add to ~/.bashrc: eval "$(hermes completion bash)" - -_hermes_profiles() { - local profiles_dir="$HOME/.hermes/profiles" - local profiles="default" - if [ -d "$profiles_dir" ]; then - profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)" - fi - echo "$profiles" -} - -_hermes_completion() { - local cur prev - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - # Complete profile names after -p / --profile - if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then - COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur")) - return - fi - - # Complete profile subcommands - if [[ "${COMP_WORDS[1]}" == "profile" ]]; then - case "$prev" in - profile) - COMPREPLY=($(compgen -W "list use create delete show alias rename export import" -- "$cur")) - return - ;; - use|delete|show|alias|rename|export) - COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur")) - return - ;; - esac - fi - - # Top-level subcommands - if [[ "$COMP_CWORD" == 1 ]]; then - local commands="chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version" - COMPREPLY=($(compgen -W "$commands" -- "$cur")) - fi -} - -complete -F _hermes_completion hermes -''' - - -def generate_zsh_completion() -> str: - """Generate a zsh completion script for hermes profile names.""" - return '''#compdef hermes -# Hermes Agent profile completion -# Add to ~/.zshrc: eval "$(hermes completion zsh)" - -_hermes() { - local -a profiles - profiles=(default) - if [[ -d "$HOME/.hermes/profiles" ]]; then - profiles+=("${(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}") - fi - - _arguments \\ - '-p[Profile name]:profile:($profiles)' \\ - '--profile[Profile name]:profile:($profiles)' \\ - '1:command:(chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version)' \\ - '*::arg:->args' - - case $words[1] in - profile) - _arguments '1:action:(list use create delete show alias rename export import)' \\ - '2:profile:($profiles)' - ;; - esac -} - -_hermes "$@" -''' - - # --------------------------------------------------------------------------- # Profile env resolution (called from _apply_profile_override) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index f4c8a4d1ff6..4b521fa94da 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -29,8 +29,6 @@ from hermes_cli.profiles import ( rename_profile, export_profile, import_profile, - generate_bash_completion, - generate_zsh_completion, _get_profiles_root, _get_default_hermes_home, seed_profile_skills, @@ -1013,32 +1011,6 @@ class TestProfileIsolation: assert (beta_dir / "skills").is_dir() -# =================================================================== -# TestCompletion -# =================================================================== - -class TestCompletion: - """Tests for bash/zsh completion generators.""" - - def test_bash_completion_contains_complete(self): - script = generate_bash_completion() - assert len(script) > 0 - assert "complete" in script - - def test_zsh_completion_contains_compdef(self): - script = generate_zsh_completion() - assert len(script) > 0 - assert "compdef" in script - - def test_bash_completion_has_hermes_profiles_function(self): - script = generate_bash_completion() - assert "_hermes_profiles" in script - - def test_zsh_completion_has_hermes_function(self): - script = generate_zsh_completion() - assert "_hermes" in script - - # =================================================================== # TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers) # =================================================================== From a9b8254e5fb11676feed048c05dba807a40357c7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 09:01:33 -0700 Subject: [PATCH 009/214] chore(release): map anton.kuenzi@gmail.com -> ZeterMordio For PR #11754 salvage (zsh completion compdef registration + _arguments syntax tests). CI release script blocks unmapped emails. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 482bb911a21..4ed5aadc32d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1003,6 +1003,7 @@ AUTHOR_MAP = { "openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590) "freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4) "zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows) + "anton.kuenzi@gmail.com": "ZeterMordio", # PR #11754 salvage (zsh completion compdef + _arguments syntax) } From d6c9711ba865a8675f14367ac6211d1ae14222bc Mon Sep 17 00:00:00 2001 From: iuyup <23yntong@stu.edu.cn> Date: Wed, 8 Apr 2026 20:44:34 +0800 Subject: [PATCH 010/214] fix(security): reduce unnecessary shell=True in subprocess calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory_setup.py: use shlex.split() for plugin dep checks instead of shell=True - transcription_tools.py: avoid shell=True for auto-detected whisper commands (user-provided templates via env var still use shell=True for compatibility) - cli.py: add comment clarifying intentional shell=True for user quick_commands - Add test verifying auto-detected template is shlex-safe Addresses CONTRIBUTING.md Priority #3 (Security hardening — shell injection). --- cli.py | 2 ++ hermes_cli/memory_setup.py | 3 +- tests/tools/test_transcription_tools.py | 42 +++++++++++++++++++++++++ tools/transcription_tools.py | 8 ++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index da2f32954ba..1f167f61cf9 100644 --- a/cli.py +++ b/cli.py @@ -7600,6 +7600,8 @@ class HermesCLI: 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 diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 7b2c6067288..6ae15e08838 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -10,6 +10,7 @@ from __future__ import annotations 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: diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index ce45cb9f1e6..7f83565b5d8 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -1363,3 +1363,45 @@ class TestTranscribeAudioXAIDispatch: 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/tools/transcription_tools.py b/tools/transcription_tools.py index 5009947895c..942fba01120 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: From 1979ef5802cd8798cb1a5096b66cbc50fd0ebc89 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 08:38:36 -0700 Subject: [PATCH 011/214] chore(release): map iuyup author for PR #6155 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 4ed5aadc32d..ddc5be1317a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1004,6 +1004,7 @@ AUTHOR_MAP = { "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) } From 6f2d1c88b76fd85bda3460128fc21819a211ad1e Mon Sep 17 00:00:00 2001 From: littlewwwhite <1095245867@qq.com> Date: Wed, 13 May 2026 08:46:01 -0700 Subject: [PATCH 012/214] feat(custom): prompt and persist explicit api_mode for custom providers Adds an explicit API compatibility mode prompt to the `hermes model -> custom` flow so Codex-compatible third-party endpoints (and any other non-default backend whose URL doesn't match the existing heuristics in `_detect_api_mode_for_url`) can be selected explicitly instead of silently falling back to chat_completions. Choices: Auto-detect / chat_completions / codex_responses / anthropic_messages. Persists `api_mode` to: - `model.api_mode` (active session config) - the matching `custom_providers[*]` entry (so re-activating the named provider next time replays the same transport) Salvaged from PR #6125 onto current main: kept the new prompt and the `_save_custom_provider(api_mode=...)` plumbing; the named-custom flow already extracts and applies `api_mode` from the saved entry on current main so those changes are preserved as-is. Test fixtures updated for the new prompt and the existing display-name prompt. Co-authored-by: littlewwwhite <1095245867@qq.com> --- hermes_cli/main.py | 111 +++++++++++++++++- scripts/release.py | 1 + tests/cli/test_cli_provider_resolution.py | 61 +++++++++- .../test_model_provider_persistence.py | 34 ++++++ 4 files changed, 200 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e8aa0d761c4..c93fa485c98 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3079,6 +3079,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 +3157,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 +3183,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 +3197,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 +3306,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 +3337,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 +3358,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 +3813,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})") diff --git a/scripts/release.py b/scripts/release.py index ddc5be1317a..e4cfaa0dd9c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "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", diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 0c9aab82add..e8eb7325157 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/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 20f81d62d8f..0b350ba9adb 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -177,6 +177,40 @@ class TestProviderPersistsAfterModelSave: 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 From 256bedb632ece7b9142a20f4e830f5a5fe48ad5f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 13:28:25 -0700 Subject: [PATCH 013/214] fix(setup): drop post-setup chat handoff (#25067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the 'Launch hermes chat now? (Y/n)' prompt at the end of hermes setup. The summary already prints 'Ready to go! → hermes' so the auto-launch was redundant, and on macOS 26+ it could crash in prompt_toolkit when setup was invoked from the curl install script with stdin redirected from /dev/tty (#5884, #6128). After setup, users run 'hermes' themselves like every other CLI tool. Same pattern applies to the Windows installer. Closes #6128 (narrower env-var-guarded fix superseded by removing the prompt outright). --- hermes_cli/setup.py | 14 ------- tests/hermes_cli/test_setup.py | 42 ------------------- .../test_setup_openclaw_migration.py | 3 -- tests/hermes_cli/test_setup_reconfigure.py | 1 - 4 files changed, 60 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index df4e88e0006..ddcd5e532bb 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -3246,18 +3246,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 +3289,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/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index f7b491ddf31..0e2b2d8f70b 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -573,48 +573,6 @@ def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeyp 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 e627b619630..c3550e9e4cd 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -262,7 +262,6 @@ class TestSetupWizardOpenclawIntegration: 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 @@ class TestSetupWizardOpenclawIntegration: 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 @@ class TestSetupWizardOpenclawIntegration: 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 9f7c97a8c1e..6ed49e54ae4 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)) From 59da8ec4ecd1e9527c30312cf150bbe7f5850973 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 09:01:22 -0700 Subject: [PATCH 014/214] fix(tools): refuse skill_view name collisions instead of guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skill_view ran the direct-path strategy across every skill dir before the recursive strategy, so a top-level skill in an external dir could silently shadow a same-named nested local skill. /skills correctly listed the local version (deduped local-first by _find_all_skills) but skill_view loaded the external one — confusing, and a real bug class for users with skills.external_dirs registered alongside categorized local skills. Pick a louder fix than @polkn's PR #6136 proposed: collect every match across all dirs (direct path, recursive by parent dir name, legacy flat .md), and if there's more than one, refuse with an error that surfaces every matching path plus a hint to load by the categorized form. Local-first precedence would have replaced silent external-shadowing with silent same-name collisions between two externals, or made an externally-shadowed-by-local skill unreachable by bare name with no signal. Refusing forces the user to disambiguate once and never wonder which skill ran. Recovery: pass the full categorized path ("foundations/runtime/explore-codebase" instead of "explore-codebase"), or rename one of the colliding skills. Co-authored-by: pol --- tests/tools/test_skills_tool.py | 165 ++++++++++++++++++++++++++++++++ tools/skills_tool.py | 98 ++++++++++++------- 2 files changed, 231 insertions(+), 32 deletions(-) diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index d95fc0671d4..9502467546e 100644 --- a/tests/tools/test_skills_tool.py +++ b/tests/tools/test_skills_tool.py @@ -1076,3 +1076,168 @@ Do the legacy thing. 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/tools/skills_tool.py b/tools/skills_tool.py index 32296729fe2..0fcd449b80b 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 + _record(None, categorized_path.with_suffix(".md")) - # 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 + # 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) - 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 + # 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) - # 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 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, + ) + + 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]] From e2b2d48610263bfc695eaa250e9a71007f1b48cb Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Sun, 10 May 2026 14:09:22 +0700 Subject: [PATCH 015/214] fix(cli): preserve startup banner on terminal resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recover from SIGWINCH without clearing the physical screen or scrollback buffer. The startup banner and tool summary are printed before prompt_toolkit owns the live chrome, so they live in normal terminal scrollback. Calling erase_screen() + \x1b[3J] on every resize removed that UI permanently — _replay_output_history cannot reconstruct it because the banner was never added to _OUTPUT_HISTORY. Instead, just reset prompt_toolkit's renderer cache and invalidate so the next incremental redraw starts from a clean slate, then let the original on_resize handler recalculate layout for the new terminal size. This matches the behaviour of bash/zsh/fish on SIGWINCH. Fixes NousResearch/hermes-agent#22999 --- cli.py | 24 +++++++++++++++++++++--- tests/cli/test_cli_force_redraw.py | 30 +++++++++++++++--------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/cli.py b/cli.py index 1f167f61cf9..72ffd0b1708 100644 --- a/cli.py +++ b/cli.py @@ -2703,9 +2703,27 @@ class HermesCLI: 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. + """ + 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: diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 4c7197ad94a..ba5b0a75534 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -71,32 +71,32 @@ class TestForceFullRedraw: "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. + """ 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._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() def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli): app = MagicMock() From 08671d877108769e99ce649bd9ea93a861a0b19b Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 13 May 2026 13:52:10 -0700 Subject: [PATCH 016/214] tui: make URLs clickable + hover-highlight in any terminal (#25071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tui: make URLs clickable + hover-highlight in any terminal Problem ------- URLs printed by `hermes --tui` were not clickable in basic macOS Terminal.app. Cmd+click did nothing, the cursor didn't change shape — like nothing was detected — even though arrow buttons and other Box onClick handlers worked fine. Root cause ---------- Two layers of dead plumbing: 1. `` only emitted the underlying `` (which carries the hyperlink metadata into the screen buffer) when `supportsHyperlinks()` said yes. On Apple_Terminal that's false, so the per-cell hyperlink field stayed empty, so `Ink.getHyperlinkAt()` had nothing to return on click. The visible underline was just decorative. 2. `Ink.openHyperlink()` calls `this.onHyperlinkClick?.(url)`, but `onHyperlinkClick` was never assigned anywhere in the codebase. The click pipeline (`App.tsx → onOpenHyperlink → Ink.openHyperlink`) ran but bailed silently on the optional chain. Bonus discovery: even when wired up, there was no hover affordance — terminal apps can't change the system mouse cursor, so users had no visual signal that a cell was clickable. Arrow buttons in the chrome worked because they had explicit `` styling; inline link URLs didn't. Fix --- - `Link.tsx`: always emit `` regardless of terminal capability. The renderer's `wrapWithOsc8Link` already gates the actual OSC 8 escape on `supportsHyperlinks()` further down — so terminals that don't understand OSC 8 still don't see the escape, but the screen-buffer metadata (which the click dispatcher reads) is now populated everywhere. - `ink.tsx + root.ts`: add `onHyperlinkClick?: (url: string) => void` to `Options` / `RenderOptions`, wire it to the existing `Ink.onHyperlinkClick` field in the constructor. - `src/lib/openExternalUrl.ts`: small platform-aware opener using `child_process.spawn` with arg-array (no shell) — http(s) only, rejects `file:`, `javascript:`, `data:`, etc., so a hostile model can't trigger arbitrary local handlers via ``. Detached + stdio ignore so closing the TUI doesn't kill the browser and Chrome stderr doesn't leak into the alt screen. - `entry.tsx`: pass `onHyperlinkClick: openExternalUrl` to `ink.render`. - `hyperlinkHover.ts` + Ink hover wiring: track the URL under the pointer in `Ink.hoveredHyperlink`, update it from `dispatchHover`, and inverse- highlight every cell of the matching link in the render-pass overlay (same pattern as `applySearchHighlight`). This is the cursor-hover affordance for clickable links — terminals don't expose cursor shape, so we light up the link itself. - `types/hermes-ink.d.ts`: add `onHyperlinkClick` to the `RenderOptions` shim so consumers (`entry.tsx`) type-check against the new option. Tests ----- - `src/lib/openExternalUrl.test.ts` (15 cases): http(s) accepted; file/js/ data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs pass through unmolested as a single argv element; synchronous spawn failure returns false. Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a URL opens in default browser, hovering inverts the link cells, and moving away clears the highlight. Full TUI suite: 713 passing, 0 type errors. Reverts ------- The earlier attempt that version-gated Apple_Terminal in `supports-hyperlinks.ts` was based on a wrong assumption — Terminal.app silently strips OSC 8 sequences but does not render them as clickable hyperlinks. Reverted to the original allowlist. * tui: address Copilot review — explorer.exe on win32 + comment fixes - openExternalUrl: switch win32 from `cmd.exe /c start` to `explorer.exe`. cmd.exe's `start` builtin reparses the URL through cmd's tokenizer, so `&`, `|`, `^`, `<`, `>` either split the command or get reinterpreted — breaking both the protocol-allowlist safety story AND plain http(s) URLs with `&` in query strings. `explorer.exe ` invokes the registered protocol handler directly with no shell. - openExternalUrl.test.ts: rename the win32 test to reflect the new contract and add two regression tests — one with `&|^<>` metachars, one with the common analytics-URL `&` query-param pattern — both pinned to single-argv-element delivery via explorer.exe. - Link.tsx: fix misleading comment. OSC 8 escapes are emitted unconditionally by the renderer (`wrapWithOsc8Link` in render-node-to-output.ts, `oscLink` in log-update.ts). Non-supporting terminals silently strip the sequence, which is why hover/click affordance has to come from the in-process overlay rather than the terminal's own link rendering. Verified: 715/715 tests pass, type-check + build clean. * tui: address Copilot review #2 — async spawn errors + hover scope + docs 1. openExternalUrl: attach a no-op `'error'` listener on the spawned child BEFORE unref(). spawn() returns a ChildProcess synchronously even when the binary is missing (ENOENT on xdg-open / explorer.exe), unreachable, or otherwise unusable; the failure surfaces later as an 'error' event. An unhandled 'error' on an EventEmitter crashes Node, which would tear down the whole TUI. The listener is a deliberate no-op — we already returned `true` synchronously and the user just doesn't see the browser pop. 2. openExternalUrl.test.ts: add a regression test using a real EventEmitter to simulate the async-error path. Pins both the listener-attached contract and the "doesn't throw on emit" behavior. Was 17/17, now 18/18. 3. ink.tsx dispatchHover: bypass `getHyperlinkAt()` and read `cellAt(...).hyperlink` directly. `getHyperlinkAt` falls back to `findPlainTextUrlAt` for cells without an OSC 8 hyperlink, but the render-pass overlay (`applyHyperlinkHoverHighlight`) only matches on `cell.hyperlink === hoveredUrl` — so plain-text URLs would burn re-renders without ever producing the highlight. Hover is now a strictly 1:1 fit for what the overlay can paint. Plain-text URLs still get the click action via the existing dispatch path. 4. root.ts + ink.tsx doc comments: replace the misleading "typically `open` / `xdg-open` / `start` shell" wording with the actual safe recipe — argv-array spawn into `open` / `xdg-open` / `explorer.exe`, with an explicit warning that `cmd.exe /c start` reparses the URL through cmd's tokenizer and is unsafe + breaks `&`-query URLs. Verified: 716/716 tests pass, type-check + build clean. * tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist 1. ink.tsx onRender: stop folding steady-state hover into hlActive. hlActive forces a full-screen damage diff so previous-frame inverted cells get re-emitted when the highlight set changes. The transition IS the trigger — enter / leave / change-to-other-link. While the pointer just sits on a link the painted cells don't change and the per-cell diff handles the no-op. Folding the steady state in would burn a full-screen diff on every frame. Added a lastRenderedHoveredHyperlink tracker and gate the hlActive bump on `hovered !== lastRendered`. 2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker) when toggling alt-screen state. Hover dispatch is alt-screen-gated, so once we leave there's no path to clear it. Without this, remounting would paint a phantom hover from the previous session until the next mouse-move arrived. 3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for xdg-open and return null for everything else (aix, sunos, cygwin, haiku, etc.). Previously the default-fallback always returned xdg-open, which made the caller's `if (!command) return false` dead and yielded a misleading `true` on platforms that probably don't have xdg-open. New tests cover the null path AND the openExternalUrl-returns-false-without-spawning behavior. Verified: 718/718 tests pass, type-check + build clean. * tui: address Copilot review #4 — doc comment accuracy 1. openExternalUrl return-value doc: now lists all three false paths (URL rejected / no opener for platform / synchronous spawn throw) plus a note that async 'error' events still return true because the spawn was attempted. 2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives either an OSC 8 hyperlink OR a plain-text URL detected by findPlainTextUrlAt — App.tsx routes both into the same callback. 3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading 'caller forces full-frame damage' promise. Caller decides; for hover the current caller only forces full damage on transitions. No behavior change. 718/718 tests pass. * tui: address Copilot review #5 — lint fixes 1. ink.tsx: reorder `./hyperlinkHover.js` import before `./screen.js` to satisfy perfectionist/sort-imports. 2. Link.tsx: drop unused `fallback` parameter destructuring + the trailing `void (null as ...)` dead-statement (would trip no-unused-expressions). Kept `fallback?: ReactNode` on the Props interface as a documented compat shim so existing call sites still compile, with a comment explaining why it's no longer wired up. 3. openExternalUrl.test.ts: replace `typeof import('node:child_process').spawn` inline annotations (forbidden by @typescript-eslint/consistent-type-imports) with a `SpawnLike` type alias backed by a real `import type { spawn as SpawnFn }`. No behavior change. 718/718 tests pass, type-check clean, lint clean on all modified files. --- .../hermes-ink/src/ink/components/Link.tsx | 65 ++---- .../hermes-ink/src/ink/hyperlinkHover.ts | 52 +++++ ui-tui/packages/hermes-ink/src/ink/ink.tsx | 104 ++++++++- ui-tui/packages/hermes-ink/src/ink/root.ts | 22 +- ui-tui/src/entry.tsx | 13 +- ui-tui/src/lib/openExternalUrl.test.ts | 217 ++++++++++++++++++ ui-tui/src/lib/openExternalUrl.ts | 158 +++++++++++++ ui-tui/src/types/hermes-ink.d.ts | 1 + 8 files changed, 587 insertions(+), 45 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts create mode 100644 ui-tui/src/lib/openExternalUrl.test.ts create mode 100644 ui-tui/src/lib/openExternalUrl.ts 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 71c49145589..6020d50bdab 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 00000000000..92a43eb06ad --- /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.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index c4669847e68..8a8603cf573 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 @@ -769,6 +806,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. @@ -1182,6 +1239,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 +1837,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 +1909,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 1d7af3803b4..41d02d52a0d 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 cfb0cd2f3f0..bfd56fa19d6 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 00000000000..3d280da3687 --- /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 00000000000..6c095a8d16f --- /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 c8038576d3a..b84f843d322 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 = { From 9a815b6c8ca7080ac01ba04d5f195c52542c7952 Mon Sep 17 00:00:00 2001 From: Kong Date: Thu, 14 May 2026 02:28:33 +0800 Subject: [PATCH 017/214] fix(gateway): preserve queued follow-up transcript history Keep the outer history_offset when _run_agent drains queued follow-ups recursively so transcript persistence includes every queued turn in the chain instead of only the last one. --- gateway/run.py | 35 +++++++++++++++- tests/gateway/test_transcript_offset.py | 55 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 46c508e4bde..4946a7e6c1e 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. @@ -16042,7 +16074,7 @@ class GatewayRunner: 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 +16086,7 @@ class GatewayRunner: 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: diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index 27c96ad4b2c..c13e5eb1000 100644 --- a/tests/gateway/test_transcript_offset.py +++ b/tests/gateway/test_transcript_offset.py @@ -14,6 +14,8 @@ to ``_run_agent``'s return dict and uses it for the slice. import pytest +from gateway.run import _preserve_queued_followup_history_offset + # --------------------------------------------------------------------------- # Helpers - replicate the filtering logic from _run_agent @@ -265,3 +267,56 @@ class TestTranscriptHistoryOffset: 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"}, + ] + cool_turn = [ + {"role": "user", "content": "cool"}, + {"role": "assistant", "content": "Quote again"}, + ] + order_turn = [ + {"role": "user", "content": "how to make order?"}, + {"role": "assistant", "content": "Deposit flow"}, + ] + + current_result = { + "history_offset": len(history_before_chain), + "messages": history_before_chain + cool_turn, + } + followup_result = { + "history_offset": len(history_before_chain + cool_turn), + "messages": history_before_chain + cool_turn + order_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 == cool_turn + order_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 From cc64a04f61ff27ba7940884006a7632bf09e1ecb Mon Sep 17 00:00:00 2001 From: Kong Date: Thu, 14 May 2026 02:52:42 +0800 Subject: [PATCH 018/214] test(gateway): make queued follow-up regression generic Replace tenant-specific example text in the transcript offset regression with generic follow-up turns so the upstream test documents the bug without customer-specific wording. --- tests/gateway/test_transcript_offset.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index c13e5eb1000..d8a2672f4d6 100644 --- a/tests/gateway/test_transcript_offset.py +++ b/tests/gateway/test_transcript_offset.py @@ -282,22 +282,26 @@ class TestTranscriptHistoryOffset: {"role": "user", "content": "Earlier question"}, {"role": "assistant", "content": "Earlier answer"}, ] - cool_turn = [ - {"role": "user", "content": "cool"}, - {"role": "assistant", "content": "Quote again"}, + first_followup_turn = [ + {"role": "user", "content": "First follow-up question"}, + {"role": "assistant", "content": "First follow-up answer"}, ] - order_turn = [ - {"role": "user", "content": "how to make order?"}, - {"role": "assistant", "content": "Deposit flow"}, + 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 + cool_turn, + "messages": history_before_chain + first_followup_turn, } followup_result = { - "history_offset": len(history_before_chain + cool_turn), - "messages": history_before_chain + cool_turn + order_turn, + "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( @@ -307,7 +311,7 @@ class TestTranscriptHistoryOffset: assert merged["history_offset"] == len(history_before_chain) persisted = merged["messages"][merged["history_offset"]:] - assert persisted == cool_turn + order_turn + 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.""" From b833d85019463b101f52667390557f3fc86a25e5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 13:27:20 -0700 Subject: [PATCH 019/214] chore(release): map mgongzai author for PR #25183 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index e4cfaa0dd9c..afe864d2e94 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -41,6 +41,7 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" AUTHOR_MAP = { # teknium (multiple emails) "teknium1@gmail.com": "teknium1", + "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", "m@mobrienv.dev": "mikeyobrien", From 9d42c2c2869e5be531b6302bdc8ea6c6269a9604 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 16:39:41 -0700 Subject: [PATCH 020/214] feat(video_gen): unified video_generate tool with pluggable provider backends (#25126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(video_gen): unified video_generate tool with pluggable provider backends One core video_generate tool, every backend a plugin. Mirrors the image_gen + memory_provider + context_engine architecture: ABC, registry, plugin-context registration hook, and per-plugin model catalogs surfaced through hermes tools. Surface (one schema, every backend): - operation: generate / edit / extend - modalities: text-to-video (prompt only), image-to-video (prompt + image_url), video edit (prompt + video_url), video extend (video_url) - reference_image_urls, duration, aspect_ratio, resolution, negative_prompt, audio, seed, model override - Providers ignore unknown kwargs and declare what they support via VideoGenProvider.capabilities() — backend-specific quirks stay in the backend, the agent learns one tool Backends shipped: - plugins/video_gen/xai/ — Grok-Imagine, full generate/edit/extend + image-to-video + reference images (salvaged from PR #10600 by @Jaaneek, reshaped into the plugin interface) - plugins/video_gen/fal/ — Veo 3.1 (t2v + i2v), Kling O3 i2v, Pixverse v6 i2v with model-aware payload building that drops keys a model doesn't declare Wiring: - agent/video_gen_provider.py — VideoGenProvider ABC, normalize_operation, success_response / error_response, save_b64_video / save_bytes_video, $HERMES_HOME/cache/videos/ - agent/video_gen_registry.py — thread-safe register/get/list + get_active_provider() reading video_gen.provider from config.yaml - hermes_cli/plugins.py — PluginContext.register_video_gen_provider() - hermes_cli/tools_config.py — Video Generation category in hermes tools, plugin-only providers list, model picker per plugin, config write to video_gen.{provider,model} - toolsets.py — new video_gen toolset - tests: 31 new tests covering ABC, registry, tool dispatch, both plugins - docs: developer-guide/video-gen-provider-plugin.md (parallel to the image-gen guide), sidebar + toolsets-reference + plugin guides updated Supersedes: #25035 (FAL), #17972 (FAL), #14543 (xAI), #13847 (HappyHorse), #10458 (provider categories), #10786 (xAI media+search bundle), #2984 (FAL duplicate), #19086 (Google Veo standalone — easy port to plugin interface). Co-authored-by: Jaaneek * feat(video_gen): dynamic schema reflects active backend's capabilities Address the 'capability variance' question — instead of one tool with a static schema that lies about what every backend supports, the video_generate tool now rebuilds its description at get_definitions() time based on the configured video_gen.provider and video_gen.model. The agent sees backend-specific guidance up-front: - 'fal-ai/veo3.1/image-to-video': 'image-to-video only — image_url is REQUIRED; text-only prompts will be rejected' - 'fal-ai/veo3.1' (t2v): no image_url restriction shown - xAI grok-imagine-video: 'operations: generate, edit, extend; up to 7 reference_image_urls' - Backends without edit/extend: 'not supported on this backend — surface that they need to switch backends via hermes tools' This is the same pattern PR #22694 used for delegate_task self-capping — documented in the dynamic-tool-schemas skill. Cache invalidation is free: get_tool_definitions() already memoizes on config.yaml mtime, so a mid-session backend swap rebuilds the schema automatically. Tested: - Empirical FAL OpenAPI schema check confirms image-to-video models require image_url (FAL returns HTTP 422 otherwise) — client-side rejection in FALVideoGenProvider.generate() now prevents the wasted round-trip - Live E2E: fal-ai/veo3.1/image-to-video + prompt-only → clean missing_image_url error; fal-ai/veo3.1 + prompt-only → dispatches - 6 new tests cover the builder (no config / image-only / full-surface / text-only / unknown provider / registry wiring), all passing - 37/37 in the slice, 134/134 in the broader regression set * test(video_gen/xai): full surface integration tests + cleaner schema Verified end-to-end that the xAI plugin handles every documented mode from PR #10600's surface: text-to-video, image-to-video, reference-images-to-video, video edit, video extend (with and without prompt). All five modes route to the correct xAI endpoint (/videos/generations, /videos/edits, /videos/extensions) with the right payload shape (image / reference_images / video keys), and all five client-side rejections fire before the network: edit-without-prompt, extend-without-video_url, image+refs conflict, >7 references, and duration/aspect_ratio clamping. 15 new integration tests grouped into four classes (endpoint routing, modalities, validation, clamping). httpx is stubbed via a small fake AsyncClient that records POSTs so the tests assert the actual payload the plugin would send to xAI — not just the success/error envelope. Also cleaned up a description redundancy: when a model's operations match the backend's overall set, we no longer print the duplicate 'operations supported by this model' line. xAI's description now reads: Active backend: xAI . model: grok-imagine-video - operations supported by this backend: edit, extend, generate - modalities supported by this backend: image, reference_images, text - aspect_ratio choices: 16:9, 1:1, 2:3, 3:2, 3:4, 4:3, 9:16 - resolution choices: 480p, 720p - duration range: 1-15s - reference_image_urls: up to 7 images Co-authored-by: Jaaneek * feat(video_gen): collapse surface to t2v + i2v, family-based auto-routing Two design changes per Teknium: 1) Drop edit/extend from the tool surface entirely. Only text-to-video and image-to-video remain. The agent sees a clean tool with two modalities; backend-specific quirks like xAI's edit/extend endpoints stay out of the unified schema. 2) FAL: pick a model FAMILY once, the plugin routes between the family's text-to-video and image-to-video endpoints based on whether image_url was passed. Users no longer pick 'fal-ai/veo3.1' AND 'fal-ai/veo3.1/image-to-video' as separate options — they pick 'veo3.1', and the plugin handles the rest. Catalog rewritten as families: veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video pixverse-v6 fal-ai/pixverse/v6/text-to-video / fal-ai/pixverse/v6/image-to-video kling-o3-standard fal-ai/kling-video/o3/standard/text-to-video / fal-ai/kling-video/o3/standard/image-to-video xAI uses a single endpoint (/videos/generations) for both modes, routed by the presence of the 'image' field in the payload — no edit/extend exposure. Schema changes: - VIDEO_GENERATE_SCHEMA: drop operation, drop video_url. Final params: prompt (required), image_url, reference_image_urls, duration, aspect_ratio, resolution, negative_prompt, audio, seed, model. - VideoGenProvider ABC: drop normalize_operation, VALID_OPERATIONS, DEFAULT_OPERATION. capabilities() drops 'operations' key. - success_response: add 'modality' field ('text' | 'image') so the agent and logs can see which endpoint was actually hit. Dynamic schema builder simplified — no operations bullet, no 'switch backends if you need edit/extend' guidance. When the active backend supports both modalities (the common case), description reads: Active backend: FAL . model: pixverse-v6 - supports both text-to-video (omit image_url) and image-to-video (pass image_url) - routes automatically - aspect_ratio choices: 16:9, 9:16, 1:1 - resolution choices: 360p, 540p, 720p, 1080p - duration range: 1-15s - audio: pass audio=true to enable native audio (pricing tier) - negative_prompt: supported Tests: 51 in the video_gen slice, 216 across the broader image+video sweep, all passing. New FAL routing tests prove pixverse-v6 + no image hits text-to-video endpoint, pixverse-v6 + image_url hits image-to-video endpoint, same for veo3.1 and kling-o3-standard. Docs updated: developer-guide page rewrites the 'model families' pattern as a first-class section so external plugin authors know the convention. toolsets-reference and toolsets.py descriptions match the new surface. Co-authored-by: Jaaneek * feat(video_gen/fal): expand catalog to 6 families, cheap + premium tiers Catalog now covers everything Teknium specced from FAL: Cheap tier: ltx-2.3 fal-ai/ltx-2.3-22b/text-to-video / image-to-video pixverse-v6 fal-ai/pixverse/v6/text-to-video / 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 / image-to-video kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / image-to-video happy-horse fal-ai/happy-horse/text-to-video / image-to-video DEFAULT_MODEL moved from veo3.1 (premium) to pixverse-v6 (cheap, sane defaults, both modalities) — better first-run UX for users who haven't explicitly picked a model. New family-entry knob: image_param_key. Kling v3 4K's image-to-video endpoint expects start_image_url instead of image_url; declaring image_param_key='start_image_url' on the family lets _build_payload remap correctly. Other families default to plain image_url. Per-family capability flags reflect each model's docs: - LTX 2.3 + Happy Horse: minimal payloads (no duration/aspect/resolution enum exposed by FAL — let endpoint apply defaults) - Seedance: 6 aspect ratios incl 21:9, durations 4-15, audio supported, negative prompts NOT supported per docs - Kling v3 4K: 16:9/9:16/1:1, 3-15s, audio + negative - Veo 3.1: unchanged, 16:9/9:16, 4/6/8s Tests: +5 covering the new families (full catalog, Kling 4K start_image_url remap, Seedance routing, LTX payload minimality, Happy Horse minimality). 56/56 in the slice green. Note: I did NOT add the FAL-hosted xAI Grok-Imagine variant. Hermes already has a direct xAI plugin that talks to xAI's own API; routing the same model through FAL's wrapper would duplicate the surface without adding capabilities. Users on FAL who want Grok-Imagine should use the xAI plugin directly; flag if you want both routes available. * test(video_gen): tool-surface routing matrix — every model x modality End-to-end matrix test driven through _handle_video_generate() — the actual function the agent's video_generate tool call lands in. Writes config.yaml, invokes the registered handler with a raw args dict, then asserts the outbound HTTP/SDK call hit the right endpoint with the right payload shape. Parametrized over FAL_FAMILIES.keys() so the matrix auto-discovers new families as they're added (add a family to FAL_FAMILIES and you get both modalities tested for free). Coverage: - All 6 FAL families x {text-only, text+image} = 12 cases - xAI x {text-only, text+image} = 2 cases - tool-level model= arg overrides config = 2 cases For each case, verifies: - result['success'] is True - result['modality'] matches input shape ('text' if no image_url, 'image' otherwise) - outbound endpoint URL matches the family's text_endpoint or image_endpoint - text-only payloads carry no image-shaped keys - text+image payloads carry the family's image key (image_url for most, start_image_url for kling-v3-4k, wrapped 'image' object for xAI) All 16 cases passing. Confirms the tool surface routes every (provider, model, modality) combination correctly with zero leakage. * feat(video_gen): keep video_gen out of first-run setup, surface in status Two changes: 1. video_gen joins _DEFAULT_OFF_TOOLSETS, so it is NOT pre-selected in the first-run toolset checklist. Video gen is niche, paid, and slow — most users don't want it nagging them during initial setup. Anyone who wants it opts in via 'hermes tools' -> Video Generation, which already routes to the provider+model picker. 2. The 'hermes setup' status panel learns about video_gen — but only shows the row when a plugin reports available. Users without FAL_KEY/XAI_API_KEY see nothing about video gen; users with one of those keys see 'Video Generation (FAL) ✓' as confirmation it's wired. Verified live: - Fresh install (no creds): zero video_gen mentions in wizard. - With FAL_KEY: status row appears with active backend name. - 160/160 in the setup + tools_config + video_gen test slice. Rationale: image_gen is on by default because it's a featured creative tool used in casual chat (telegrams, etc). Video gen is heavier — long wait, paid per-second pricing. Default-off matches user intent better. --------- Co-authored-by: Jaaneek --- agent/video_gen_provider.py | 299 ++++++++++ agent/video_gen_registry.py | 117 ++++ hermes_cli/config.py | 4 +- hermes_cli/plugins.py | 27 + hermes_cli/setup.py | 20 + hermes_cli/tools_config.py | 185 +++++- plugins/video_gen/fal/__init__.py | 523 ++++++++++++++++ plugins/video_gen/fal/plugin.yaml | 7 + plugins/video_gen/xai/__init__.py | 402 +++++++++++++ plugins/video_gen/xai/plugin.yaml | 7 + tests/agent/test_video_gen_registry.py | 114 ++++ tests/plugins/video_gen/__init__.py | 1 + tests/plugins/video_gen/test_fal_plugin.py | 314 ++++++++++ tests/plugins/video_gen/test_xai_plugin.py | 69 +++ .../video_gen/test_xai_plugin_integration.py | 191 ++++++ tests/tools/test_video_generation_dispatch.py | 126 ++++ .../test_video_generation_dynamic_schema.py | 153 +++++ ...st_video_generation_tool_surface_matrix.py | 253 ++++++++ tools/video_generation_tool.py | 561 ++++++++++++++++++ toolsets.py | 11 + .../video-gen-provider-plugin.md | 231 ++++++++ website/docs/guides/build-a-hermes-plugin.md | 1 + website/docs/reference/toolsets-reference.md | 1 + website/docs/user-guide/features/plugins.md | 2 + website/sidebars.ts | 1 + 25 files changed, 3617 insertions(+), 3 deletions(-) create mode 100644 agent/video_gen_provider.py create mode 100644 agent/video_gen_registry.py create mode 100644 plugins/video_gen/fal/__init__.py create mode 100644 plugins/video_gen/fal/plugin.yaml create mode 100644 plugins/video_gen/xai/__init__.py create mode 100644 plugins/video_gen/xai/plugin.yaml create mode 100644 tests/agent/test_video_gen_registry.py create mode 100644 tests/plugins/video_gen/__init__.py create mode 100644 tests/plugins/video_gen/test_fal_plugin.py create mode 100644 tests/plugins/video_gen/test_xai_plugin.py create mode 100644 tests/plugins/video_gen/test_xai_plugin_integration.py create mode 100644 tests/tools/test_video_generation_dispatch.py create mode 100644 tests/tools/test_video_generation_dynamic_schema.py create mode 100644 tests/tools/test_video_generation_tool_surface_matrix.py create mode 100644 tools/video_generation_tool.py create mode 100644 website/docs/developer-guide/video-gen-provider-plugin.md diff --git a/agent/video_gen_provider.py b/agent/video_gen_provider.py new file mode 100644 index 00000000000..af8bf9faf78 --- /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 00000000000..ad936e29d42 --- /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/hermes_cli/config.py b/hermes_cli/config.py index 4c2596594ec..a94f7e2d527 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2107,10 +2107,10 @@ OPTIONAL_ENV_VARS = { "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", }, diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 70b0dc9cd7f..fd785ba0258 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -542,6 +542,33 @@ class PluginContext: 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, + ) + # -- platform adapter registration --------------------------------------- def register_platform( diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ddcd5e532bb..6a8bf950589 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: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f5e464f163e..03ffa800f9c 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -60,6 +60,7 @@ CONFIGURABLE_TOOLSETS = [ ("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 @@ CONFIGURABLE_TOOLSETS = [ # 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 @@ -349,6 +354,15 @@ TOOL_CATEGORIES = { }, ], }, + "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 +1539,43 @@ 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 + + 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 +1592,11 @@ 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()) + return visible @@ -1608,6 +1664,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 +2025,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 +2187,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 +2241,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: diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py new file mode 100644 index 00000000000..0f46f62a7a0 --- /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 00000000000..2003a817e78 --- /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 00000000000..b7421799044 --- /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 00000000000..85aa6e68f13 --- /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/tests/agent/test_video_gen_registry.py b/tests/agent/test_video_gen_registry.py new file mode 100644 index 00000000000..a6439ec92fc --- /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/plugins/video_gen/__init__.py b/tests/plugins/video_gen/__init__.py new file mode 100644 index 00000000000..07355db30ae --- /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 00000000000..fdfa9a6ec44 --- /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 00000000000..25695d852e5 --- /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 00000000000..31d44f15be4 --- /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/tools/test_video_generation_dispatch.py b/tests/tools/test_video_generation_dispatch.py new file mode 100644 index 00000000000..36551acbe02 --- /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 00000000000..590215468b5 --- /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 00000000000..7fe9efefbd6 --- /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/tools/video_generation_tool.py b/tools/video_generation_tool.py new file mode 100644 index 00000000000..63d80165dc0 --- /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/toolsets.py b/toolsets.py index 5e34a0548c8..c664136c52a 100644 --- a/toolsets.py +++ b/toolsets.py @@ -107,6 +107,17 @@ TOOLSETS = { "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/website/docs/developer-guide/video-gen-provider-plugin.md b/website/docs/developer-guide/video-gen-provider-plugin.md new file mode 100644 index 00000000000..611c662621c --- /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/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index 45ad3622ea5..ee74e23ac5e 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/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 37bd5aae1d8..ce11d86cb41 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/features/plugins.md b/website/docs/user-guide/features/plugins.md index 8bab522f9dd..e9dc2910889 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/sidebars.ts b/website/sidebars.ts index 67a256bcc09..f706d2a607d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -223,6 +223,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', From 091d8e10306613819c6cf3a64dda5b166c3048cd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 17:18:15 -0700 Subject: [PATCH 021/214] feat(codex-runtime): optional codex app-server runtime for OpenAI/Codex models (#24182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codex-runtime): scaffold optional codex app-server runtime Foundational commit for an opt-in alternate runtime that hands OpenAI/Codex turns to a 'codex app-server' subprocess instead of Hermes' tool dispatch. Default behavior is unchanged. Lands in three pieces: 1. agent/transports/codex_app_server.py — JSON-RPC 2.0 over stdio speaker for codex's app-server protocol (codex-rs/app-server). Spawn, init handshake, request/response, notification queue, server-initiated request queue (for approval round-trips), interrupt-friendly blocking reads. Tested against real codex 0.130.0 binary end-to-end during development. 2. hermes_cli/runtime_provider.py: - Adds 'codex_app_server' to _VALID_API_MODES. - Adds _maybe_apply_codex_app_server_runtime() helper, called at the end of _resolve_runtime_from_pool_entry(). Inert unless 'model.openai_runtime: codex_app_server' is set in config.yaml AND provider in {openai, openai-codex}. Other providers cannot be rerouted (anthropic, openrouter, etc. preserved). 3. tests/agent/transports/test_codex_app_server_runtime.py — 24 tests covering api_mode registration, the rewriter helper (default-off, case-insensitive, opt-in, non-eligible providers preserved), version parser, missing-binary handling, error class. Does NOT require codex CLI installed. This commit is wire-only: the api_mode is recognized but AIAgent does not yet branch on it. Followup commits add the session adapter, event projector, approval bridge, transcript projection (so memory/skill review still works), plugin migration, and slash command. Existing tests remain green: - tests/cli/test_cli_provider_resolution.py (29 passed) - tests/agent/test_credential_pool_routing.py (included above) * feat(codex-runtime): add codex item projector for memory/skill review The translator that lets Hermes' self-improvement loop keep working under the Codex runtime: converts codex 'item/*' notifications into Hermes' standard {role, content, tool_calls, tool_call_id} message shape that agent/curator.py already knows how to read. Item taxonomy (matches codex-rs/app-server-protocol/src/protocol/v2/item.rs): - userMessage → {role: user, content} - agentMessage → {role: assistant, content: text} - reasoning → stashed in next assistant's 'reasoning' field - commandExecution → assistant tool_call(name='exec_command') + tool result - fileChange → assistant tool_call(name='apply_patch') + tool result - mcpToolCall → assistant tool_call(name='mcp..') + tool result - dynamicToolCall → assistant tool_call(name=) + tool result - plan/hookPrompt/etc → opaque assistant note, no fabricated tool_calls Invariants preserved: - Message role alternation never violated: each tool item produces at most one assistant + one tool message in that order, correlated by call_id. - Streaming deltas (item//outputDelta, item/agentMessage/delta) don't materialize messages — only item/completed does. Mirrors how Hermes already only writes the assistant message after streaming ends. - Tool call ids are deterministic (codex item id-based) so replays produce identical messages and prefix caches stay valid (AGENTS.md pitfall #16). - JSON args use sorted_keys for the same reason. Real wire formats verified against codex 0.130.0 by capturing live notifications from thread/shellCommand and including one as a fixture (COMMAND_EXEC_COMPLETED). 23 new tests, all green: - Streaming deltas don't materialize (3 paths) - Turn/thread frame events are silent - commandExecution: 5 tests including non-zero exit annotation + deterministic id stability across replays - agentMessage + reasoning attachment + reasoning consumption - fileChange: summary without inlined content - mcpToolCall: namespaced naming + error surfacing - userMessage: text fragments only (drops images/etc) - opaque items: no fabricated tool_calls - Helpers: deterministic id stability + sorted JSON args - Role alternation invariant across all four tool-shaped item types This commit is a pure addition. AIAgent integration (the wire that uses the projector) is the next commit. * feat(codex-runtime): add session adapter + approval bridge The third self-contained module: CodexAppServerSession owns one Codex thread per Hermes session, drives turn/start, consumes streaming notifications via CodexEventProjector, handles server-initiated approval requests, and translates cancellation into turn/interrupt. The adapter has a single public per-turn method: result = session.run_turn(user_input='...', turn_timeout=600) # result.final_text → assistant text for the caller # result.projected_messages → list ready to splice into AIAgent.messages # result.tool_iterations → tick count for _iters_since_skill nudge # result.interrupted → True on Ctrl+C / deadline / interrupt # result.error → error string when the turn cannot complete # result.turn_id, thread_id → for sessions DB / resume Behavior: - ensure_started() spawns codex, does the initialize handshake, and issues thread/start with cwd + permissions profile. Idempotent. - run_turn() blocks until turn/completed, drains server-initiated requests (approvals) before reading notifications so codex never deadlocks waiting for us, projects every item/completed via the projector, and increments tool_iterations for the skill nudge gate. - request_interrupt() is thread-safe (threading.Event); the next loop iteration issues turn/interrupt and unwinds. - turn_timeout deadlock guard issues turn/interrupt and records an error if the turn never completes. - close() escalates terminate → kill via the underlying client. Approval bridge: Codex emits server-initiated requests for execCommandApproval and applyPatchApproval. The adapter translates Hermes' approval choice vocabulary onto codex's decision vocabulary: Hermes 'once' → codex 'approved' Hermes 'session' or 'always' → codex 'approvedForSession' Hermes 'deny' / anything else → codex 'denied' Routing precedence: 1. _ServerRequestRouting.auto_approve_* flags (cron / non-interactive) 2. approval_callback wired by the CLI (defers to tools.approval.prompt_dangerous_approval()) 3. Fail-closed denial when neither is wired Unknown server-request methods are answered with JSON-RPC error -32601 so codex doesn't hang waiting for us. Permission profile mapping mirrors AGENTS.md: Hermes 'auto' → codex 'workspace-write' Hermes 'approval-required' → codex 'read-only-with-approval' Hermes 'unrestricted/yolo' → codex 'full-access' 20 new tests, all green. Combined with prior commits this PR now has 67 tests across three modules: - test_codex_app_server_runtime.py: 24 (api_mode + transport surface) - test_codex_event_projector.py: 23 (item taxonomy projections) - test_codex_app_server_session.py: 20 (turn loop + approvals + interrupts) Full tests/agent/transports/ directory: 249/249 pass — no regressions to existing transport tests. Still no wire into AIAgent.run_conversation(); that integration commit is small and goes next. * feat(codex-runtime): wire codex_app_server runtime into AIAgent The integration commit. AIAgent.run_conversation() now early-returns to a new helper _run_codex_app_server_turn() when self.api_mode == 'codex_app_server', bypassing the chat_completions tool loop entirely. Three small surgical edits to run_agent.py (~105 LOC total): 1. Line ~1204 (constructor api_mode validation set): Add 'codex_app_server' so an explicit api_mode='codex_app_server' passed to AIAgent() isn't silently rewritten to 'chat_completions'. 2. Line ~12048 (run_conversation, just before the while loop): Early-return to _run_codex_app_server_turn() when self.api_mode is 'codex_app_server'. Placed AFTER all standard pre-loop setup — logging context, session DB, surrogate sanitization, _user_turn_count and _turns_since_memory increments, _ext_prefetch_cache, memory manager on_turn_start — so behavior outside the model-call loop is identical between paths. Default Hermes flow is unchanged when the flag is off. 3. End-of-class (line ~15497): New method _run_codex_app_server_turn(). Lazy-instantiates one CodexAppServerSession per AIAgent (reused across turns), runs the turn, splices projected_messages into messages, increments _iters_since_skill by tool_iterations (since the chat_completions loop normally does that per iteration), fires _spawn_background_review on the same cadence as the default path. Counter accounting: _turns_since_memory ← already incremented at run_conversation:11817 (gated on memory store configured) — codex helper does NOT touch it (would double-count). _user_turn_count ← already incremented at run_conversation:11793 — codex helper does NOT touch it. _iters_since_skill ← incremented in the chat_completions loop per tool iteration. Codex helper increments by turn.tool_iterations since the loop is bypassed. User message: ALREADY appended to messages by run_conversation pre-loop (line 11823) before the early-return reaches us. Helper does NOT append again. Regression test test_user_message_not_duplicated guards this. Approval callback wiring: Lazy-fetches tools.terminal_tool._get_approval_callback at session spawn time, passes to CodexAppServerSession. CLI threads with prompt_toolkit get interactive approvals; gateway/cron contexts get the codex-side fail-closed deny. Error path: Codex session exceptions become a 'partial' result with completed=False and a final_response that explicitly tells the user how to switch back: 'Codex app-server turn failed: ... Fall back to default runtime with /codex-runtime auto.' Same return-dict shape as the chat_completions path so all callers (gateway, CLI, batch_runner, ACP) work unchanged. 9 new integration tests in tests/run_agent/test_codex_app_server_integration.py: - api_mode='codex_app_server' is accepted on AIAgent construction - run_conversation returns the expected codex shape (final_response, codex_thread_id, codex_turn_id, completed, partial) - Projected messages are spliced into messages list - _iters_since_skill ticks per tool iteration - _user_turn_count delegated to standard flow (not double-counted) - User message appears exactly once (regression guard) - _spawn_background_review IS invoked (memory/skill review keeps working) - chat.completions.create is NEVER called (loop fully bypassed) - Session exception → partial result with /codex-runtime auto hint - Interrupted turn → partial result with error preserved Adjacent test runs confirm no regressions: - tests/run_agent/test_memory_nudge_counter_hydration.py: green - tests/run_agent/test_background_review.py: green - tests/run_agent/test_fallback_model.py: green - tests/agent/transports/: 249/249 green Still missing for full feature: /codex-runtime slash command, plugin migration helper, docs page, live e2e test gated on codex binary. Those are the remaining followup commits. * feat(codex-runtime): add /codex-runtime slash command (CLI + gateway) User-facing toggle for the optional codex app-server runtime. Follows the 'Adding a Slash Command (All Platforms)' pattern from AGENTS.md exactly: single CommandDef in the central registry → CLI handler → gateway handler → running-agent guard → all surfaces (autocomplete, /help, Telegram menu, Slack subcommands) update automatically. Surface: /codex-runtime — show current state + codex CLI status /codex-runtime auto — Hermes default runtime /codex-runtime codex_app_server — codex subprocess runtime /codex-runtime on / off — synonyms Files changed: hermes_cli/codex_runtime_switch.py (new): Pure-Python state machine shared by CLI and gateway. Parse args, read/write model.openai_runtime in the config dict, gate enabling behind a codex --version check (don't let users opt in to a runtime they have no binary for; print npm install hint instead). Returns a CodexRuntimeStatus dataclass that callers render however suits their surface. hermes_cli/commands.py: Single CommandDef entry, no aliases (codex-runtime is its own thing). cli.py: Dispatch in process_command() + _handle_codex_runtime() handler that delegates to the shared module and renders results via _cprint. gateway/run.py: Dispatch in _handle_message() + _handle_codex_runtime_command() that returns a string (gateway sends as message). On a successful change that requires a new session, _evict_cached_agent() forces the next inbound message to construct a fresh AIAgent with the new api_mode — avoids prompt-cache invalidation mid-session. gateway/run.py running-agent guard: /codex-runtime joins /model in the early-intercept block so a runtime flip mid-turn can't split a turn across two transports. Tests: tests/hermes_cli/test_codex_runtime_switch.py — 25 tests covering the state machine: arg parsing (10 cases incl. case-insensitive and synonyms), reading current runtime (5 cases incl. malformed configs), writing runtime (3 cases), apply() entry point covering read-only, no-op, codex-missing-blocked, codex-present-success, disable-no-binary-check, and persist-failure paths (8 cases). All green. Adjacent test suites confirm no regressions: - tests/hermes_cli/test_commands.py + test_codex_runtime_switch.py: 167/167 green - tests/agent/transports/: 283/283 green when combined with prior commits Still missing: plugin migration helper, docs page, live e2e test gated on codex binary. Followup commits. * feat(codex-runtime): auto-migrate Hermes MCP servers to ~/.codex/config.toml Translates the user's mcp_servers config from ~/.hermes/config.yaml into the TOML format codex's MCP client expects. Wired into the /codex-runtime codex_app_server enable path so users get their MCP tool surface in the spawned subprocess automatically. The migration runs on every enable. Failures are non-fatal — the runtime change still proceeds and the user gets a warning so they can fix the codex config manually. What translates (mapping verified against codex-rs/core/src/config/edit.rs): 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 Hermes mcp_servers..cwd → codex stdio cwd Hermes mcp_servers..enabled: false → codex enabled = false What does NOT translate (warned + skipped per server): 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. Idempotency design: All managed content lives between a 'managed by hermes-agent' marker and the next non-mcp_servers section header. _strip_existing_managed_block removes the prior managed region cleanly, preserving any user-added codex config (model, providers.openai, sandbox profiles, etc.) above or below. Files added: hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration helper. Public API: migrate(hermes_config, codex_home=None, dry_run=False) returns MigrationReport with .migrated/.errors/ .skipped_keys_per_server. No external TOML dependency — minimal formatter handles strings/numbers/booleans/lists/inline-tables. tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests covering: - per-server translation (12): stdio/http/sse, cwd, timeouts, enabled flag, command+url precedence, sampling drop, unknown keys - TOML formatter (8): types, escaping, inline tables, error case - existing-block stripping (4): no marker, alone, with user content above, with user content below - end-to-end migrate() (8): empty, dry-run, round-trip, idempotent re-run, preserves user config, error reporting, invalid input, summary formatting Files changed: hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in the codex_app_server enable branch. Migration failure logs a warning in the result message but does NOT fail the runtime change. Disable path (auto) explicitly skips migration. tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests: test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration, test_migration_failure_does_not_block_enable. All 325 feature tests green: - tests/agent/transports/: 249 (incl. 67 new) - tests/run_agent/test_codex_app_server_integration.py: 9 - tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new) - tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new) * perf(codex-runtime): cache codex --version check within apply() Single /codex-runtime invocation could spawn 'codex --version' up to 3 times (state report, enable gate, success message). Each spawn is ~50ms, so the cumulative cost wasn't a crisis, but it was wasteful and turned a trivial slash command into something noticeably laggy on slower systems. Refactored to lazy-once via a closure over a nonlocal cache. First call spawns; subsequent calls in the same apply() reuse the result. Behavior unchanged — same return shape, same error handling, same install hint when codex is missing. Just one subprocess per call instead of three. Two regression-guard tests added: - test_binary_check_cached_within_apply: enable path → call_count == 1 - test_binary_check_cached_on_read_only_call: state-report path → call_count == 1 Total tests for /codex-runtime now 30 (was 28); all 143 codex-runtime tests still green. * fix(codex-runtime): correct protocol field names found via live e2e test Three real bugs caught only by running a turn end-to-end against codex 0.130.0 with a real ChatGPT subscription. Unit tests passed because they asserted on our own (incorrect) wire shapes; the wire format from codex-rs/app-server-protocol/src/protocol/v2/* is the source of truth and my initial reading of the README was incomplete. Bug 1: thread/start.permissions wire format Was sending {"profileId": "workspace-write"}. Real format per PermissionProfileSelectionParams enum (tagged union): {"type": "profile", "id": "workspace-write"} AND requires the experimentalApi capability declared during initialize. AND requires a matching [permissions] table in ~/.codex/config.toml or codex fails the request with 'default_permissions requires a [permissions] table'. Fix: stop overriding permissions on thread/start. Codex picks its default profile (read-only unless user configures otherwise), which matches what codex CLI users expect — they configure their default permission profile in ~/.codex/config.toml the standard way. Trying to be clever about profile selection broke every turn we tested. Live error before fix: 'Invalid request: missing field type' on every turn/start, even though our turn/start payload was correct — the field codex was complaining about was inside the permissions sub-object we shouldn't have been sending. Bug 2: server-request method names Was matching 'execCommandApproval' and 'applyPatchApproval'. Real names per common.rs ServerRequest enum: item/commandExecution/requestApproval item/fileChange/requestApproval item/permissions/requestApproval (new third method) Fix: match the documented names. Added handler for item/permissions/requestApproval that always declines — codex sometimes asks to escalate permissions mid-turn and silent acceptance would surprise users. Live symptom before fix: agent.log showed 'Unknown codex server request: item/commandExecution/requestApproval' and codex stalled because we replied with -32601 (unsupported method) instead of an approval decision. The agent reported back 'The write command was rejected' even though Hermes never showed the user an approval prompt. Bug 3: approval decision values Was sending decision strings 'approved'/'approvedForSession'/'denied'. Real values per CommandExecutionApprovalDecision enum (camelCase): accept, acceptForSession, decline, cancel (also AcceptWithExecpolicyAmendment and ApplyNetworkPolicyAmendment variants we don't currently use). Fix: rename _approval_choice_to_codex_decision return values; update auto_approve_* fallbacks; update fail-closed default from 'denied' to 'decline'. Test mapping table updated to match. Live test verified after fixes: $ hermes (with model.openai_runtime: codex_app_server) > Run the shell command: echo hermes-codex-livetest > .../proof.txt then read it back Approval prompt fired with 'Codex requests exec in '. User chose 'Allow once'. Codex executed the command, wrote the file, read it back. Final response: 'Read back from proof.txt: hermes-codex-livetest'. File contents on disk match. agent.log confirms: codex app-server thread started: id=019e200e profile=workspace-write cwd=/tmp/hermes-codex-livetest/workspace All 20 session tests still green after wire-format updates. * fix(codex-runtime): correct apply_patch approval params + ship docs Live e2e revealed FileChangeRequestApprovalParams doesn't carry the changeset (just itemId, threadId, turnId, reason, grantRoot) — Codex's 'reason' field describes what the patch wants to do. Test config and display logic updated to use it. The first 'apply_patch (0 change(s))' display from the live test is now 'apply_patch: '. Adds website/docs/user-guide/features/codex-app-server-runtime.md covering enable/disable, prerequisites, approval UX, MCP migration behavior, permission profile delegation to ~/.codex/config.toml, known limitations, and the architecture diagram. Wired into the Automation category in sidebars.ts. Live e2e validation across the path matrix: ✓ thread/start handshake ✓ turn/start with text input ✓ commandExecution items + projection ✓ item/commandExecution/requestApproval → Hermes UI → response ✓ Approve once → command runs ✓ Deny → command rejected, codex falls back to read-only message ✓ Multi-turn (codex remembers prior turn's results) ✓ apply_patch via Codex's fileChange path ✓ item/fileChange/requestApproval → Hermes UI ✓ MCP server migration loads inside spawned codex (verified via 'use the filesystem MCP tool' prompt) ✓ /codex-runtime auto → codex_app_server toggle cycle ✓ Disable doesn't trigger migration ✓ Enable with codex CLI present succeeds + migrates ✓ Hermes-side interrupt path (turn/interrupt request issued cleanly even if codex finishes before the interrupt lands) Known live-validated limitations now documented in the docs page: - delegate_task subagents unavailable on this runtime - permission profile selection delegated to ~/.codex/config.toml - apply_patch approval prompt has no inline changeset (codex protocol doesn't expose it) 145/145 codex-runtime tests still green. * feat(codex-runtime): native plugin migration + UX polish (quirks 2/4/5/10/11) Major: migrate native Codex plugins (#7 in OpenClaw's PR list) Discovers installed curated plugins via codex's plugin/list RPC and writes [plugins."@"] entries to ~/.codex/config.toml so they're enabled in the spawned Codex sessions. This is the 'YouTube-video-worthy' bit Pash highlighted: when a user has google-calendar, github, etc. installed in their Codex CLI, those plugins activate automatically when they enable Hermes' codex runtime. Implementation: - hermes_cli/codex_runtime_plugin_migration.py: new _query_codex_plugins() helper spawns 'codex app-server' briefly and walks plugin/list. Returns (plugins, error) — failures are non-fatal so MCP migration still works. - render_codex_toml_section() now takes plugins + permissions args. - migrate() defaults: discover_plugins=True, default_permission_profile= 'workspace-write'. Explicit None on either disables that side. - _strip_existing_managed_block() now also strips [plugins.*] and [permissions]/[permissions.*] sections inside the managed block, so re-runs replace plugins cleanly without touching codex's own config. Quirk fixes: #2 Default permissions profile written on enable. Without this, Codex's read-only default kicks in and EVERY write triggers an approval prompt. Now writes [permissions] default = 'workspace-write' so the runtime feels normal out of the box. Set default_permission_profile=None to opt out. #4 apply_patch approval prompt now shows what's changing. Codex's FileChangeRequestApprovalParams doesn't carry the changeset. Session adapter now caches the fileChange item from item/started notifications and looks it up by itemId when codex requests approval. Prompt shows '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of 'apply_patch (0 change(s))'. Side benefit: also drains pending notifications BEFORE handling a server request, so the projector and per-turn caches are up to date when the approval decision fires. Bounded to 8 notifications per loop iter to avoid starving codex's response. #5/#10 Exec approval prompt never shows empty cwd. When codex omits cwd in CommandExecutionRequestApprovalParams, fall back to the session's cwd. If somehow neither is available, show '' explicitly instead of an empty string. Also surfaces 'reason' from the approval params when codex provides it — gives users more context on why codex wants to run something. #11 Banner indicates the codex_app_server runtime when active. New 'Runtime: codex app-server (terminal/file ops/MCP run inside codex)' line appears in the welcome banner only when the runtime is on. Default banner is unchanged. Tests: - 7 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery (mocked), failure handling, dry-run skip, opt-out flag, idempotent re-runs, and permissions writing. - 3 new tests in test_codex_app_server_session.py covering the enriched approval prompts: cwd fallback, change summary on apply_patch, fallback when no item/started cache exists. - All 26 session tests + 46 migration tests green; 153 total in PR. * feat(codex-runtime): hermes-tools MCP callback + native plugin migration The big architectural addition: when codex_app_server runtime is on, Hermes registers its own tool surface as an MCP server in ~/.codex/config.toml so the codex subprocess can call back into Hermes for tools codex doesn't ship with — web_search, browser_*, vision, image_generate, skills, TTS. Also: 'migrate native codex plugins' (Pash's YouTube-video-worthy bit) — when the user has plugins like Linear, GitHub, Gmail, Calendar, Canva installed via 'codex plugin', Hermes discovers them via plugin/list and writes [plugins.@openai-curated] entries so they activate automatically. New module: agent/transports/hermes_tools_mcp_server.py FastMCP stdio server exposing 17 Hermes tools. Each call dispatches through model_tools.handle_function_call() — same code path as the Hermes default runtime. Run with: python -m agent.transports.hermes_tools_mcp_server [--verbose] Exposed: web_search, web_extract, browser_navigate / _click / _type / _press / _snapshot / _scroll / _back / _get_images / _console / _vision, vision_analyze, image_generate, skill_view, skills_list, text_to_speech. NOT exposed (deliberately): - terminal/shell/read_file/write_file/patch — codex has built-ins - delegate_task/memory/session_search/todo — _AGENT_LOOP_TOOLS in model_tools.py:493, require running AIAgent context. Documented as a limitation and surfaced in the slash command output. Migration changes (hermes_cli/codex_runtime_plugin_migration.py): - _query_codex_plugins() spawns 'codex app-server' briefly to walk plugin/list and pull installed openai-curated plugins. Failures are non-fatal — MCP migration still completes. - render_codex_toml_section() now takes plugins + permissions args AND wraps the managed block with a MIGRATION_END_MARKER comment so the stripper can reliably find both ends, even when the block contains top-level keys (default_permissions = ...). - migrate() defaults: discover_plugins=True, expose_hermes_tools=True, default_permission_profile=':workspace' (built-in codex profile name — must be prefixed with ':'). All three opt-out via explicit args. - _build_hermes_tools_mcp_entry() builds the codex stdio entry with HERMES_HOME and PYTHONPATH passthrough so a worktree-launched Hermes points the MCP subprocess at the same module layout. Live-caught wire bugs fixed during this turn: 1. Permission profile config key is top-level , NOT a [permissions] table. The [permissions] table is for *user-defined* profiles with structured fields. Built-in profile names start with ':' (':workspace', ':read-only', ':danger-no-sandbox'). Was emitting which codex rejected with 'invalid type: string "X", expected struct PermissionProfileToml'. 2. Built-in profile is , NOT . Codex rejected with 'unknown built-in profile'. 3. Codex's MCP layer sends for tool-call confirmation. We weren't handling it, so codex stalled and returned 'MCP tool call was rejected'. Now: auto-accept for our own hermes-tools server (user already opted in by enabling the runtime), decline for third-party servers. Quirk fixes shipped (from the limitations list): #2 default permissions: workspace profile written on enable. No more approval prompt on every write. #4 apply_patch approval shows what's changing: cache fileChange items from item/started, look up by itemId when codex sends item/fileChange/requestApproval. Prompt: '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of '0 change(s)'. #5/#10 exec approval cwd never empty: fall back to session cwd, then ''. Also surfaces 'reason' from codex when present. #11 banner shows 'Runtime: codex app-server' line when active so users understand why tool counts may not match what's reachable. Tests: - 5 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery, expose_hermes_tools entry generation, idempotent re-runs, opt-out flag, permissions profile. - 3 new tests in test_codex_app_server_session.py covering enriched approval prompts (cwd fallback, fileChange summary). - 2 new tests for mcpServer/elicitation/request handling (accept hermes-tools, decline others). - New test file test_hermes_tools_mcp_server.py covering module surface, EXPOSED_TOOLS safety invariants (no shell/file_ops, no agent-loop tools), and main() error paths. - 166 codex-runtime tests total, all green. Live e2e validated against codex 0.130.0 + ChatGPT subscription: ✓ /codex-runtime codex_app_server enables, migrates filesystem MCP, registers hermes-tools, writes default_permissions = ':workspace' ✓ Banner shows 'Runtime: codex app-server' line in subsequent sessions ✓ Shell command runs without approval prompt (workspace profile works) ✓ Multi-turn — codex remembers prior turn's results ✓ apply_patch path via fileChange request approval ✓ web_search via hermes-tools MCP callback returns real Firecrawl results: 'OpenAI Codex CLI – Getting Started' end-to-end in 13s ✓ Disable cycle clean Docs updated: website/docs/user-guide/features/codex-app-server-runtime.md Full re-write covering native plugin migration, the hermes-tools callback architecture, the prerequisites change ('codex login is separate from hermes auth login codex'), the trade-off table now reflecting which Hermes tools work via callback, and the limitations list updated with what's actually unavailable on this runtime. * feat(codex-runtime): pin user-config preservation invariant for quirk #6 Quirk #6 from the limitations list — user MCP servers / overrides / codex-only sections in ~/.codex/config.toml that live OUTSIDE the hermes-managed block must survive re-migration verbatim. This already worked thanks to the MIGRATION_MARKER + MIGRATION_END_MARKER pair I added when fixing the default_permissions wire format (so the strip can find both ends of the managed region even with top-level keys like default_permissions). But it was an emergent property without a test pinning it. Now explicitly tested: - User MCP server above the managed block survives migration - User MCP server below the managed block survives migration - Both above + below survive a second re-migration - User content (model, providers, sandbox, otel, etc.) outside our region is left untouched Docs added a section "Editing ~/.codex/config.toml safely" explaining the marker contract — so users know they can add their own MCP servers, override permissions, configure codex-only options, etc. without fear of Hermes overwriting their work. 167 codex-runtime tests, all green. * docs(codex-runtime): clarify the actual tool surface — shell covers terminal/read/write/find Previous docs and PR description undersold what codex's built-in toolset actually provides. apply_patch alone made it sound like the runtime could only edit files in patch format — implying you'd lose terminal use, read_file, write_file, search/find. That was wrong. Codex's 'shell' tool runs arbitrary shell commands inside the sandbox, which covers everything you'd do in bash: cat/head/tail (read), echo> or heredocs (write), find/rg/grep (search), ls/cd (navigate), build/ test/git/etc. apply_patch is for structured multi-file edits on top of that. update_plan is its in-runtime todo. view_image loads images. And codex has its own web_search built in (in addition to the Firecrawl-backed one Hermes exposes via MCP callback). Docs now have a 'What tools the model actually has' section right after Why, breaking the surface into three clearly-labeled buckets: 1. Codex's built-in toolset (always on) — shell, apply_patch, update_plan, view_image, web_search; covers everything terminal- adjacent. 2. Native Codex plugins (auto-migrated from your codex plugin install) — Linear, GitHub, Gmail, Calendar, Outlook, Canva, etc. 3. Hermes tool callback (MCP server in ~/.codex/config.toml) — web_search/web_extract via Firecrawl, browser_*, vision_analyze, image_generate, skill_view/skills_list, text_to_speech. Plus a 'What's NOT available' callout listing the four agent-loop tools (delegate_task, memory, session_search, todo) that need running AIAgent context and can't reach the codex runtime. Trade-offs table broken out: shell, apply_patch, update_plan, view_image, sandbox each get their own row with a one-line description so users can see at a glance what's available natively. Architecture diagram updated to list the codex built-ins by name instead of 'apply_patch + shell + sandbox'. No code changes — purely docs clarification. 167 codex-runtime tests still green. * fix(codex-runtime): _spawn_background_review signature + review fork api_mode downgrade Two real bugs in the self-improvement loop integration that the previous test mocked away. Bug 1: wrong call signature The codex helper was calling self._spawn_background_review() with no args after every turn. That function actually requires: messages_snapshot=list (positional or keyword) review_memory=bool (at least one trigger must be True) review_skills=bool So the call would have raised TypeError at runtime — except the only test that exercised this path mocked _spawn_background_review entirely and just asserted spawn.called, so the wrong-arg shape never surfaced. Bug 2: review fork inherits codex_app_server api_mode The review fork is constructed with: api_mode = _parent_runtime.get('api_mode') So when the parent is codex_app_server, the review fork ALSO runs as codex_app_server. But the review fork's whole job is to call agent-loop tools (memory, skill_manage) which require Hermes' own dispatch — they short-circuit with 'must be handled by the agent loop' on the codex runtime. So the review fork would have run, decided to save something, called memory or skill_manage, and silently no-op'd. Fixed in run_agent.py:_spawn_background_review() — when the parent api_mode is '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). Also rewrote the codex helper's review wiring to match the chat_completions path: - Computes _should_review_memory in the pre-loop block (was already being computed; now passed through to the helper as an arg). - Computes _should_review_skills AFTER the codex turn returns + counters tick (line ~15432 pattern in chat_completions). - Calls _spawn_background_review(messages_snapshot=, review_memory=, review_skills=) only when at least one trigger fires. - Adds the external memory provider sync (_sync_external_memory_for_turn) that the chat_completions path runs after every turn. Tests: Replaced the broken test_background_review_invoked (which only asserted spawn.called) with three sharper tests: - test_background_review_NOT_invoked_below_threshold: single turn at default thresholds → no review fires (would have caught the original 'every turn calls spawn with no args' bug) - test_background_review_skill_trigger_fires_above_threshold: 10 tool_iterations at threshold=10 → review fires with messages_snapshot=list, review_skills=True, counter resets - test_background_review_signature_never_breaks: regression guard asserting positional args are always empty and kwargs include messages_snapshot New TestReviewForkApiModeDowngrade class: - test_codex_app_server_parent_downgrades_review_fork: drives the real _spawn_background_review function (no mock at that level), asserts the review_agent gets api_mode='codex_responses' when the parent was codex_app_server. Live-validated against real run_conversation: - Counter ticked from 0 to 5 after a 5-tool-iteration turn - _spawn_background_review fired exactly once with kwargs-only signature - review_skills=True, review_memory=False - messages_snapshot was 12 entries (5 assistant tool_calls + 5 tool results + 1 final assistant + initial system/user) - Counter reset to 0 after fire 170 codex-runtime tests, all green. Docs: added a Self-improvement loop section to the codex runtime page explaining both how the trigger logic stays equivalent and that the review fork is auto-downgraded to codex_responses for the agent-loop tools. Also clarified that apply_patch and update_plan ARE codex's built-in tools (the previous version made it sound like they were separate from 'codex's stuff' — they're not, all five tools listed in 'What tools the model actually has' section 1 are codex built-ins). * feat(codex-runtime): expose kanban tools through Hermes MCP callback Kanban workers spawn as separate hermes chat -q subprocesses that read the user's config.yaml. If model.openai_runtime: codex_app_server is set globally (which is the whole point of opt-in), every dispatched worker ALSO comes up on the codex runtime. That mostly works — codex's built-in shell + apply_patch + update_plan do the actual task work fine — but it had one critical break: the worker handoff tools (kanban_complete, kanban_block, kanban_comment, kanban_heartbeat) are Hermes-registered tools, not codex built-ins. On the codex runtime, codex builds its own tool list and these never reach the model, so the worker would do the work but not be able to report back, hanging until the dispatcher's timeout escalates it as zombie. Fix: add all 9 kanban tools to the EXPOSED_TOOLS list in the Hermes MCP callback. They dispatch statelessly through handle_function_call() just like web_search and the others — they read HERMES_KANBAN_TASK from env (set by the dispatcher), gate correctly (worker tools require the env var, orchestrator tools require it unset), and write to ~/.hermes/kanban.db. Why kanban tools work via stateless dispatch when delegate_task/memory/ session_search/todo don't: those four are listed in _AGENT_LOOP_TOOLS (model_tools.py:493) and short-circuit in handle_function_call() with 'must be handled by the agent loop' — they need to mutate AIAgent's mid-loop state. Kanban tools have no such requirement; they're pure side-effect functions against the kanban.db plus state_meta. Tools exposed: Worker handoff (require HERMES_KANBAN_TASK): kanban_complete, kanban_block, kanban_comment, kanban_heartbeat Read-only board queries: kanban_show, kanban_list Orchestrator (require HERMES_KANBAN_TASK unset): kanban_create, kanban_unblock, kanban_link Tests: - test_kanban_worker_tools_exposed: complete/block/comment/heartbeat in EXPOSED_TOOLS (regression guard for the would-hang-worker bug) - test_kanban_orchestrator_tools_exposed: create/show/list/unblock/link Docs: - New 'Workflow features' section in the docs page covering /goal, kanban, and cron behavior on this runtime - /goal: works fully via run_conversation feedback; only caveat is approval-prompt noise on long writes-heavy goals (mitigated by the default :workspace permission profile) - Kanban: enumerated which tools are reachable via the callback and why the env var propagates correctly through the codex subprocess to the MCP server subprocess - Cron: documented as 'not specifically tested' — same rules as the CLI apply since cron runs through AIAgent.run_conversation - Trade-offs table gained rows for /goal, kanban worker, kanban orchestrator 172/172 codex-runtime tests green (+2 from kanban tests). * docs(codex-runtime): wire /codex-runtime into slash-commands ref + flag aux token cost Three docs gaps caught during a final audit: 1. /codex-runtime was only in the feature docs page, not in the slash-commands reference. Added rows to both the CLI section and the Messaging section so users discover it where they'd look for slash command syntax. 2. CODEX_HOME and HERMES_KANBAN_TASK weren't in environment-variables.md. CODEX_HOME lets users redirect Codex CLI's config dir (the migration honors it). HERMES_KANBAN_TASK is set by the kanban dispatcher and propagates to the codex subprocess + the hermes-tools MCP subprocess so kanban worker tools gate correctly — documented as 'don't set manually' since it's an internal handoff. 3. Aux client behavior on this runtime. When openai_runtime= codex_app_server is on with the openai-codex provider, every aux task (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) flows through the user's ChatGPT subscription by default. This is true for the existing codex_responses path too, but it's more visible / important here because users explicitly opted in for subscription billing. Added a 'Auxiliary tasks and ChatGPT subscription token cost' section to the docs page with a YAML example showing how to override specific aux tasks to a cheaper model (typically google/gemini-3-flash-preview via OpenRouter). Also documents how the self-improvement review fork gets auto-downgraded from codex_app_server to codex_responses by the fix earlier in this PR. No code changes — pure docs. 172 codex-runtime tests still green. * docs+test(codex-runtime): pin HOME passthrough, document multi-profile + CODEX_HOME OpenClaw hit a real footgun in openclaw/openclaw#81562: when spawning codex app-server they were synthesizing a per-agent HOME alongside CODEX_HOME. That made every subprocess codex's shell tool launches (gh, git, aws, npm, gcloud, ...) see a fake $HOME and miss the user's real config files. They had to back it out in PR #81562 — keep CODEX_HOME isolation, leave HOME alone. Audit confirms Hermes' codex spawn doesn't have this problem. We do os.environ.copy() and only overlay CODEX_HOME (when provided) and RUST_LOG. HOME passes through unchanged. But it was an emergent property without a test pinning it, so adding a regression guard: test_spawn_env_preserves_HOME — confirms parent HOME survives intact in the subprocess env test_spawn_env_sets_CODEX_HOME_when_provided — confirms codex_home arg still isolates codex state correctly Docs additions: 'HOME environment variable passthrough' section — calls out the contract explicitly: CODEX_HOME isolates codex's own state, HOME stays user-real so gh/git/aws/npm/etc. find their normal config. Cites openclaw#81562 as the cautionary tale. 'Multi-profile / multi-tenant setups' section — addresses the related concern: profiles share ~/.codex/ by default. For users who want per-profile codex isolation (separate auth, separate plugins), documents the manual CODEX_HOME= approach. Explains why we DON'T auto-scope CODEX_HOME per profile: doing so would silently invalidate existing codex login state for anyone upgrading to this PR with tokens already at ~/.codex/auth.json. Opt-in is safer than surprising users. 174 codex-runtime tests (+2 from HOME guards), all green. * fix(codex-runtime): TOML control-char escapes + atomic config.toml write Two footguns caught in a final audit pass before merge. Bug 1: TOML control characters not escaped The _format_toml_value() helper escaped backslashes and double quotes but passed literal control characters (\n, \t, \r, \f, \b) through unchanged. TOML basic strings don't allow literal control characters — a path or env var containing a newline would produce invalid TOML that codex refuses to load. Realistic exposure: pathological cases like a HERMES_HOME with a trailing newline (env var concatenation accident), or a PYTHONPATH with a tab from a multi-line shell heredoc. Fix: escape all five TOML basic-string control sequences (\b \t \n \f \r) in addition to \\ and \" that we already did. Order matters — backslash must come first or the other escapes get re-escaped. Bug 2: config.toml write wasn't atomic If the python process crashed between target.mkdir() and the write_text() finishing, a half-written config.toml could be left behind. On NFS / Windows / some FUSE mounts this is a real concern; on ext4/APFS small writes are usually atomic in practice but not guaranteed. Fix: write to a tempfile.mkstemp() temp file in the same directory, then Path.replace() (atomic same-dir rename on POSIX, ReplaceFile on Windows). On rename failure, clean up the temp file so repeated failed migrations don't pile up .config.toml.* files. Tests: - test_string_with_newline_escaped — \n in value → \n in output - test_string_with_tab_escaped — \t in value → \t in output - test_string_with_other_controls_escaped — \r, \f, \b - test_windows_path_escaped_correctly — backslash doubling - test_atomic_write_no_temp_leak_on_success — no .config.toml.* left over after a successful write - test_atomic_write_cleanup_on_rename_failure — temp file removed when Path.replace raises (simulated disk full) 180 codex-runtime tests, all green (+6 from this commit). Footguns audited but NOT fixed (with rationale): - Concurrent migrations race. Two Hermes processes hitting /codex-runtime codex_app_server within seconds of each other could cause one writer to lose entries. Low probability (you'd have to enable from two surfaces simultaneously) and low impact (just re-run migration). Adding fcntl/msvcrt locking is more code than it's worth here. The atomic rename above means each individual write is consistent — only the merge step is racy. - Codex protocol version drift. We pin MIN_CODEX_VERSION=0.125 and check at runtime but don't reject too-new versions. Right call — the protocol has been stable through 0.125 → 0.130. If OpenAI breaks it later we'd see the error in test_codex_app_server_runtime on CI before users hit it. --- agent/transports/codex_app_server.py | 368 +++++++++++ agent/transports/codex_app_server_session.py | 525 +++++++++++++++ agent/transports/codex_event_projector.py | 312 +++++++++ agent/transports/hermes_tools_mcp_server.py | 225 +++++++ cli.py | 42 ++ gateway/run.py | 54 ++ hermes_cli/banner.py | 13 + hermes_cli/codex_runtime_plugin_migration.py | 598 ++++++++++++++++++ hermes_cli/codex_runtime_switch.py | 266 ++++++++ hermes_cli/commands.py | 2 + hermes_cli/runtime_provider.py | 45 +- run_agent.py | 153 ++++- .../test_codex_app_server_runtime.py | 243 +++++++ .../test_codex_app_server_session.py | 502 +++++++++++++++ .../transports/test_codex_event_projector.py | 303 +++++++++ .../test_hermes_tools_mcp_server.py | 135 ++++ .../test_codex_runtime_plugin_migration.py | 589 +++++++++++++++++ tests/hermes_cli/test_codex_runtime_switch.py | 231 +++++++ .../test_codex_app_server_integration.py | 344 ++++++++++ .../docs/reference/environment-variables.md | 2 + website/docs/reference/slash-commands.md | 2 + .../features/codex-app-server-runtime.md | 443 +++++++++++++ website/sidebars.ts | 1 + 23 files changed, 5395 insertions(+), 3 deletions(-) create mode 100644 agent/transports/codex_app_server.py create mode 100644 agent/transports/codex_app_server_session.py create mode 100644 agent/transports/codex_event_projector.py create mode 100644 agent/transports/hermes_tools_mcp_server.py create mode 100644 hermes_cli/codex_runtime_plugin_migration.py create mode 100644 hermes_cli/codex_runtime_switch.py create mode 100644 tests/agent/transports/test_codex_app_server_runtime.py create mode 100644 tests/agent/transports/test_codex_app_server_session.py create mode 100644 tests/agent/transports/test_codex_event_projector.py create mode 100644 tests/agent/transports/test_hermes_tools_mcp_server.py create mode 100644 tests/hermes_cli/test_codex_runtime_plugin_migration.py create mode 100644 tests/hermes_cli/test_codex_runtime_switch.py create mode 100644 tests/run_agent/test_codex_app_server_integration.py create mode 100644 website/docs/user-guide/features/codex-app-server-runtime.md diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py new file mode 100644 index 00000000000..b1aeaa00786 --- /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 00000000000..619cfeabfc1 --- /dev/null +++ b/agent/transports/codex_app_server_session.py @@ -0,0 +1,525 @@ +"""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.transports.codex_app_server import ( + CodexAppServerClient, + CodexAppServerError, +) +from agent.transports.codex_event_projector import CodexEventProjector + +logger = logging.getLogger(__name__) + + +# 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 + + +@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) + self._thread_id = result["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() + + # ---------- per-turn ---------- + + def run_turn( + self, + user_input: str, + *, + turn_timeout: float = 600.0, + notification_poll_timeout: float = 0.25, + ) -> TurnResult: + """Send a user message and block until turn/completed, while + forwarding server-initiated approval requests and projecting items + into Hermes' messages shape.""" + self.ensure_started() + assert self._client is not None and self._thread_id is not None + + self._interrupt_event.clear() + projector = CodexEventProjector() + result = TurnResult(thread_id=self._thread_id) + + # 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: + result.error = f"turn/start failed: {exc}" + return result + + result.turn_id = (ts.get("turn") or {}).get("id") + deadline = time.time() + turn_timeout + turn_complete = False + + while time.time() < deadline and not turn_complete: + if self._interrupt_event.is_set(): + self._issue_interrupt(result.turn_id) + result.interrupted = 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 + if proj.final_text is not None: + result.final_text = proj.final_text + self._handle_server_request(sreq) + 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 + 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 + + 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: + result.error = ( + f"turn ended status={turn_status}: " + f"{err_obj.get('message') or err_obj}" + ) + + if not turn_complete and not result.interrupted: + # Hit the deadline. Issue interrupt to stop wasted compute. + self._issue_interrupt(result.turn_id) + result.interrupted = True + result.error = result.error or f"turn timed out after {turn_timeout}s" + + 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 _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 00000000000..0a388a60cfb --- /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 00000000000..f7f8ae24887 --- /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/cli.py b/cli.py index 72ffd0b1708..5560846320d 100644 --- a/cli.py +++ b/cli.py @@ -6774,6 +6774,46 @@ class HermesCLI: 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): @@ -7454,6 +7494,8 @@ class HermesCLI: self._handle_resume_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) diff --git a/gateway/run.py b/gateway/run.py index 4946a7e6c1e..95f1d811543 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6128,6 +6128,12 @@ class GatewayRunner: 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. @@ -6462,6 +6468,9 @@ class GatewayRunner: 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) @@ -9242,6 +9251,51 @@ class GatewayRunner: 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 diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1cfb0d51f76..c4ec348ef48 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/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py new file mode 100644 index 00000000000..c00ec26bd29 --- /dev/null +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -0,0 +1,598 @@ +"""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 + 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 00000000000..b3adda12b54 --- /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 56a62c85a0a..62790bf9c14 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -120,6 +120,8 @@ COMMAND_REGISTRY: list[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/runtime_provider.py b/hermes_cli/runtime_provider.py index 1652b72034c..4ac21ea4568 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/run_agent.py b/run_agent.py index f2f3379e0d7..f9eaee85af6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1271,7 +1271,7 @@ class AIAgent: 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" @@ -4267,13 +4267,24 @@ class AIAgent: # 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), @@ -12115,6 +12126,20 @@ class AIAgent: 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() @@ -15554,6 +15579,130 @@ class AIAgent: 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") + 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), + } + + # 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/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py new file mode 100644 index 00000000000..d12ac227254 --- /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 00000000000..de0b2f60cb8 --- /dev/null +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -0,0 +1,502 @@ +"""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 + + # 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 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_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"] 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 00000000000..04980f35c61 --- /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 00000000000..3c11cb3f81d --- /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/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py new file mode 100644 index 00000000000..0274251327c --- /dev/null +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -0,0 +1,589 @@ +"""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, expose_hermes_tools=False) + 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_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 00000000000..9a01543776e --- /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/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py new file mode 100644 index 00000000000..6fc60695d2a --- /dev/null +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -0,0 +1,344 @@ +"""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" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b17036ade44..409ddf8fe35 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -515,6 +515,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/slash-commands.md b/website/docs/reference/slash-commands.md index 718da1350aa..377c31c4477 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/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md new file mode 100644 index 00000000000..5d4b068088b --- /dev/null +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -0,0 +1,443 @@ +--- +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 not yet installed in Codex CLI. Install them via `codex plugin` first. +- 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/sidebars.ts b/website/sidebars.ts index f706d2a607d..6bdd5d296a0 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', From aa1e2edd35a8e14fc02ad13b0fc4e8cecd10bbfc Mon Sep 17 00:00:00 2001 From: Mibayy Date: Wed, 13 May 2026 19:21:42 -0400 Subject: [PATCH 022/214] feat: add EVM multi-chain skill (8 chains, 14 commands) Adds a comprehensive EVM blockchain skill with 14 commands: - stats, wallet, tx, token, activity, gas, price (core queries) - compare: gas + prices across all 8 chains simultaneously - whale: scan recent blocks for large transfers (configurable min USD) - multichain: scan same wallet across all 8 chains in parallel - allowance: check dangerous ERC-20 approvals (Permit2, Uniswap, 1inch...) - decode: decode tx input data via 4byte.directory - ens: resolve ENS names <-> addresses (bidirectional) - contract: inspect contracts (proxy detection, ERC-20/721, bytecode size) Chains: Ethereum, BNB Chain, Base, Arbitrum One, Polygon, Optimism, Avalanche, zkSync Era Zero external dependencies. Python stdlib only (urllib, json, argparse, threading). Co-authored-by: Mibayy --- optional-skills/blockchain/evm/SKILL.md | 203 +++ .../blockchain/evm/scripts/evm_client.py | 1419 +++++++++++++++++ 2 files changed, 1622 insertions(+) create mode 100644 optional-skills/blockchain/evm/SKILL.md create mode 100644 optional-skills/blockchain/evm/scripts/evm_client.py diff --git a/optional-skills/blockchain/evm/SKILL.md b/optional-skills/blockchain/evm/SKILL.md new file mode 100644 index 00000000000..5990326c1ca --- /dev/null +++ b/optional-skills/blockchain/evm/SKILL.md @@ -0,0 +1,203 @@ +--- +name: evm +description: Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + CoinGecko. No API key required. +version: 1.0.0 +author: Mibayy +license: MIT +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). + +--- + +## 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 requires internet access to ensideas.com. +- Tx decode requires internet access to 4byte.directory. + +--- + +## 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 00000000000..fc2dd2142c9 --- /dev/null +++ b/optional-skills/blockchain/evm/scripts/evm_client.py @@ -0,0 +1,1419 @@ +#!/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": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + "WETH": "0x4200000000000000000000000000000000000006", + }, + "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) + +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]]]) -> List[Any]: + """Send a batch of JSON-RPC calls; returns list of results in same order.""" + url = get_rpc_url(chain) + payload = [ + {"jsonrpc": "2.0", "id": i, "method": m, "params": p} + for i, (m, p) in enumerate(calls) + ] + resp = _http_post(url, payload) + if isinstance(resp, list): + # Sort by id to preserve order + resp_sorted = sorted(resp, key=lambda x: x.get("id", 0)) + results = [] + for r in resp_sorted: + if "error" in r: + results.append(None) + else: + results.append(r.get("result")) + return results + return [resp.get("result")] + +# --------------------------------------------------------------------------- +# 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 = 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 = 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 = args.contract + 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 = 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 = 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 = KNOWN_TOKENS.get(chain, {}) + for contract, (symbol, _name) 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 = 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 = [] + + for contract, (symbol, _name) 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 = 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 = 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() From e3fc0814996d043fa9badce7da241ef02d5f905b Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 13 May 2026 19:46:22 -0400 Subject: [PATCH 023/214] feat(skills): merge blockchain/base into blockchain/evm; salvage PR #2010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvages the closed PR #2010 (Mibayy's EVM multi-chain skill) and folds the existing optional-skills/blockchain/base/ skill into it, so we ship one unified EVM skill instead of two overlapping ones. Pulled in from base/: - 8 missing Base-specific tokens (AERO, DEGEN, TOSHI, BRETT, WELL, cbETH, cbBTC, wstETH, rETH) added to KNOWN_TOKENS['base'] — base/ had 11, evm/ only had 3 (USDC/DAI/WETH). - L1 data-fee pitfall note for rollups (Base, Arbitrum, Optimism, zkSync). - Batch-size chunking in rpc_batch (Base RPC caps batches at 10 calls per JSON-RPC request; adding more known tokens tripped that limit and broke 'wallet --chain base' with a 'list index out of range' error). Ported the chunking pattern from base/_rpc_batch_chunk. Latent bugs found and fixed while smoke-testing the merge: - cmd_multichain and cmd_allowance both iterated KNOWN_TOKENS[chain] with 'for contract, (symbol, _name) in known.items()' — but the dict shape is {symbol: contract_str}, not {addr: (sym, name)}. This raised 'too many values to unpack (expected 2)' on every non-zero balance. Now iterates as 'for symbol, contract in known.items()'. - Input validation: added is_valid_address / is_valid_txhash / require_address / require_txhash helpers and wired them into cmd_wallet, cmd_tx, cmd_token, cmd_activity, cmd_allowance, cmd_decode, cmd_contract, cmd_multichain. Fails fast with exit 2 on malformed input instead of burning an RPC round-trip on garbage. Documentation: - SKILL.md now flags that this skill supersedes optional-skills/blockchain/base. - Pitfalls expanded for ENS (single-endpoint dependency on ensideas.com), tx decoding (single-endpoint dependency on 4byte.directory), and rollup L1 fees. - Regenerated website/docs/user-guide/skills/optional/blockchain/ blockchain-evm.md and removed the old blockchain-base.md page; catalog updated. Removed: - optional-skills/blockchain/base/SKILL.md - optional-skills/blockchain/base/scripts/base_client.py - website/docs/user-guide/skills/optional/blockchain/blockchain-base.md Smoke-tested live against Base mainnet: stats, price, token, wallet (vitalik.eth — 3.12 ETH + 13.88 USDC + 4.23 DAI + 0.06 WETH on Base) and allowance (ethereum, 7 unlimited approvals to Uniswap/Permit2). Original PR #2010 author: Mibayy. Original base/ skill author: youssefea. --- optional-skills/blockchain/base/SKILL.md | 232 ---- .../blockchain/base/scripts/base_client.py | 1008 ----------------- optional-skills/blockchain/evm/SKILL.md | 11 +- .../blockchain/evm/scripts/evm_client.py | 147 ++- .../docs/reference/optional-skills-catalog.md | 2 +- .../optional/blockchain/blockchain-base.md | 249 ---- .../optional/blockchain/blockchain-evm.md | 226 ++++ 7 files changed, 354 insertions(+), 1521 deletions(-) delete mode 100644 optional-skills/blockchain/base/SKILL.md delete mode 100644 optional-skills/blockchain/base/scripts/base_client.py delete mode 100644 website/docs/user-guide/skills/optional/blockchain/blockchain-base.md create mode 100644 website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md diff --git a/optional-skills/blockchain/base/SKILL.md b/optional-skills/blockchain/base/SKILL.md deleted file mode 100644 index b5c041a9714..00000000000 --- 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 cafffb49f2e..00000000000 --- 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 index 5990326c1ca..64f90e580d0 100644 --- a/optional-skills/blockchain/evm/SKILL.md +++ b/optional-skills/blockchain/evm/SKILL.md @@ -25,6 +25,11 @@ 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 @@ -188,8 +193,10 @@ Shows gwei price + USD cost for: transfer, ERC-20 transfer, approve, swap, NFT m - `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 requires internet access to ensideas.com. -- Tx decode requires internet access to 4byte.directory. +- 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). --- diff --git a/optional-skills/blockchain/evm/scripts/evm_client.py b/optional-skills/blockchain/evm/scripts/evm_client.py index fc2dd2142c9..31da48fd192 100644 --- a/optional-skills/blockchain/evm/scripts/evm_client.py +++ b/optional-skills/blockchain/evm/scripts/evm_client.py @@ -137,9 +137,21 @@ KNOWN_TOKENS: Dict[str, Dict[str, str]] = { "DOGE": "0xbA2aE424d960c26247Dd6c32edC70B295c744C43", }, "base": { - "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", - "WETH": "0x4200000000000000000000000000000000000006", + # 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", @@ -226,9 +238,73 @@ def hex_to_int(h: str) -> int: 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 @@ -326,25 +402,36 @@ def rpc_call(chain: str, method: str, params: List[Any], req_id: int = 1) -> Any raise RuntimeError(f"RPC error: {resp['error']}") return resp.get("result") -def rpc_batch(chain: str, calls: List[Tuple[str, List[Any]]]) -> List[Any]: - """Send a batch of JSON-RPC calls; returns list of results in same order.""" +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) - payload = [ + + # 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) ] - resp = _http_post(url, payload) - if isinstance(resp, list): - # Sort by id to preserve order - resp_sorted = sorted(resp, key=lambda x: x.get("id", 0)) - results = [] - for r in resp_sorted: - if "error" in r: - results.append(None) - else: - results.append(r.get("result")) - return results - return [resp.get("result")] + + 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) @@ -556,7 +643,7 @@ def cmd_stats(args: argparse.Namespace) -> None: def cmd_wallet(args: argparse.Namespace) -> None: - address = args.address + address = require_address(args.address) chain = args.chain limit = args.limit no_prices = args.no_prices @@ -633,7 +720,7 @@ def cmd_wallet(args: argparse.Namespace) -> None: def cmd_tx(args: argparse.Namespace) -> None: - tx_hash = args.hash + tx_hash = require_txhash(args.hash) chain = args.chain cfg = CHAINS[chain] @@ -702,7 +789,7 @@ def cmd_tx(args: argparse.Namespace) -> None: def cmd_token(args: argparse.Namespace) -> None: - contract = args.contract + contract = require_address(args.contract, field="contract address") chain = args.chain # Batch all ERC-20 metadata calls @@ -744,7 +831,7 @@ def cmd_token(args: argparse.Namespace) -> None: def cmd_activity(args: argparse.Namespace) -> None: - address = args.address + address = require_address(args.address) chain = args.chain limit = args.limit cfg = CHAINS[chain] @@ -1000,7 +1087,7 @@ def cmd_multichain(args: argparse.Namespace) -> None: """Scan same wallet across all 8 chains simultaneously.""" import threading - address = args.address + address = require_address(args.address) results: Dict[str, Any] = {} lock = threading.Lock() @@ -1019,9 +1106,10 @@ def cmd_multichain(args: argparse.Namespace) -> None: "tokens": [], "total_usd": native_usd or 0.0, } - # Check known tokens for this chain + # Check known tokens for this chain. + # KNOWN_TOKENS[chain] maps {symbol: contract_address}, not {addr: (sym, name)}. known = KNOWN_TOKENS.get(chain, {}) - for contract, (symbol, _name) in known.items(): + for symbol, contract in known.items(): raw = eth_call_erc20(chain, contract, "balanceOf(address)", address) if not raw or raw == "0x": continue @@ -1067,7 +1155,7 @@ def cmd_multichain(args: argparse.Namespace) -> None: def cmd_allowance(args: argparse.Namespace) -> None: """Check dangerous ERC-20 approvals for a wallet (known spenders).""" - address = args.address + address = require_address(args.address) chain = args.chain # Well-known spender contracts (DEXes, bridges, etc.) @@ -1085,7 +1173,8 @@ def cmd_allowance(args: argparse.Namespace) -> None: known = KNOWN_TOKENS.get(chain, {}) approvals = [] - for contract, (symbol, _name) in known.items(): + # 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) @@ -1127,7 +1216,7 @@ def cmd_allowance(args: argparse.Namespace) -> None: def cmd_decode(args: argparse.Namespace) -> None: """Decode transaction input data using 4byte.directory.""" chain = args.chain - tx_hash = args.hash + tx_hash = require_txhash(args.hash) tx = rpc_call(chain, "eth_getTransactionByHash", [tx_hash]) if not tx: @@ -1212,7 +1301,7 @@ def cmd_ens(args: argparse.Namespace) -> None: def cmd_contract(args: argparse.Namespace) -> None: """Inspect a smart contract: bytecode size, proxy detection, creation info.""" chain = args.chain - address = args.address + address = require_address(args.address) # Get bytecode code_hex = rpc_call(chain, "eth_getCode", [address, "latest"]) diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 1cedabe4ff2..840aebc8488 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) | Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + CoinGecko. No API key required. | | [**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/user-guide/skills/optional/blockchain/blockchain-base.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-base.md deleted file mode 100644 index a9d9cb8c6c1..00000000000 --- 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 00000000000..1b481b3d9b3 --- /dev/null +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md @@ -0,0 +1,226 @@ +--- +title: "Evm" +sidebar_label: "Evm" +description: "Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, a..." +--- + +{/* 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 + +Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + CoinGecko. No API key required. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/blockchain/evm` | +| Path | `optional-skills/blockchain/evm` | +| Version | `1.0.0` | +| Author | Mibayy | +| License | MIT | +| 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 +``` From 66c70966cd2ae3c13bacf4d57522cf86f469b9d3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 17:16:24 -0700 Subject: [PATCH 024/214] chore(skills/evm): tighten SKILL.md to modern format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - description ≤60 chars (was 346) - platforms: [linux, macos, windows] — script is pure stdlib (urllib, json, argparse), no POSIX-only primitives - author: credit @Mibayy + @youssefea + @ethernet8023 + Hermes Agent (was just Mibayy) - regenerated auto-gen docs page --- optional-skills/blockchain/evm/SKILL.md | 5 +++-- website/docs/reference/optional-skills-catalog.md | 2 +- .../skills/optional/blockchain/blockchain-evm.md | 9 +++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/optional-skills/blockchain/evm/SKILL.md b/optional-skills/blockchain/evm/SKILL.md index 64f90e580d0..989d59509f3 100644 --- a/optional-skills/blockchain/evm/SKILL.md +++ b/optional-skills/blockchain/evm/SKILL.md @@ -1,9 +1,10 @@ --- name: evm -description: Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + CoinGecko. No API key required. +description: "Read-only EVM client: wallets, tokens, gas across 8 chains." version: 1.0.0 -author: Mibayy +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] diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 840aebc8488..40f9c5539c8 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 | |-------|-------------| -| [**evm**](/docs/user-guide/skills/optional/blockchain/blockchain-evm) | Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + 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/user-guide/skills/optional/blockchain/blockchain-evm.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md index 1b481b3d9b3..01006870ee4 100644 --- a/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md @@ -1,14 +1,14 @@ --- -title: "Evm" +title: "Evm — Read-only EVM client: wallets, tokens, gas across 8 chains" sidebar_label: "Evm" -description: "Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, a..." +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 -Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, transactions, gas tracker, whale detection, multi-chain scan, ENS resolution, allowance checker, contract inspection, and tx decoder. Supports Ethereum, BNB Chain, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync. Uses public RPCs + CoinGecko. No API key required. +Read-only EVM client: wallets, tokens, gas across 8 chains. ## Skill metadata @@ -17,8 +17,9 @@ Query EVM blockchain data across 8 chains — wallet portfolios, ERC-20 tokens, | Source | Optional — install with `hermes skills install official/blockchain/evm` | | Path | `optional-skills/blockchain/evm` | | Version | `1.0.0` | -| Author | Mibayy | +| 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) | From ef98e3f9e60b6e4066050bd8c6d13409f9fedf5d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 17:19:50 -0700 Subject: [PATCH 025/214] docs: close in-tree memory plugins to new PRs and codify skill standards (#25302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md and CONTRIBUTING.md both now state: 1. No new memory providers in the repo. The set under plugins/memory/ (honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb) is closed. New backends ship as standalone plugin repos that users install into ~/.hermes/plugins/ via the same MemoryProvider ABC, discovery path, and hermes memory setup integration. PRs adding a new plugins/memory// directory get closed with a pointer to publish as their own repo. 2. Skill authoring standards (hardline) — applies to all new or modernized skills (bundled, optional, contributed): - description <= 60 chars, one sentence, ends with period, no marketing words, no name repetition (verification snippet included) - tools referenced in SKILL.md prose must be native Hermes tools or MCP servers the skill expects — no grep/cat/sed/find etc. when search_files/read_file/patch already cover them - platforms: gating audited against actual POSIX-only primitives - author credits the human contributor first, not 'Hermes Agent' - SKILL.md uses modern section order with line targets - scripts/references/templates layout for non-trivial logic - tests at tests/skills/test__skill.py, stdlib + mock only - .env.example edits isolated to a delimited block CONTRIBUTING.md includes a good/bad description example and a 'don't say / say' table mapping shell utilities to native tools. AGENTS.md points the agent at references/new-skill-pr-salvage.md for the full salvage checklist. --- AGENTS.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d8ba934c521..da9f903eefb 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 56f0c8ff016..4bbc3c67c70 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`). From dd5a9502e389781275a1649716f6d3ca4ae98c51 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 17:31:54 -0700 Subject: [PATCH 026/214] fix(tools-config): write video_gen.provider on Reconfigure tool path (#25307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_reconfigure_provider()` handled `image_gen_plugin_name` in both branches (no-env-vars early return and post-env-vars) but never mirrored the same handling for `video_gen_plugin_name`. The first-time `_configure_provider()` path correctly routes to `_select_plugin_video_gen_provider()`; reconfigure forgot to. Repro: 1. Enable video_gen in `hermes tools` → Configure for All Platforms. 2. Go back into `hermes tools` → Reconfigure tool → Video Generation. 3. Pick xAI (with XAI_API_KEY already set). 4. Hit Enter at the "keep current key?" prompt. Expected: `video_gen.provider: xai` written to config.yaml. Actual: function returns silently; no `video_gen:` block ever written; `video_generate` tool fails with "No video generation backend is configured." Fix: add the missing `video_gen_plugin_name` branch in both code paths of `_reconfigure_provider()`, mirroring the existing `image_gen_plugin_name` handling and the first-time configure logic. Tests: `tests/hermes_cli/test_video_gen_picker.py` covers both branches (env-vars-set keep-current and no-env-vars paths). --- hermes_cli/tools_config.py | 11 ++ tests/hermes_cli/test_video_gen_picker.py | 148 ++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/hermes_cli/test_video_gen_picker.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 03ffa800f9c..108dfe9dd93 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -2469,6 +2469,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: @@ -2501,6 +2506,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/tests/hermes_cli/test_video_gen_picker.py b/tests/hermes_cli/test_video_gen_picker.py new file mode 100644 index 00000000000..85350947c96 --- /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 From 3c106c89a1759b767e6676b16d45daf4f7640862 Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Wed, 13 May 2026 17:29:43 -0700 Subject: [PATCH 027/214] test(ci): stabilize shared optional dependency baselines --- tests/agent/test_bedrock_adapter.py | 20 ++++- tests/agent/test_bedrock_integration.py | 26 ++++--- tests/gateway/test_dingtalk.py | 77 ++++++++++++++++++- tests/gateway/test_feishu_bot_admission.py | 31 +++++++- tests/gateway/test_matrix.py | 16 ++-- tests/hermes_cli/test_bedrock_model_picker.py | 19 ++++- tests/run_agent/test_switch_model_context.py | 15 ++-- tests/tools/test_registry.py | 46 +++-------- tests/tools/test_transcription.py | 11 ++- tests/tools/test_tts_kittentts.py | 3 +- 10 files changed, 194 insertions(+), 70 deletions(-) diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index 6c51288461e..04c0913f289 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -12,12 +12,24 @@ Covers: 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 @@ class TestResolveBedrocRegion: 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 @@ class TestResolveBedrocRegion: 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 954075ab722..a5ab3563381 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -253,20 +253,24 @@ class TestErrorClassifierBedrock: # --------------------------------------------------------------------------- class TestPackaging: - """Verify bedrock optional dependency is declared.""" + """Verify Bedrock remains a declared lazy optional dependency.""" + + @staticmethod + def _optional_dependencies(): + import tomllib + from pathlib import Path + + content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() + return tomllib.loads(content)["project"]["optional-dependencies"] def test_bedrock_extra_exists(self): - import configparser - 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 + extras = self._optional_dependencies() + assert "bedrock" in extras + assert any(dep.startswith("boto3==") for dep in extras["bedrock"]) - 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 + 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/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index aceb079b4b8..570eb997ba0 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -10,6 +10,80 @@ import pytest 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 @@ from gateway.config import Platform, PlatformConfig 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_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 83b70238430..5ccc386d83e 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 bd95fb6136f..c329441531d 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -716,8 +716,10 @@ class TestMatrixModuleImport: "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 @@ class TestMatrixRequirements: 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 @@ class TestMatrixRequirements: 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 @@ class TestMatrixRequirements: 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 @@ class TestMatrixRequirements: 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/hermes_cli/test_bedrock_model_picker.py b/tests/hermes_cli/test_bedrock_model_picker.py index 3b2c4d5dc7b..70335be2186 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/tests/hermes_cli/test_bedrock_model_picker.py @@ -17,6 +17,8 @@ All Bedrock API calls are mocked — no real AWS credentials needed. """ import os +from contextlib import contextmanager +from types import ModuleType from unittest.mock import MagicMock, patch import pytest @@ -26,6 +28,19 @@ import pytest # 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 @@ class TestBedrockRegionRouting: 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 @@ class TestBedrockRegionRouting: 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/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 8b04a73262b..c925a508915 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/tools/test_registry.py b/tests/tools/test_registry.py index 0023b5c9bd2..7ad5fff4f16 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -5,7 +5,7 @@ import threading 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 @@ class TestCheckFnExceptionHandling: 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_transcription.py b/tests/tools/test_transcription.py index e56577ca556..32f0ad48798 100644 --- a/tests/tools/test_transcription.py +++ b/tests/tools/test_transcription.py @@ -8,11 +8,16 @@ import json 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 @@ class TestTranscribeLocal: 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 @@ class TestNormalizeLocalModel: }), \ 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_tts_kittentts.py b/tests/tools/test_tts_kittentts.py index ab841f59f4a..f4918df4496 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 From 3f13d78088d1a9a35eb542f29b16d11d534066e7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 18:40:14 -0700 Subject: [PATCH 028/214] perf(tools): cache get_nous_auth_status() and load_env() to fix slow `hermes tools` menus (#25341) `hermes tools` -> "All Platforms" took ~14s to render the checklist because building the toolset labels called `get_nous_auth_status()` ~31x transitively (`_toolset_has_keys` -> `_visible_providers` -> `get_nous_subscription_features` -> `managed_nous_tools_enabled`). Each call did a synchronous OAuth refresh POST to portal.nousresearch.com (~350ms even on the failure path), so one menu paint burned >13s of HTTP and 31 single-use Nous refresh tokens. Secondary hot spot: every `get_env_value()` re-read and re-sanitised the entire .env file. 116 reads with O(lines x known-keys) scanning added ~300ms of CPU per render. Fix is two process-level caches, both mtime-keyed so login/logout/edit invalidate naturally: * `hermes_cli/auth.py`: memoise `get_nous_auth_status()` for 15s keyed on auth.json mtime. Splits `_compute_nous_auth_status()` as the uncached impl. Adds `invalidate_nous_auth_status_cache()`. * `hermes_cli/config.py`: memoise `load_env()` keyed on .env (path, mtime, size). Adds `invalidate_env_cache()`, wired into `save_env_value`, `remove_env_value`, and the sanitize-on-load writer so writers don't return stale dicts on same-second writes. Before/after on Teknium's box (real HERMES_HOME, no Nous login): * "All Platforms" cold path: ~13,874ms -> ~691ms label-build * Warm re-open within the same process: ~122ms -> ~17ms Side benefit: stops burning a Nous refresh token on every menu paint, which was risking the portal's reuse-detection revocation logic. --- hermes_cli/auth.py | 60 +++++- hermes_cli/config.py | 56 ++++- tests/hermes_cli/test_env_load_cache.py | 193 ++++++++++++++++++ .../hermes_cli/test_nous_auth_status_cache.py | 144 +++++++++++++ 4 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_env_load_cache.py create mode 100644 tests/hermes_cli/test_nous_auth_status_cache.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 88acd1cd438..2dcf6a03b45 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -35,7 +35,7 @@ from dataclasses import dataclass, field 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 _snapshot_nous_pool_status() -> Dict[str, Any]: 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/config.py b/hermes_cli/config.py index a94f7e2d527..6fd772e84ca 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4319,10 +4319,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 +4362,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 +4491,7 @@ def sanitize_env_file() -> int: pass raise _secure_file(env_path) + invalidate_env_cache() return fixes @@ -4555,6 +4603,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 +4659,7 @@ def remove_env_value(key: str) -> bool: _secure_file(env_path) os.environ.pop(key, None) + invalidate_env_cache() return found 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 00000000000..f898208c46a --- /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_nous_auth_status_cache.py b/tests/hermes_cli/test_nous_auth_status_cache.py new file mode 100644 index 00000000000..5f0e733fb4c --- /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() From 6122a79aab45041d8b7c8d775f95be3ac6ce579f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 18:58:14 -0700 Subject: [PATCH 029/214] feat(slack): support !cmd as alternate prefix for slash commands in threads (#25355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack platform-blocks native slash commands inside thread replies ("/queue is not supported in threads. Sorry!") and there is no app-side setting to re-enable them. As a workaround, rewrite a leading '!' to '/' for any known gateway command before downstream processing — so '!queue', '!stop', '!model gpt-5.4' etc. work inside Slack threads (and anywhere else). Only the first token is checked against is_gateway_known_command(), so casual messages like '!nice work' pass through to the agent unchanged. Downstream pipeline (MessageType.COMMAND tagging, gateway dispatcher, thread reply routing) is unchanged. Adds 6 tests covering rewrite, args preservation, thread routing, casual-message passthrough, '@bot' suffix, and plain '/' still-works. --- gateway/platforms/slack.py | 20 +++++ tests/gateway/test_slack.py | 88 ++++++++++++++++++++++ website/docs/user-guide/messaging/slack.md | 16 ++++ 3 files changed, 124 insertions(+) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 432b01d80bf..53d7c57da40 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1799,6 +1799,26 @@ class SlackAdapter(BasePlatformAdapter): 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/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 478370d8c41..bc09279eec4 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -691,10 +691,98 @@ class TestSendVideo: 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/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index f5b29c9d132..b5a64fb84f4 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 From c875c0dc117f737d2f407ad9caea3052d13b5c6c Mon Sep 17 00:00:00 2001 From: pty819 <14341805+pty819@users.noreply.github.com> Date: Thu, 7 May 2026 02:49:46 +0800 Subject: [PATCH 030/214] fix(tts): update MiniMax default model to speech-02 and correct API endpoint The MiniMax TTS defaults were outdated: - DEFAULT_MINIMAX_MODEL was 'speech-01' but MiniMax now uses 'speech-02' - DEFAULT_MINIMAX_BASE_URL was 'https://api.minimax.chat/v1/text_to_speech' which no longer works; the correct endpoint is 'https://api.minimaxi.com/v1/t2a_v2' Users who configured tts.provider: minimax were getting model-not-supported errors because the hardcoded defaults did not match available API permissions. --- tools/tts_tool.py | 120 ++++++++++++++++++++++++++++++---------------- 1 file changed, 79 insertions(+), 41 deletions(-) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 1ea3ba21c63..a0ea52a1d01 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -159,9 +159,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper" 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_MODEL = "speech-02" DEFAULT_MINIMAX_VOICE_ID = "female-shaonv" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech" +DEFAULT_MINIMAX_BASE_URL = "https://api.minimaxi.com/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,94 @@ 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) headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", } + # Detect endpoint from URL + is_t2a_v2 = "t2a_v2" in base_url + + 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, + } + response = requests.post(base_url, json=payload, headers=headers, timeout=60) - content_type = response.headers.get("Content-Type", "") + if is_t2a_v2: + # t2a_v2 returns JSON with hex-encoded audio + result = response.json() + base_resp = result.get("base_resp", {}) + status_code = base_resp.get("status_code", -1) - if "audio/" in content_type: - # New API: returns raw audio directly + 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") + + audio_bytes = bytes.fromhex(hex_audio) with open(output_path, "wb") as f: - f.write(response.content) + f.write(audio_bytes) return output_path - # Legacy / fallback: try parsing as JSON with hex-encoded audio - try: - 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)" - ) + else: + # text_to_speech returns raw audio directly + content_type = response.headers.get("Content-Type", "") - base_resp = result.get("base_resp", {}) - status_code = base_resp.get("status_code", -1) + if "audio/" in content_type: + with open(output_path, "wb") as f: + f.write(response.content) + return output_path - 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}") + # 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)" + ) - hex_audio = result.get("data", {}).get("audio", "") - if not hex_audio: - raise RuntimeError("MiniMax TTS returned empty audio data") - - # Legacy: hex-encoded audio - audio_bytes = bytes.fromhex(hex_audio) - - with open(output_path, "wb") as f: - f.write(audio_bytes) - - return output_path + raise RuntimeError("MiniMax TTS returned no audio data") # =========================================================================== From 7f08cb59417b19d70a7bc82e05f7bbedeb8a4f82 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:01:41 -0700 Subject: [PATCH 031/214] fix(tts): align MiniMax TTS defaults with current API and add GroupId support Follow-up on @pty819's t2a_v2 endpoint fix: - Default model: speech-02 -> speech-02-hd (bare 'speech-02' is not in the supported enum; t2a_v2 rejects it with 400). Official enum: speech-01-hd, speech-01-turbo, speech-02-hd, speech-02-turbo, speech-2.6-hd/turbo, speech-2.8-hd/turbo. - Default voice: female-shaonv -> English_expressive_narrator. The legacy speech-01-series short ID doesn't resolve cleanly on the speech-02+ models that are now the default. - Default base URL: api.minimaxi.com -> api.minimax.io (matches the canonical host in the published docs; api-uw.minimax.io is the reduced-latency alt). - Add GroupId support via tts.minimax.group_id config or MINIMAX_GROUP_ID env var. Some MiniMax accounts scope TTS requests by group; without it, requests 401. Only appended when not already in the user's base_url. Tests rewritten to cover both the default t2a_v2 path (hex-encoded audio in JSON, nested voice_setting/audio_setting) and the legacy text_to_speech path (raw audio bytes, flat payload). Adds coverage for GroupId config/env wiring and error surfacing. Also adds AUTHOR_MAP entry for pty819's GitHub-noreply email. --- scripts/release.py | 1 + tests/tools/test_tts_speed.py | 130 +++++++++++++++++++++++++++++----- tools/tts_tool.py | 18 ++++- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index afe864d2e94..8983408f11a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -223,6 +223,7 @@ AUTHOR_MAP = { "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", diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py index 8a3866aaa8a..d9274bb84d7 100644 --- a/tests/tools/test_tts_speed.py +++ b/tests/tools/test_tts_speed.py @@ -8,7 +8,12 @@ import pytest @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 @@ class TestOpenaiTtsSpeed: # --------------------------------------------------------------------------- -# 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 _run(self, tts_config, tmp_path, monkeypatch): - monkeypatch.setenv("MINIMAX_API_KEY", "test-key") - 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: +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_simple_payload(self, tmp_path, monkeypatch): - """New API uses flat payload with model, text, voice_id.""" + 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" + 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"), cfg) + return mock_post, output + + 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/tools/tts_tool.py b/tools/tts_tool.py index a0ea52a1d01..9f0d272dac0 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -159,9 +159,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper" 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-02" -DEFAULT_MINIMAX_VOICE_ID = "female-shaonv" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimaxi.com/v1/t2a_v2" +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" @@ -991,6 +991,18 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any 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}", From 52521c937a50d94493374c1c6d8fea1a39f96f5c Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 13 May 2026 18:37:06 -0600 Subject: [PATCH 032/214] fix(install): skip browser download when system chromium exists --- scripts/install.sh | 170 +++++++++++++++------ tests/test_install_sh_browser_install.py | 35 +++++ tests/tools/test_browser_chromium_check.py | 11 +- tools/browser_tool.py | 11 +- 4 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 tests/test_install_sh_browser_install.py diff --git a/scripts/install.sh b/scripts/install.sh index 72cc81637da..25d566c9881 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -64,6 +64,7 @@ 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 @@ -1421,6 +1422,7 @@ copy_config_templates() { else log_info "~/.hermes/.env already exists, keeping it" fi + configure_browser_env_from_system_browser # Create config.yaml at ~/.hermes/config.yaml (top level, easy to find) if [ ! -f "$HERMES_HOME/config.yaml" ]; then @@ -1469,6 +1471,68 @@ SOUL_EOF fi } +find_system_browser() { + # Prefer a user-specified browser path, then common Linux/macOS Chrome and + # Chromium command names. Arch-family distributions commonly ship plain + # `chromium`, while Debian-family systems often use `chromium-browser`. + if [ -n "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ]; then + if [ -x "$AGENT_BROWSER_EXECUTABLE_PATH" ]; then + echo "$AGENT_BROWSER_EXECUTABLE_PATH" + return 0 + fi + if command -v "$AGENT_BROWSER_EXECUTABLE_PATH" >/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)" @@ -1495,57 +1559,63 @@ install_node_deps() { # For Arch/Manjaro we install the system libs via pacman first. # Other systems must install Chromium dependencies manually. 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 - 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" + 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) + 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" && 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" + } + ;; + 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 - 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 + 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 log_success "Browser engine setup complete" fi diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py new file mode 100644 index 00000000000..4e1908e4294 --- /dev/null +++ b/tests/test_install_sh_browser_install.py @@ -0,0 +1,35 @@ +"""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 + assert "run_browser_install_with_timeout 600 npx playwright install --with-deps chromium" in text diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index ef3fca4352f..760dfa5d230 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -41,6 +41,16 @@ class TestChromiumSearchRoots: 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/tools/browser_tool.py b/tools/browser_tool.py index 40ba7cab25c..79a6c7e6172 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -3381,8 +3381,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 +3405,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 From d898e0eb7f2a0df757113fafbcc52d17a1a36fd9 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Thu, 14 May 2026 10:41:46 +0530 Subject: [PATCH 033/214] fix(gateway): complete lazy-install rebind for slack/feishu/matrix + add ensure_and_bind helper (#25038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #25028. The lazy-install hooks added in #25014 installed packages correctly but failed to rebind module-level globals after install: - Slack: missing aiohttp rebind → NameError on file uploads - Feishu: none of the ~25 lark_oapi symbols rebound → TypeError on adapter instantiation - Matrix: mautrix.types enums stayed as stubs → mismatched values at runtime Introduces tools.lazy_deps.ensure_and_bind() — a DRY helper that combines ensure() + importer-callable + globals().update(). This eliminates the error-prone pattern of manually listing every global that needs updating after lazy-install. Each platform adapter now defines a single _import() function returning all bindings. Also fixes: pyproject.toml [slack] extra was missing aiohttp (needed by slack-bolt's async path). --- gateway/platforms/feishu.py | 66 +++++++++++++++++++++++++++++-------- gateway/platforms/matrix.py | 28 ++++++++++++---- gateway/platforms/slack.py | 35 ++++++++++---------- pyproject.toml | 2 +- tools/lazy_deps.py | 55 ++++++++++++++++++++++++++++++- uv.lock | 2 ++ 6 files changed, 149 insertions(+), 39 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index e7be062e84c..6481c8fa31a 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -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 12075e67837..95dc73201c5 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/slack.py b/gateway/platforms/slack.py index 53d7c57da40..ca34ab4acac 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: diff --git a/pyproject.toml b/pyproject.toml index 118f30c501c..a880bcb05bf 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/tools/lazy_deps.py b/tools/lazy_deps.py index 6e298c23320..60883663439 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -59,7 +59,7 @@ import subprocess import sys from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional logger = logging.getLogger(__name__) @@ -440,3 +440,56 @@ 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 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/uv.lock b/uv.lock index 713cd588fd6..a519cc2b194 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" }, From 3a30c605b3d7526d412eb4c90fd7778581370a34 Mon Sep 17 00:00:00 2001 From: WorldWriter <30366221+WorldWriter@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:04:56 +0800 Subject: [PATCH 034/214] feat(plugins): add thread-local tool whitelist to pre_tool_call gate Adds set_thread_tool_whitelist / clear_thread_tool_whitelist to hermes_cli/plugins.py. When set on the current thread, restricts which tools can pass through get_pre_tool_call_block_message; non-whitelisted tools are blocked with a configurable deny message. Mirrors the per-thread approval-callback pattern already used by set_approval_callback (tools/terminal_tool.py:190). Used by _spawn_background_review to deny non-memory/non-skill tools at runtime while inheriting the parent agent's full tools schema for prefix-cache parity (see follow-up commit). Tests cover allow / deny / clear / cross-thread isolation. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins.py | 20 +++++++ tests/hermes_cli/test_plugins.py | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index fd785ba0258..1aa7075f6f6 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1339,6 +1339,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]], @@ -1357,6 +1372,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/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 959b2246832..7be43a236f2 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -538,6 +538,95 @@ class TestPreToolCallBlocking: 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 ────────────────────────────────────────────────────── From 5fe0672260e65a6ff664f5905eb69a6fca674707 Mon Sep 17 00:00:00 2001 From: WorldWriter <30366221+WorldWriter@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:05:23 +0800 Subject: [PATCH 035/214] fix(memory): hit prefix cache in background review fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background review fork is supposed to hit Anthropic's prefix cache on the parent's messages_snapshot, but currently doesn't (cache_read=0 on every fork). Two root causes, fixed in this commit: 1. System prompt is rebuilt at fork time. _cached_system_prompt starts as None, so run_conversation calls _build_system_prompt, which embeds a minute-precision "Conversation started: ..." timestamp. Reviews fire 10+ turns after session start, so the minute differs from main's, producing a 1-character diff that invalidates the byte-exact cache key. Fix: inherit the parent's _cached_system_prompt directly (same idea as #17089, which was self-closed for only fixing this half). 2. Tools schema was narrowed via enabled_toolsets=["memory","skills"] for safety. Anthropic's cache key includes `tools`, which sits before `system` in the cache hierarchy, so even byte-identical `system` won't hit when `tools` differs from main's full set. Fix: drop the schema-level restriction so `tools` matches main, and deny non-whitelisted tools at runtime via the existing get_pre_tool_call_block_message gate (hermes_cli/plugins.py:1085, already called at all three dispatch sites). Install/clear a thread- local whitelist (added in the previous commit) on the daemon thread. Append a soft constraint to the review prompt so the model knows. Real E2E on Sonnet 4.5 (12-tool task + auto-triggered review): - Per review-call cost: $0.331 → $0.035 (~89% reduction) - End-to-end per run: $0.848 → $0.629 (~26% reduction) - Review fork cache_create / cache_read: 88,385 / 0 → 1,234 / 94,404 Co-Authored-By: Claude Opus 4.7 (1M context) --- run_agent.py | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/run_agent.py b/run_agent.py index f9eaee85af6..ecaceaa78d7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4289,7 +4289,6 @@ class AIAgent: 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" @@ -4306,12 +4305,51 @@ class AIAgent: # _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 - review_agent.run_conversation( - user_message=prompt, - conversation_history=messages_snapshot, + from model_tools import get_tool_definitions + from hermes_cli.plugins import ( + set_thread_tool_whitelist, + clear_thread_tool_whitelist, ) + 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() + # Scan the review agent's messages for successful tool actions # and surface a compact summary to the user. Tool messages # already present in messages_snapshot must be skipped, since From 95d074cdb205e6e80de660dc547af8aff086259b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 21:56:48 -0700 Subject: [PATCH 036/214] chore(release): map WorldWriter for PR #17276 salvage --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 8983408f11a..471c6b0ae68 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -41,6 +41,8 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" AUTHOR_MAP = { # teknium (multiple emails) "teknium1@gmail.com": "teknium1", + "30366221+WorldWriter@users.noreply.github.com": "WorldWriter", + "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", From 07349ce4df74a98678070255f46fcee0f1718ba0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:04:49 -0700 Subject: [PATCH 037/214] fix(memory): pin session_start + session_id on background review fork Belt-and-suspenders complement to the cached-system-prompt inheritance: pin session_start and 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 already short-circuits the normal rebuild path, but these pins guarantee parity even if a future code path bypasses the cache. Idea from simpolism's reference PR #25427 for #25322. Co-Authored-By: simpolism <32201324+simpolism@users.noreply.github.com> --- run_agent.py | 9 +++++++++ scripts/release.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/run_agent.py b/run_agent.py index ecaceaa78d7..53177931b81 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4316,6 +4316,15 @@ class AIAgent: # 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 ( diff --git a/scripts/release.py b/scripts/release.py index 471c6b0ae68..9932dfd55ee 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -43,6 +43,8 @@ AUTHOR_MAP = { "teknium1@gmail.com": "teknium1", "30366221+WorldWriter@users.noreply.github.com": "WorldWriter", "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", + "32201324+simpolism@users.noreply.github.com": "simpolism", + "simpolism@gmail.com": "simpolism", "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", From 8c6b0c9ecdabd67cb22b34e5c294e3f0aba47bbc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:06:31 -0700 Subject: [PATCH 038/214] test(memory): cover cache-parity + runtime whitelist on background review fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_background_review_does_not_narrow_toolset_schema: review fork must NOT pass enabled_toolsets to AIAgent (full parent schema = matching Anthropic cache key on the 'tools' field). - test_background_review_installs_thread_local_whitelist: the runtime whitelist that replaces schema-level narrowing must contain memory + skills tools and exclude terminal / send_message / delegate_task / web_search / execute_code. - test_review_fork_inherits_parent_cached_system_prompt: new test for PR #17276's first root cause — the fork's _cached_system_prompt must equal the parent's byte-for-byte. - test_review_fork_pins_session_start_and_session_id: defensive belt-and- suspenders for the cached-prompt inheritance. Inverted the original test_background_review_agent_uses_restricted_toolsets (which asserted the schema-level narrowing) — that narrowing was the direct cause of #25322's cache miss, and the runtime whitelist replaces its safety claim without breaking cache parity. Refs #25322, #15204, PR #17276. --- tests/run_agent/test_background_review.py | 3 + .../test_background_review_cache_parity.py | 185 ++++++++++++++++++ ...t_background_review_toolset_restriction.py | 94 ++++++++- 3 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 tests/run_agent/test_background_review_cache_parity.py diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py index 8f2a61b7504..2e79b10b346 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 00000000000..ac91cf75f7a --- /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 d1193dc6f91..7eea665b86f 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. +"""Tests that the background review agent restricts tools at runtime, not at schema time. -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.). +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 @@ class _SyncThread: 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 test_background_review_agent_uses_restricted_toolsets(): ) 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"])) From e90508103cac1d3b27f0455d29fbda17c49ead92 Mon Sep 17 00:00:00 2001 From: snav Date: Wed, 13 May 2026 19:56:31 -0400 Subject: [PATCH 039/214] chore(release): map jake@nousresearch.com and simpolism@gmail.com to @simpolism Both addresses route to the same GitHub account (@simpolism / snav). Adding the mappings here keeps release notes from showing two separate contributors for what is one person's work, and unblocks subsequent PRs from this account that would otherwise each need their own scripts/release.py noise. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 9932dfd55ee..714a44a9d3c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ AUTHOR_MAP = { "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", "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", From f7ad2f1115eb370798abe1aca4802d96fe889795 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:20:25 -0700 Subject: [PATCH 040/214] feat(dashboard): hide token/cost analytics behind config flag (default off) (#25438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Analytics page and the token/cost surfaces on the Models page show local debug estimates only. They count input+output (and a bar viz adds cache_read+reasoning, missing cache_write entirely) from successful main-agent responses that returned a usable usage block. Excluded silently: - All auxiliary calls — context compression, title generation, vision, session search, web extract, smart approvals, MCP routing, plugin LLM access (13 production call sites bypass update_token_counts) - Provider-side retries, fallback attempts - Any call whose usage block didn't come back - cache_write_tokens (column exists in sessions table but not returned by /api/analytics/models) Real-world impact: a user on Kimi K2.6 saw 150K local vs 27M on the OpenRouter side over the same window. Precise-looking numbers next to provider billing create false confidence and support load. This change adds dashboard.show_token_analytics (default False) to gate: - The Analytics nav item (hidden from sidebar when off) - The Analytics page (renders an explanation card instead of charts) - Token bars, totals, cost figures, avg/api_calls on the Models page The Models page keeps capability metadata (context window, vision, tools, reasoning), the use-as-main/aux menu, sessions count, and last-used timestamps when the flag is off. Set dashboard.show_token_analytics: true in config.yaml to opt back in to the local debug estimate. Fixing the underlying accounting (issue #23270) is a separate, larger workstream. Refs: #23270, #21705 --- hermes_cli/config.py | 15 +++ web/src/App.tsx | 27 ++++- web/src/pages/AnalyticsPage.tsx | 114 ++++++++++++++----- web/src/pages/ModelsPage.tsx | 193 +++++++++++++++++++++----------- 4 files changed, 249 insertions(+), 100 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6fd772e84ca..5d4ecb5b619 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -971,6 +971,21 @@ DEFAULT_CONFIG = { # 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index d7239c2ad11..71a97113c24 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 57943eba6f2..4896e760636 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 01c239d7034..f09104d4241 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)} + {showTokens ? ( +
+
+ {formatTokens(totalTokens)} +
+
+ {t.models.tokens} +
-
- {t.models.tokens} -
-
+ ) : ( + entry.sessions > 0 && ( +
+
+ {entry.sessions} +
+
+ {t.models.sessions} +
+
+ ) + )} - + {showTokens && ( + <> + -
-
-
{entry.sessions}
-
- {t.models.sessions} +
+
+
{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} +
+
-
-
-
- {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} /> ))}
From d5775fe98870f4d7ba7cf322bd05283533079aa3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:20:27 -0700 Subject: [PATCH 041/214] feat(codex-runtime): skip unavailable plugins during migration (#25437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to PR #24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - #81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - #81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - #81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - #80688 (constrain sandbox): we don't have an outer-sandbox concept - #80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - #80278 (expose activeModel in plugin SDK): not our surface - #80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new). --- hermes_cli/codex_runtime_plugin_migration.py | 16 ++++++ .../test_codex_runtime_plugin_migration.py | 50 ++++++++++++++++++- .../features/codex-app-server-runtime.md | 3 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index c00ec26bd29..dd7faa09794 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -397,6 +397,22 @@ def _query_codex_plugins( 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 diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index 0274251327c..b2e27f8c97b 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -353,7 +353,7 @@ class TestMigrate: ], None monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query) - report = migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) + 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 @@ -361,6 +361,54 @@ class TestMigrate: 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.""" diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 5d4b068088b..a1aa6a0776e 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -340,7 +340,8 @@ Plugins installed via `codex plugin` (Linear, GitHub, Gmail, Calendar, Canva, et 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 not yet installed in Codex CLI. Install them via `codex plugin` first. +- 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. From 3633c8690b86d68edfe235fa56abb68dcb52ec29 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 11:59:09 +0530 Subject: [PATCH 042/214] refactor(plugins): add apply_yaml_config_fn registry hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets platform plugins own their YAML→env config bridge instead of forcing core gateway/config.py to know every platform's schema. The hook receives the full parsed config.yaml and the platform's own sub-dict, may mutate os.environ (env > YAML precedence preserved via the standard `not os.getenv(...)` guards), and may return a dict to merge into PlatformConfig.extra. It runs during load_gateway_config() after the existing generic shared-key loop and before _apply_env_overrides(), mirroring the env_enablement_fn dispatch pattern (#21306, #21331). Pure addition — no behavior change for existing platforms. Each of the eight platforms with hardcoded YAML→env blocks today (discord, telegram, whatsapp, slack, dingtalk, mattermost, matrix, feishu, ~252 LOC in gateway/config.py) can migrate in independent follow-up PRs; the hardcoded blocks remain functional in the meantime, and their `not os.getenv(...)` guards make them no-ops for any env var the hook already set. Test coverage: 10 new tests in tests/gateway/test_platform_registry.py covering field default, callable acceptance, env mutation, extras merge, both signature args, exception swallowing, missing/non-dict sections, and env > YAML precedence. Refs #3823, #24356. Closes #24836. --- gateway/config.py | 74 ++++- gateway/platform_registry.py | 16 + gateway/platforms/ADDING_A_PLATFORM.md | 8 + tests/gateway/test_platform_registry.py | 314 ++++++++++++++++++ .../adding-platform-adapters.md | 41 +++ 5 files changed, 444 insertions(+), 9 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 11bc8b75a0b..a7c742839c0 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 @@ -755,7 +773,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 +848,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): diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 96bfe1ccadf..97f0c0e1d74 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 ffe67e046b1..c373b9fa0b9 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/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index e6bb823aa6c..4ddc645b7b2 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -394,3 +394,317 @@ class TestPlatformsMerge: 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/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index f3597dfca39..a8433fcacdd 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: From d557544560b0492be67b320f06033e9362c2cf09 Mon Sep 17 00:00:00 2001 From: simpolism Date: Sun, 10 May 2026 01:37:56 -0400 Subject: [PATCH 043/214] fix(discord): keep free-response channels inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-response channels are intended as lightweight chat surfaces — the bot responds to every message without requiring an @mention. But the auto-thread gate only checked DISCORD_NO_THREAD_CHANNELS, not DISCORD_FREE_RESPONSE_CHANNELS, so every message in a free-response channel still spawned a brand-new thread. That turns a chat channel into a thread-spawning machine: 1 thread per message. The user-facing docs at website/docs/user-guide/messaging/discord.md already describe the intended behavior ("Free-response channels also skip auto-threading — the bot replies inline rather than spinning off a new thread per message"), so this is a code-vs-docs gap, not a design change. Fix: OR is_free_channel into skip_thread alongside the existing no_thread_channels check. One-line production change. Regression test added at tests/gateway/test_discord_free_response.py: test_discord_free_response_channel_skips_auto_thread asserts that a message in a free-response channel never calls _auto_create_thread. Reverting the one-line fix causes the test to fail with 'Expected mock to not have been awaited. Awaited 1 times.' — i.e. the test demonstrates the bug concretely. --- gateway/platforms/discord.py | 2 +- tests/gateway/test_discord_free_response.py | 31 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1817ece173d..e770d5558da 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -4223,7 +4223,7 @@ class DiscordAdapter(BasePlatformAdapter): 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: diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 91b23bd8602..7fa388dc4ae 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -446,6 +446,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 From d863773c81b4d1c958b2f28b76bcd8b0809d7eac Mon Sep 17 00:00:00 2001 From: snav Date: Wed, 13 May 2026 20:03:15 -0400 Subject: [PATCH 044/214] feat(discord): add thread_require_mention for multi-bot threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default, once Hermes participates in a Discord thread (auto-created on @mention or replied in once) it auto-responds to every subsequent message in that thread without requiring further @mentions. That's the right default for one-on-one conversations and isolated channel threads. But it's a confirmed footgun in multi-bot threads. When a user invokes one bot per turn — addressing Codex first, then Hermes — every other bot in the thread also fires on every message, burning credits and spamming the channel. Author has hit this personally in active multi-bot research-team threads. Add a new `discord.thread_require_mention` config key (env: `DISCORD_THREAD_REQUIRE_MENTION`), default `false` to preserve existing behavior. When `true`, the in-thread mention shortcut is disabled and threads are gated the same way channels are. Explicit @mentions still pass through as expected. Mirrors the existing helper shape (config.extra > env > default) and the existing yaml→env bridge pattern used by `require_mention`. Changes: - gateway/platforms/discord.py: new `_discord_thread_require_mention()` helper; in_bot_thread shortcut now AND's with `not _discord_thread_require_mention()` - gateway/config.py: bridge `discord.thread_require_mention` from config.yaml to `DISCORD_THREAD_REQUIRE_MENTION` env var (mirrors the existing `require_mention` bridge two lines above) - hermes_cli/config.py: add `thread_require_mention: False` default to DEFAULT_CONFIG['discord'] - tests/gateway/test_discord_free_response.py: 4 new tests covering default behaviour (in-thread shortcut still works), enabled behaviour (mention required in threads), enabled+mentioned (mention still passes through), and yaml-via-config.extra path. Also clears DISCORD_* env vars in the `adapter` fixture so process-env state from the contributor's shell doesn't leak into per-test behaviour. - tests/gateway/test_config.py: 2 new tests covering the yaml→env bridge (both the apply-from-yaml and env-precedence-over-yaml paths) - website/docs/user-guide/messaging/discord.md: document the new env var + config key with multi-bot rationale; cross-link from `auto_thread` section Tested on Ubuntu 24.04. --- gateway/config.py | 2 + gateway/platforms/discord.py | 30 ++++++- hermes_cli/config.py | 1 + tests/gateway/test_config.py | 37 +++++++++ tests/gateway/test_discord_free_response.py | 84 ++++++++++++++++++++ website/docs/user-guide/messaging/discord.md | 18 ++++- 6 files changed, 169 insertions(+), 3 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index a7c742839c0..39a583e2e79 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -908,6 +908,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): diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e770d5558da..b1b5012776b 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3577,6 +3577,25 @@ class DiscordAdapter(BasePlatformAdapter): 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 _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -4209,8 +4228,15 @@ class DiscordAdapter(BasePlatformAdapter): ) # 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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5d4ecb5b619..fd9784d7847 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1244,6 +1244,7 @@ DEFAULT_CONFIG = { "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) "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 diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c59b27d8001..aae3c9e5880 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -302,6 +302,43 @@ class TestLoadGatewayConfig: 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() diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 7fa388dc4ae..57198b9e73a 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -88,6 +88,20 @@ 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", + ): + monkeypatch.delenv(_var, raising=False) + config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) @@ -494,3 +508,73 @@ 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() diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 375d682f92d..a4530148cbf 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. | @@ -302,6 +303,7 @@ 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 @@ -324,6 +326,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 +366,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. From ffbc21100d0862f0b630e5921e159648d59e68b8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:19:44 -0700 Subject: [PATCH 045/214] =?UTF-8?q?chore(release):=20map=20jake@nousresear?= =?UTF-8?q?ch.com=20=E2=86=92=20simpolism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 714a44a9d3c..6e5ac99fa3f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -606,6 +606,7 @@ AUTHOR_MAP = { "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", From dee71a31e5b4f9732c0a2137f51f7a4cad1633a9 Mon Sep 17 00:00:00 2001 From: snav Date: Wed, 13 May 2026 20:04:51 -0400 Subject: [PATCH 046/214] feat(compression): make protect_first_n configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number of head messages preserved verbatim across context compactions was previously hardcoded to 3 in AIAgent.__init__. Expose it as `compression.protect_first_n` in config, matching the existing `protect_last_n` pattern. Motivation: users who rely on rolling compaction for long-running sessions had the opening user/assistant exchange pinned as head forever, which doesn't always match how they want the session framed after many compactions. Lowering to 1 preserves the system prompt + first non-system message; lowering to 0 preserves only the system prompt and lets the entire first exchange age out naturally through the summary. Semantics: `protect_first_n` counts non-system head messages protected **in addition to** the system prompt, which is always implicitly protected when present. Same meaning across both code paths: protect_first_n=0 → system prompt only (or nothing if no system message) protect_first_n=2 → system prompt + first 2 non-system messages (default) This unifies the CLI path (which reads messages with the system prompt at position 0) and the gateway path (where the gateway /compress handler strips the system prompt before calling compress() — see gateway/run.py L9150-9154 on the parent fork). Previously these two paths disagreed: CLI path: protect_first_n=1 → protect system prompt only Gateway path: protect_first_n=1 → protect first USER turn forever In practice on long-running gateway sessions the old semantics pinned whatever stale aside happened to be the first user message, reinserting it into every compaction summary indefinitely. Default chosen as 2 (not 3) so that the effective protected head count remains 3 messages in the common case — assuming a system prompt is present, default protection becomes system + 2 non-system = 3 total, matching the pre-feature behaviour where `protect_first_n` was hardcoded to protect 3 messages total. Sessions without a system prompt will see a small behaviour change (2 protected head messages instead of 3), but this is the rare path and the new semantics make the system-prompt-present case the well-defined one. Changes: - agent/context_compressor.py: redefine protect_first_n as the count of non-system head messages protected beyond the implicit system-prompt guarantee; both paths converge. Constructor default updated to 2. - hermes_cli/config.py: add `compression.protect_first_n` default (2), matching the new semantics. `show_config` label tweaked to 'Protect first: N non-system head messages' for clarity. - run_agent.py: read protect_first_n from config; 0 is now valid (system prompt is always implicitly protected). - cli-config.yaml.example: document the new key and rationale. - tests/agent/test_context_compressor.py: cover default, override, the end-to-end `protect_first_n=0` and `protect_first_n=1` behaviour, the no-system-prompt (gateway) path, and the new shared-semantics regression test. Fixes #13751 Tested on Ubuntu 24.04. --- agent/context_compressor.py | 28 ++++++- cli-config.yaml.example | 12 +++ hermes_cli/config.py | 8 +- run_agent.py | 11 ++- tests/agent/test_context_compressor.py | 101 +++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 11 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d16236737c4..99012c73c1b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -405,7 +405,7 @@ class ContextCompressor(ContextEngine): self, model: str, threshold_percent: float = 0.50, - protect_first_n: int = 3, + protect_first_n: int = 2, protect_last_n: int = 20, summary_target_ratio: float = 0.20, quiet_mode: bool = False, @@ -1185,6 +1185,26 @@ The user has requested that this compaction PRIORITISE preserving all informatio 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 @@ The user has requested that this compaction PRIORITISE preserving all informatio 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 @@ The user has requested that this compaction PRIORITISE preserving all informatio 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 @@ The user has requested that this compaction PRIORITISE preserving all informatio 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 diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 6daceba04a9..1bfec39698a 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 2 preserves the system prompt plus the first user/assistant + # exchange (≈ 3 messages total when a system prompt is present). + protect_first_n: 2 + # To pin a specific model/provider for compression summaries, use the # auxiliary section below (auxiliary.compression.provider / model). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fd9784d7847..3feb2cbddbb 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -731,8 +731,13 @@ DEFAULT_CONFIG = { "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": 2, # non-system head messages always preserved beyond the system prompt + # 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). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. "prompt_caching": { @@ -4862,6 +4867,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', 2)} 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/run_agent.py b/run_agent.py index 53177931b81..8c7dfe2b061 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2115,6 +2115,15 @@ class AIAgent: 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", 2)) + ) # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -2315,7 +2324,7 @@ class AIAgent: 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, diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 97a7c7b3d0f..821d3c4c4b7 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -991,9 +991,12 @@ class TestCompressWithClient: 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 @@ class TestCompressWithClient: 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 @@ class TestCompressWithClient: 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 @@ class TestCompressWithClient: 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,90 @@ class TestSummaryTargetRatio: c = ContextCompressor(model="test", quiet_mode=True) assert c.protect_last_n == 20 + def test_default_protect_first_n_is_2(self): + """Default protect_first_n is 2 (system + 2 extra non-system messages = + 3 protected messages total, preserving the pre-feature behaviour where + protect_first_n was hardcoded to protect 3 head messages total). + """ + 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 == 2 + + 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=2, result[1] and result[2] would be + # the literal "msg 0" / "msg 1"; 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). From 4ceab16893e3d77b2388bf5d1db8d9cc26a1307e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:22:21 -0700 Subject: [PATCH 047/214] fix(compression): keep default protect_first_n at 3 + align ABC Follow-up on the salvaged feat commit: - Keep the constructor / config / yaml-example default at 3 so existing gateway and CLI users see no behavioural change. PR #13754 (which this builds on) had lowered the default to 2 to chase pre-feature parity in the system-prompt-present case, at the cost of quietly halving the protected head for the gateway path (which strips the system prompt before calling compress()). With the new "system prompt is implicit" semantics, default 3 gives every caller a stable head shape. - agent/context_engine.py: bring the ABC's protect_first_n docstring in line with the new semantics so plugin context engines interpret the config key the same way the built-in compressor does. - tests: adjust the default-value test (3, not 2) and a stale comment; per-test protect_first_n=2/3/1 values added in PR #13754 stay as-is since those tests fix concrete head shapes. --- agent/context_compressor.py | 2 +- agent/context_engine.py | 5 +++++ cli-config.yaml.example | 6 +++--- hermes_cli/config.py | 5 +++-- run_agent.py | 2 +- tests/agent/test_context_compressor.py | 16 +++++++++------- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 99012c73c1b..df75b8b88ce 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -405,7 +405,7 @@ class ContextCompressor(ContextEngine): self, model: str, threshold_percent: float = 0.50, - protect_first_n: int = 2, + protect_first_n: int = 3, protect_last_n: int = 20, summary_target_ratio: float = 0.20, quiet_mode: bool = False, diff --git a/agent/context_engine.py b/agent/context_engine.py index bbafcd29c01..2947da54d8c 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -55,6 +55,11 @@ class ContextEngine(ABC): # 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/cli-config.yaml.example b/cli-config.yaml.example index 1bfec39698a..13d9ad9c420 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -372,9 +372,9 @@ compression: # 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 2 preserves the system prompt plus the first user/assistant - # exchange (≈ 3 messages total when a system prompt is present). - protect_first_n: 2 + # 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). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3feb2cbddbb..685de3d7341 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -731,13 +731,14 @@ DEFAULT_CONFIG = { "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": 2, # non-system head messages always preserved beyond the system prompt + "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). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. "prompt_caching": { @@ -4867,7 +4868,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', 2)} non-system head 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/run_agent.py b/run_agent.py index 8c7dfe2b061..4f50cb06e4d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2122,7 +2122,7 @@ class AIAgent: # is a legitimate (and common) configuration for long-running # rolling-compaction sessions. compression_protect_first = max( - 0, int(_compression_cfg.get("protect_first_n", 2)) + 0, int(_compression_cfg.get("protect_first_n", 3)) ) # Read optional explicit context_length override for the auxiliary diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 821d3c4c4b7..559cf2237a2 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1299,14 +1299,16 @@ class TestSummaryTargetRatio: c = ContextCompressor(model="test", quiet_mode=True) assert c.protect_last_n == 20 - def test_default_protect_first_n_is_2(self): - """Default protect_first_n is 2 (system + 2 extra non-system messages = - 3 protected messages total, preserving the pre-feature behaviour where - protect_first_n was hardcoded to protect 3 head messages total). + 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 == 2 + 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 @@ -1342,8 +1344,8 @@ class TestSummaryTargetRatio: 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=2, result[1] and result[2] would be - # the literal "msg 0" / "msg 1"; with protect_first_n=0 they aren't. + # 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"] From efc32ab639ff36d40aabf2c2401f6452a74a4b60 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 11 May 2026 13:57:02 +0530 Subject: [PATCH 048/214] refactor(inventory): extract shared ConfigContext + build_models_payload Three call-sites in the codebase each duplicated the same config-slice + list_authenticated_providers + post-processing pattern: - hermes_cli/web_server.py /api/model/options - tui_gateway/server.py model.options JSON-RPC - tui_gateway/server.py model.save_key JSON-RPC This consolidates them onto hermes_cli/inventory.py: load_picker_context() -> ConfigContext Replaces the 17-LOC config-slice (model.{default,name,provider, base_url}, providers:, custom_providers:) every consumer did inline. ConfigContext.with_overrides(*, current_provider=, current_model=, current_base_url=) -> ConfigContext Truthy-only overlay for TUI agent-session state on top of disk config. Empty getattr(agent, ...) attrs MUST NOT clobber disk. build_models_payload(ctx, *, include_unconfigured, picker_hints, canonical_order, max_models) -> dict Single payload builder. Delegates curation to list_authenticated_providers (does not call provider_model_ids per row \u2014 that pulls non-agentic models). picker_hints + canonical_order produce the TUI ModelPickerDialog shape; defaults match the dashboard's existing /api/model/options contract. Two latent bugs fixed by consolidation: 1. The dashboard read cfg.get('custom_providers') directly, missing the v12+ keyed providers: form. Now both surfaces go through get_compatible_custom_providers(). 2. The TUI's canonical-merge keyed on is_user_defined to decide order. Section 3 of list_authenticated_providers sets is_user_defined=True on rows from the providers: config dict even when the slug is canonical \u2014 that silently demoted them to the picker tail. _reorder_canonical now keys on slug membership instead. Stats: +666 / -145 (net +521). Module 240 LOC; 18 behavior tests. This PR replaces the rejected #23369 (which bundled the consolidation with new scriptable CLI surfaces \u2014 hermes models list/status, hermes providers list \u2014 and a JSON contract that have no external user demand). Just the refactor; the CLI surface is deferred to a separate PR gated on actual demand. Refs #23359. --- hermes_cli/inventory.py | 240 ++++++++++++++++++ hermes_cli/web_server.py | 34 +-- tests/hermes_cli/test_inventory.py | 378 +++++++++++++++++++++++++++++ tui_gateway/server.py | 159 ++++-------- 4 files changed, 666 insertions(+), 145 deletions(-) create mode 100644 hermes_cli/inventory.py create mode 100644 tests/hermes_cli/test_inventory.py diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py new file mode 100644 index 00000000000..5cf32d1c847 --- /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/web_server.py b/hermes_cli/web_server.py index 3f0eae0aebc..bdb24554f87 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/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py new file mode 100644 index 00000000000..2a288b37a45 --- /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/tui_gateway/server.py b/tui_gateway/server.py index 41cbdd05e37..230387ce23b 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 {} + # 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 "", ) - custom_provs = ( - cfg.get("custom_providers") - if isinstance(cfg.get("custom_providers"), list) - 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: From 563077a47ad32b9fadd7ed302827c7083e25a2e0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:26:52 -0700 Subject: [PATCH 049/214] refactor(cli): route /model picker through shared inventory module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive CLI /model picker was the third call-site duplicating the inline config-slice + list_authenticated_providers pattern that PR #23666 consolidated for the dashboard and TUI. Route it through load_picker_context() + build_models_payload() too so all surfaces that show authenticated providers share one substrate. Side effect: cli.py now also benefits from the latent v12+ keyed providers fix (custom_providers populated via get_compatible_custom_providers, not cfg.get raw). The aux-task switcher (hermes_cli/main.py) and gateway model switcher (gateway/run.py) deliberately stay on the legacy path — they use different config sections (auxiliary..*) and a different config loader (_load_gateway_config) respectively, so forcing them through ConfigContext would either overload its semantics or grow the module past the clean refactor scope. --- cli.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/cli.py b/cli.py index 5560846320d..f2d0d019df2 100644 --- a/cli.py +++ b/cli.py @@ -6614,7 +6614,7 @@ class HermesCLI: /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 @@ -6624,16 +6624,25 @@ class HermesCLI: # 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: @@ -6641,14 +6650,9 @@ class HermesCLI: 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 = [] From 2cea98e143b4016b277fb3221728e3efbb4c0cc4 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 22:54:59 +0530 Subject: [PATCH 050/214] feat(web): add WebSearchProvider ABC mirroring image_gen template --- agent/web_search_provider.py | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 agent/web_search_provider.py diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py new file mode 100644 index 00000000000..605af9d5cf5 --- /dev/null +++ b/agent/web_search_provider.py @@ -0,0 +1,155 @@ +""" +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 plugin-facing surface. The legacy +:mod:`tools.web_providers.base` module retains its own ABCs for in-tree +consumers that haven't migrated yet; over time those will all flow through +this provider. + +Response shape (mirrors the legacy contract in ``tools/web_providers/base.py`` +so the tool wrapper does not have to translate): + +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 backend. + + Subclasses must implement :meth:`is_available` and at least one of + :meth:`search` / :meth:`extract`. The :meth:`supports_search` and + :meth:`supports_extract` capability flags let the registry route each + tool call to the right provider, and let multi-capability providers + (SearXNG, Firecrawl, Tavily, …) advertise both. + """ + + @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`.""" + 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) -> Dict[str, 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. + + ``kwargs`` may carry forward-compat fields (e.g. ``include_raw``, + ``max_chars``) — implementations should ignore unknown keys. + """ + raise NotImplementedError( + f"{self.name} does not support extract (override supports_extract)" + ) + + 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": [], + } From 007a630b16988981e786fd562a03a177607dd9b6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 22:55:27 +0530 Subject: [PATCH 051/214] feat(web): add web search provider registry mirroring image_gen pattern --- agent/web_search_registry.py | 178 +++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 agent/web_search_registry.py diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py new file mode 100644 index 00000000000..8f1e884b3cf --- /dev/null +++ b/agent/web_search_registry.py @@ -0,0 +1,178 @@ +""" +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`` (for search) or ``web.extract_backend`` (for extract) +2. ``web.backend`` (shared fallback) +3. If exactly one capability-eligible provider is registered, use it. +4. Legacy preference order (``brave-free`` → ``firecrawl`` → ``searxng`` → ``ddgs``) + so installs that omitted the config key keep working. +5. Otherwise ``None`` — the tool surfaces a helpful error pointing at + ``hermes tools``. + +The capability filter (``supports_search`` vs ``supports_extract``) is applied +at every step so a search-only provider (``brave-free``) configured as +``web.extract_backend`` correctly falls through. +""" + +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 config +# at all. brave-free first because it was the shipped default after the +# Brave migration; firecrawl second for back-compat with older configs. +_LEGACY_PREFERENCE = ("brave-free", "firecrawl", "searxng", "ddgs") + + +def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: + """Resolve the active provider for a capability ("search" | "extract").""" + 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()) + return False + + 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, + ) + + eligible = [p for p in snapshot.values() if _capable(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): + 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 _reset_for_tests() -> None: + """Clear the registry. **Test-only.**""" + with _lock: + _providers.clear() From f29f02a73fd021bc8a9ee14f0aaf176e46ce1a5f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 22:55:53 +0530 Subject: [PATCH 052/214] feat(plugins): add ctx.register_web_search_provider() facade --- hermes_cli/plugins.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 1aa7075f6f6..9e9af0e0644 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -569,6 +569,34 @@ class PluginContext: 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( From d403cf018c8e6a887e5b867bf6de76cc4aadacd9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:30:31 +0530 Subject: [PATCH 053/214] feat(web): brave_free plugin (first migration from tools/web_providers/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugins/web/brave_free/ as the first plugin built against the new WebSearchProvider ABC. Mirrors the plugins/image_gen/openai/ layout exactly: plugins/web/brave_free/ plugin.yaml kind: backend, provides_web_providers: [brave-free] __init__.py register(ctx) -> ctx.register_web_search_provider(...) provider.py BraveFreeWebSearchProvider(WebSearchProvider) Behavior preserved: same name ("brave-free" with hyphen), same env var (BRAVE_SEARCH_API_KEY), same HTTP request shape, same response normalization. The legacy tools/web_providers/brave_free.py is left in place — the dispatcher in tools/web_tools.py still references it. Task 7 cuts over the dispatcher to the new registry; Task 10 deletes the legacy file. E2E verified: HERMES_PLUGINS_DEBUG=1 python -c " from hermes_cli.plugins import _ensure_plugins_discovered _ensure_plugins_discovered() from agent.web_search_registry import list_providers print([p.name for p in list_providers()]) " # -> ['brave-free'] --- plugins/web/__init__.py | 7 ++ plugins/web/brave_free/__init__.py | 14 +++ plugins/web/brave_free/plugin.yaml | 7 ++ plugins/web/brave_free/provider.py | 137 +++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 plugins/web/__init__.py create mode 100644 plugins/web/brave_free/__init__.py create mode 100644 plugins/web/brave_free/plugin.yaml create mode 100644 plugins/web/brave_free/provider.py diff --git a/plugins/web/__init__.py b/plugins/web/__init__.py new file mode 100644 index 00000000000..ad557e17744 --- /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 00000000000..6499d546722 --- /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 00000000000..3b39a34e18d --- /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/plugins/web/brave_free/provider.py b/plugins/web/brave_free/provider.py new file mode 100644 index 00000000000..dfa927ef10e --- /dev/null +++ b/plugins/web/brave_free/provider.py @@ -0,0 +1,137 @@ +"""Brave Search (free tier) — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider` (the +plugin-facing ABC) and reuses the existing Brave search logic from the +legacy ``tools.web_providers.brave_free`` module. Once the spike validates +the pattern, the legacy module is deleted and this becomes the canonical +implementation. + +Config keys this provider responds to:: + + web: + search_backend: "brave-free" # explicit per-capability + backend: "brave-free" # shared fallback + +Auth env var:: + + BRAVE_SEARCH_API_KEY=... # https://brave.com/search/api/ (free tier) +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" + + +class BraveFreeWebSearchProvider(WebSearchProvider): + """Search-only Brave provider using the free-tier Data-for-Search API. + + Free tier is 2,000 queries/month (1 qps). No content-extraction capability — + users pair this with Firecrawl/Tavily/Exa for ``web_extract``. + """ + + @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" + + @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 supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a search against the Brave Search API. + + Returns ``{"success": True, "data": {"web": [{"title", "url", "description", "position"}]}}`` + on success, or ``{"success": False, "error": str}`` on failure. + """ + import httpx + + api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"} + + # Brave's `count` is capped at 20. + count = max(1, min(int(limit), 20)) + + try: + resp = httpx.get( + _BRAVE_ENDPOINT, + params={"q": query, "count": count}, + headers={ + "X-Subscription-Token": api_key, + "Accept": "application/json", + }, + timeout=15, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("Brave Search HTTP error: %s", exc) + return { + "success": False, + "error": f"Brave Search returned HTTP {exc.response.status_code}", + } + except httpx.RequestError as exc: + logger.warning("Brave Search request error: %s", exc) + return {"success": False, "error": f"Could not reach Brave Search: {exc}"} + + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.warning("Brave Search response parse error: %s", exc) + return {"success": False, "error": "Could not parse Brave Search response as JSON"} + + raw_results = (data.get("web") or {}).get("results", []) or [] + truncated = raw_results[:limit] + + web_results = [ + { + "title": str(r.get("title", "")), + "url": str(r.get("url", "")), + "description": str(r.get("description", "")), + "position": i + 1, + } + for i, r in enumerate(truncated) + ] + + logger.info( + "Brave Search '%s': %d results (from %d raw, limit %d)", + query, + len(web_results), + len(raw_results), + limit, + ) + + 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/", + }, + ], + } From 5c7d098bee5f22adca078d7d7632737549e1fb29 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:31:13 +0530 Subject: [PATCH 054/214] feat(web): ddgs plugin (second migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugins/web/ddgs/ following the same plugins/image_gen/ pattern as brave_free. DuckDuckGo search via the community ddgs package; no API key, package is an optional dep gated by is_available(). E2E verified — registry now has ['brave-free', 'ddgs']. --- plugins/web/ddgs/__init__.py | 15 ++++++ plugins/web/ddgs/plugin.yaml | 7 +++ plugins/web/ddgs/provider.py | 100 +++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 plugins/web/ddgs/__init__.py create mode 100644 plugins/web/ddgs/plugin.yaml create mode 100644 plugins/web/ddgs/provider.py diff --git a/plugins/web/ddgs/__init__.py b/plugins/web/ddgs/__init__.py new file mode 100644 index 00000000000..26eb6407ef8 --- /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 00000000000..e85236c14cf --- /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/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py new file mode 100644 index 00000000000..eefd98d51f6 --- /dev/null +++ b/plugins/web/ddgs/provider.py @@ -0,0 +1,100 @@ +"""DuckDuckGo search — plugin form (via the ``ddgs`` package). + +Subclasses the plugin-facing :class:`agent.web_search_provider.WebSearchProvider`. +Same behavior as the legacy ``tools.web_providers.ddgs`` module — only the +ABC name and import path change. + +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 + +import logging +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class DDGSWebSearchProvider(WebSearchProvider): + """DuckDuckGo HTML-scrape search provider. + + 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. + """ + + @property + def name(self) -> str: + return "ddgs" + + @property + def display_name(self) -> str: + return "DuckDuckGo (ddgs)" + + def is_available(self) -> bool: + """Return True when the ``ddgs`` package is importable. + + 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 supports_search(self) -> bool: + return True + + 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: + return { + "success": False, + "error": "ddgs package is not installed — run `pip install ddgs`", + } + + # DDGS().text yields at most `max_results` items; we cap defensively + # in case the package ignores the hint. + safe_limit = max(1, int(limit)) + + try: + web_results = [] + with DDGS() as client: + for i, hit in enumerate(client.text(query, max_results=safe_limit)): + if i >= safe_limit: + break + url = str(hit.get("href") or hit.get("url") or "") + web_results.append( + { + "title": str(hit.get("title", "")), + "url": url, + "description": str(hit.get("body", "")), + "position": i + 1, + } + ) + except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions + logger.warning("DDGS search error: %s", exc) + return {"success": False, "error": f"DuckDuckGo search failed: {exc}"} + + 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", + "tag": "No API key — community ddgs package (pip install ddgs).", + "env_vars": [], + } From 0d085d9454dd841cd4afac2306414205793ac7c8 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:32:06 +0530 Subject: [PATCH 055/214] feat(web): searxng plugin (search-only, third migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugins/web/searxng/. SearXNG aggregates results from upstream engines via its JSON API (/search?format=json) — search-only, no extract capability (supports_extract() returns False). E2E verified — registry now has ['brave-free', 'ddgs', 'searxng']. --- plugins/web/searxng/__init__.py | 15 ++++ plugins/web/searxng/plugin.yaml | 7 ++ plugins/web/searxng/provider.py | 138 ++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 plugins/web/searxng/__init__.py create mode 100644 plugins/web/searxng/plugin.yaml create mode 100644 plugins/web/searxng/provider.py diff --git a/plugins/web/searxng/__init__.py b/plugins/web/searxng/__init__.py new file mode 100644 index 00000000000..cea8eabb18e --- /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 00000000000..3d758bad58b --- /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/plugins/web/searxng/provider.py b/plugins/web/searxng/provider.py new file mode 100644 index 00000000000..a303ef1f5d7 --- /dev/null +++ b/plugins/web/searxng/provider.py @@ -0,0 +1,138 @@ +"""SearXNG search — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Same JSON +API call (``/search?format=json``), same result normalization as the legacy +:mod:`tools.web_providers.searxng` module. + +Search-only — SearXNG aggregates results from upstream engines but does not +fetch/extract arbitrary URLs. ``supports_extract()`` returns False. + +Config keys this provider responds to:: + + web: + search_backend: "searxng" # explicit per-capability + backend: "searxng" # shared fallback + +Env var:: + + SEARXNG_URL=http://localhost:8080 +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class SearXNGWebSearchProvider(WebSearchProvider): + """Search via a user-hosted SearXNG instance.""" + + @property + def name(self) -> str: + return "searxng" + + @property + def display_name(self) -> str: + return "SearXNG" + + def is_available(self) -> bool: + """Return True when ``SEARXNG_URL`` is set.""" + return bool(os.getenv("SEARXNG_URL", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + 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("/") + if not base_url: + return {"success": False, "error": "SEARXNG_URL is not set"} + + params: Dict[str, Any] = { + "q": query, + "format": "json", + "pageno": 1, + } + + try: + resp = httpx.get( + f"{base_url}/search", + params=params, + timeout=15, + headers={"Accept": "application/json"}, + ) + 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}", + } + 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}", + } + + 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", + } + + raw_results = data.get("results", []) + + # SearXNG may return a score field; sort descending and cap to limit. + sorted_results = sorted( + raw_results, + key=lambda r: float(r.get("score", 0)), + reverse=True, + )[:limit] + + web_results = [ + { + "title": str(r.get("title", "")), + "url": str(r.get("url", "")), + "description": str(r.get("content", "")), + "position": i + 1, + } + for i, r in enumerate(sorted_results) + ] + + logger.info( + "SearXNG search '%s': %d results (from %d raw, limit %d)", + query, + len(web_results), + len(raw_results), + limit, + ) + + 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/", + }, + ], + } From 6bd16a645b49e4814f35a17f1c6e5bf854f8ecee Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:34:10 +0530 Subject: [PATCH 056/214] refactor(web): dispatch brave-free/ddgs/searxng via web_search_registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three migrated providers (brave-free, ddgs, searxng) are now dispatched through agent.web_search_registry.get_provider() instead of importing their concrete classes directly. The four inline providers (parallel, exa, tavily, firecrawl) keep their existing branches — they live in tools/web_tools.py itself and aren't part of this spike's plugin extraction. The legacy tools/web_providers/{brave_free,ddgs,searxng}.py modules are still in place (untouched by this commit) — Task 10 deletes them once the real migration PR is ready. Keeping them alive during the spike means revertibility is trivial. E2E verified: 1. Plugin discovery registers ['brave-free','ddgs','searxng'] 2. Config web.search_backend: brave-free resolves to the plugin instance 3. Dispatch result matches the original {success, data.web[]} contract 4. compile OK; no new LSP errors beyond pre-existing ones in web_tools.py --- tools/web_tools.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/tools/web_tools.py b/tools/web_tools.py index 79ddc8d27f2..80eabe4d8b9 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1249,29 +1249,26 @@ def web_search_tool(query: str, limit: int = 5) -> str: _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 + # Plugin-backed providers (brave-free, ddgs, searxng) — dispatched + # through agent.web_search_registry. Inline providers (parallel, + # exa, tavily, firecrawl) keep their own branches below until they + # too migrate to plugins. Spike scope: only the three providers + # already living in tools/web_providers/ are moved to plugins; the + # rest follow in the real migration PR. + if backend in {"brave-free", "ddgs", "searxng"}: + from agent.web_search_registry import get_provider as _wsp_get_provider - 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) + provider = _wsp_get_provider(backend) + if provider is None or not provider.supports_search(): + response_data = { + "success": False, + "error": ( + f"Web search provider '{backend}' is not registered. " + "Run `hermes tools` to set up a provider." + ), + } + else: + 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) From 714630110b61b3537490869ed1bfa4ac0d086da2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:35:52 +0530 Subject: [PATCH 057/214] feat(tools): mirror image_gen plugin-injection in Web Search picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _plugin_web_search_providers() and wires it into _visible_providers() for the "Web Search & Extract" category. Mirrors the existing image_gen pattern at the same site exactly. Spike scope: while the three migrated providers (brave-free, ddgs, searxng) still have hardcoded TOOL_CATEGORIES rows, _WEB_PLUGIN_SKIPLIST excludes them so the picker doesn't show duplicates. The migration PR drops the hardcoded rows and the skip-list both — then this helper is the only source of web-provider picker rows. E2E verified: helper returns [] today (skip-list covers all 3 migrated providers); injection point is sound and ready for the post-migration state. --- hermes_cli/tools_config.py | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 108dfe9dd93..bb357e63d41 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1576,6 +1576,60 @@ def _plugin_video_gen_providers() -> list[dict]: return rows +# Mirror of _plugin_image_gen_providers for web search backends. Surfaces +# plugin-registered web providers (brave-free / ddgs / searxng during the +# spike) so they appear in the "Web Search & Extract" picker row. While +# the legacy TOOL_CATEGORIES entries still cover those names, this helper +# skip-lists them to avoid duplicate rows. +# +# When the migration PR drops the hardcoded entries, the skip-list can be +# removed and this helper becomes the sole source of web-provider picker +# rows (matching how Spotify / Google Meet are surfaced today purely from +# their plugins). +_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng"}) + + +def _plugin_web_search_providers() -> list[dict]: + """Build picker-row dicts from plugin-registered web search providers. + + Each returned dict looks like a regular ``TOOL_CATEGORIES`` provider + row but carries a ``web_search_plugin_name`` marker so downstream + code can route through ``agent.web_search_registry`` instead of the + legacy hardcoded dispatch. Names already covered by hardcoded picker + rows during the spike are skipped via :data:`_WEB_PLUGIN_SKIPLIST`. + """ + 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 or name in _WEB_PLUGIN_SKIPLIST: + continue + 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", []), + "web_search_plugin_name": name, + } + ) + 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) @@ -1597,6 +1651,14 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: if cat.get("name") == "Video Generation": visible.extend(_plugin_video_gen_providers()) + # Inject plugin-registered web search backends. During the spike the + # three migrated providers (brave-free, ddgs, searxng) still have + # hardcoded TOOL_CATEGORIES entries — the helper skips them so the + # picker doesn't show duplicates. When the migration PR deletes those + # hardcoded rows, this injection becomes the sole source of truth. + if cat.get("name") == "Web Search & Extract": + visible.extend(_plugin_web_search_providers()) + return visible From 6b219f5af6022ef09d0312a8ceccf3e1b11c3aa7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 13 May 2026 23:52:02 +0530 Subject: [PATCH 058/214] refactor(web): remove legacy in-tree provider modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes tools/web_providers/{brave_free,ddgs,searxng}.py — the three providers that moved to plugins/web/ in prior commits. tools/web_tools.py no longer imports them (registry dispatch as of d8735963f), so removing them is purely a cleanup pass. Also migrates the existing tests to the new import paths: tests/tools/test_web_providers_brave_free.py tests/tools/test_web_providers_ddgs.py tests/tools/test_web_providers_searxng.py Mechanical rewrites: - `from tools.web_providers.X import YSearchProvider` -> `from plugins.web.X.provider import YWebSearchProvider` - `.is_configured()` -> `.is_available()` (legacy method -> new method) - `.provider_name()` -> `.name` (legacy method -> new property) - `from tools.web_providers.base import WebSearchProvider` -> `from agent.web_search_provider import WebSearchProvider` (the subclass-check asserts membership in the new plugin-facing ABC) - `sys.modules.delitem("tools.web_providers.ddgs")` updated to point at `plugins.web.ddgs.provider` (cache-busting for lazy ddgs imports) The TestXBackendWiring / TestXSearchOnlyErrors classes (covering _is_backend_available, _get_backend, check_web_api_key, and the "search-only" error paths in web_extract/web_crawl) are untouched — those still test web_tools.py's backend-selection logic, which continues to recognize the names "brave-free" / "ddgs" / "searxng" even after the modules behind them moved to plugins. tools/web_providers/base.py is intentionally NOT deleted by this commit — it's the parent ABC of the legacy modules and shares its name with agent/web_search_provider.py::WebSearchProvider. Removing it surfaces the naming collision (see PR description Finding 0); the real migration PR deletes it in the same commit that drops the _WEB_PLUGIN_SKIPLIST guards in hermes_cli/tools_config.py. Test results: bash scripts/run_tests.sh tests/tools/test_web_providers_*.py -> 65 passed in 3.41s (all rewritten unit tests + unchanged integration tests) bash scripts/run_tests.sh tests/tools/test_web_*.py -> 141 passed in 4.70s (full web test set, post-deletion) --- plugins/web/brave_free/provider.py | 6 +- plugins/web/ddgs/provider.py | 5 +- plugins/web/searxng/provider.py | 6 +- tests/tools/test_web_providers_brave_free.py | 64 ++++----- tests/tools/test_web_providers_ddgs.py | 56 ++++---- tests/tools/test_web_providers_searxng.py | 70 +++++----- tools/web_providers/brave_free.py | 130 ------------------ tools/web_providers/ddgs.py | 98 -------------- tools/web_providers/searxng.py | 132 ------------------- 9 files changed, 105 insertions(+), 462 deletions(-) delete mode 100644 tools/web_providers/brave_free.py delete mode 100644 tools/web_providers/ddgs.py delete mode 100644 tools/web_providers/searxng.py diff --git a/plugins/web/brave_free/provider.py b/plugins/web/brave_free/provider.py index dfa927ef10e..df4584f7732 100644 --- a/plugins/web/brave_free/provider.py +++ b/plugins/web/brave_free/provider.py @@ -1,9 +1,9 @@ """Brave Search (free tier) — plugin form. Subclasses :class:`agent.web_search_provider.WebSearchProvider` (the -plugin-facing ABC) and reuses the existing Brave search logic from the -legacy ``tools.web_providers.brave_free`` module. Once the spike validates -the pattern, the legacy module is deleted and this becomes the canonical +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. Config keys this provider responds to:: diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index eefd98d51f6..1cc6f9e7b68 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -1,8 +1,9 @@ """DuckDuckGo search — plugin form (via the ``ddgs`` package). Subclasses the plugin-facing :class:`agent.web_search_provider.WebSearchProvider`. -Same behavior as the legacy ``tools.web_providers.ddgs`` module — only the -ABC name and import path change. +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. The ``ddgs`` package is an optional dependency. ``is_available()`` reflects whether the package is importable; the plugin still registers either way so diff --git a/plugins/web/searxng/provider.py b/plugins/web/searxng/provider.py index a303ef1f5d7..043f6711c1b 100644 --- a/plugins/web/searxng/provider.py +++ b/plugins/web/searxng/provider.py @@ -1,8 +1,10 @@ """SearXNG search — plugin form. Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Same JSON -API call (``/search?format=json``), same result normalization as the legacy -:mod:`tools.web_providers.searxng` module. +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. Search-only — SearXNG aggregates results from upstream engines but does not fetch/extract arbitrary URLs. ``supports_extract()`` returns False. diff --git a/tests/tools/test_web_providers_brave_free.py b/tests/tools/test_web_providers_brave_free.py index 36fe41640e8..f441bf0f8b4 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 @@ from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- -# 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 @@ class TestBraveFreeProviderSearch: 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 9a3ceec7372..d575fe63e36 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 _install_fake_ddgs(monkeypatch, *, text_results=None, text_raises=None): # --------------------------------------------------------------------------- -# 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 @@ class TestDDGSProviderIsConfigured: 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 @@ class TestDDGSProviderSearch: {"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 @@ class TestDDGSProviderSearch: _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 @@ class TestDDGSProviderSearch: {"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 @@ class TestDDGSProviderSearch: 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 4779ed6ce6e..d579fb0d0a6 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 @@ import pytest # --------------------------------------------------------------------------- -# 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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 @@ class TestSearXNGSearchProviderSearch: 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/tools/web_providers/brave_free.py b/tools/web_providers/brave_free.py deleted file mode 100644 index 52d02dec2a1..00000000000 --- a/tools/web_providers/brave_free.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Brave Search web search provider (free tier). - -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. - -Configuration:: - - # ~/.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 - -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. -""" - -from __future__ import annotations - -import logging -import os -from typing import Any, Dict - -from tools.web_providers.base 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). - - 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``. - """ - - def provider_name(self) -> str: - return "brave-free" - - def is_configured(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. - - Returns normalized results:: - - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } - - On failure returns ``{"success": False, "error": str}``. - """ - import httpx - - api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip() - if not api_key: - return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"} - - # Brave's `count` is capped at 20. - count = max(1, min(int(limit), 20)) - - try: - resp = httpx.get( - _BRAVE_ENDPOINT, - params={"q": query, "count": count}, - headers={ - "X-Subscription-Token": api_key, - "Accept": "application/json", - }, - timeout=15, - ) - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - logger.warning("Brave Search HTTP error: %s", exc) - return { - "success": False, - "error": f"Brave Search returned HTTP {exc.response.status_code}", - } - except httpx.RequestError as exc: - logger.warning("Brave Search request error: %s", exc) - return {"success": False, "error": f"Could not reach Brave Search: {exc}"} - - try: - data = resp.json() - except Exception as exc: # noqa: BLE001 - logger.warning("Brave Search response parse error: %s", exc) - return {"success": False, "error": "Could not parse Brave Search response as JSON"} - - raw_results = (data.get("web") or {}).get("results", []) or [] - truncated = raw_results[:limit] - - web_results = [ - { - "title": str(r.get("title", "")), - "url": str(r.get("url", "")), - "description": str(r.get("description", "")), - "position": i + 1, - } - for i, r in enumerate(truncated) - ] - - logger.info( - "Brave Search '%s': %d results (from %d raw, limit %d)", - query, - len(web_results), - len(raw_results), - limit, - ) - - return {"success": True, "data": {"web": web_results}} diff --git a/tools/web_providers/ddgs.py b/tools/web_providers/ddgs.py deleted file mode 100644 index b81b97de2cb..00000000000 --- a/tools/web_providers/ddgs.py +++ /dev/null @@ -1,98 +0,0 @@ -"""DuckDuckGo web search provider via the ``ddgs`` Python 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. - -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. -""" - -from __future__ import annotations - -import logging -from typing import Any, Dict - -from tools.web_providers.base import WebSearchProvider - -logger = logging.getLogger(__name__) - - -class DDGSSearchProvider(WebSearchProvider): - """Search via the ``ddgs`` package (DuckDuckGo HTML scrape). - - No API key required. The provider is considered "configured" when the - ``ddgs`` package is importable — there is nothing else to set up. - """ - - def provider_name(self) -> str: - return "ddgs" - - def is_configured(self) -> bool: - """Return True when the ``ddgs`` package is importable. - - Called at tool-registration time; must not perform network I/O. - """ - 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. - - Returns ``{"success": True, "data": {"web": [...]}}`` on success or - ``{"success": False, "error": str}`` on failure (missing package, - rate-limited, network error, etc.). - """ - try: - from ddgs import DDGS # type: ignore - except ImportError: - return { - "success": False, - "error": "ddgs package is not installed — run `pip install ddgs`", - } - - # DDGS().text yields at most `max_results` items; we cap defensively - # in case the package ignores the hint. - safe_limit = max(1, int(limit)) - - try: - web_results = [] - with DDGS() as client: - for i, hit in enumerate(client.text(query, max_results=safe_limit)): - if i >= safe_limit: - break - url = str(hit.get("href") or hit.get("url") or "") - web_results.append( - { - "title": str(hit.get("title", "")), - "url": url, - "description": str(hit.get("body", "")), - "position": i + 1, - } - ) - except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions - logger.warning("DDGS search error: %s", exc) - return {"success": False, "error": f"DuckDuckGo search failed: {exc}"} - - logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) - return {"success": True, "data": {"web": web_results}} diff --git a/tools/web_providers/searxng.py b/tools/web_providers/searxng.py deleted file mode 100644 index 589b0a2b337..00000000000 --- a/tools/web_providers/searxng.py +++ /dev/null @@ -1,132 +0,0 @@ -"""SearXNG web search provider. - -SearXNG is a free, self-hosted, privacy-respecting metasearch engine. -It implements ``WebSearchProvider`` only — there is no extract capability. - -Configuration:: - - # ~/.hermes/.env - SEARXNG_URL=http://localhost:8080 - - # Use SearXNG for search, pair with any extract provider: - # ~/.hermes/config.yaml - web: - search_backend: "searxng" - extract_backend: "firecrawl" - -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). -""" - -from __future__ import annotations - -import logging -import os -from typing import Any, Dict - -from tools.web_providers.base 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. - - 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: - 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()) - - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a search against the configured SearXNG instance. - - Returns normalized results:: - - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } - - On failure returns ``{"success": False, "error": str}``. - """ - import httpx - - base_url = os.getenv("SEARXNG_URL", "").strip().rstrip("/") - if not base_url: - return {"success": False, "error": "SEARXNG_URL is not set"} - - params: Dict[str, Any] = { - "q": query, - "format": "json", - "pageno": 1, - } - - try: - resp = httpx.get( - f"{base_url}/search", - params=params, - timeout=15, - headers={"Accept": "application/json"}, - ) - 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}"} - 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}"} - - 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"} - - raw_results = data.get("results", []) - - # SearXNG may return a score field; sort descending and cap to limit. - sorted_results = sorted( - raw_results, - key=lambda r: float(r.get("score", 0)), - reverse=True, - )[:limit] - - web_results = [ - { - "title": str(r.get("title", "")), - "url": str(r.get("url", "")), - "description": str(r.get("content", "")), - "position": i + 1, - } - for i, r in enumerate(sorted_results) - ] - - logger.info( - "SearXNG search '%s': %d results (from %d raw, limit %d)", - query, - len(web_results), - len(raw_results), - limit, - ) - - return {"success": True, "data": {"web": web_results}} From 0a7cbd33424732694cc6e7b886376ee221613e03 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:06:29 +0530 Subject: [PATCH 059/214] fix(plugins): filter resolution by is_available() in web + image_gen registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both web_search_registry._resolve() and image_gen_registry.get_active_provider() walked their registered providers and returned the first one matching the capability flag — without checking whether that provider was actually usable. On a fresh install with no credentials at all, this meant get_active_search_provider() returned `brave-free` (legacy preference order) even though BRAVE_SEARCH_API_KEY was unset, leading the dispatcher to surface a "BRAVE_SEARCH_API_KEY is not set" error for a provider the user never chose. Same bug shape in image_gen for FAL. Resolution semantics now match tools.web_tools._get_backend(): 1. Explicit config name wins, ignoring is_available() — the dispatcher surfaces a precise "X_API_KEY is not set" error rather than silently switching backends. Matches user expectation: "I configured X, tell me what's wrong with X." 2. Fallback (no explicit config) walks the legacy preference order filtered by is_available() — pick the highest-priority backend the user actually has credentials for. is_available() is wrapped in a try/except so a buggy provider doesn't brick resolution. E2E verified: - No creds + no config: get_active_search_provider() -> None - Explicit brave-free + no key: get_active_search_provider() -> brave-free (and .is_available() correctly reports False) This fix was identified during the spike (#25182 finding #1) and is fold-in to the same PR rather than a follow-up. --- agent/image_gen_registry.py | 37 ++++++++++++++++++++----- agent/web_search_registry.py | 52 +++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/agent/image_gen_registry.py b/agent/image_gen_registry.py index 715133231cb..5d14a6f1ece 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/web_search_registry.py b/agent/web_search_registry.py index 8f1e884b3cf..45f2a0f8883 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -114,7 +114,31 @@ _LEGACY_PREFERENCE = ("brave-free", "firecrawl", "searxng", "ddgs") def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: - """Resolve the active provider for a capability ("search" | "extract").""" + """Resolve the active provider for a capability ("search" | "extract"). + + 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 looking for a provider whose + ``supports_()`` is True AND whose ``is_available()`` is + True. 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) @@ -125,6 +149,17 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc return bool(p.supports_extract()) 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): @@ -140,13 +175,24 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc configured, capability, ) - eligible = [p for p in snapshot.values() if _capable(p)] + # 2. + 3. Fallback path — filter by availability so we don't surface + # a provider the user has no credentials for. Without this filter, + # brave-free's slot in the legacy preference order would make it + # the "active" provider 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): + if ( + provider is not None + and _capable(provider) + and _is_available_safe(provider) + ): return provider return None From e3f0a8889195d3936762b375c659bdbcc394236c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:08:03 +0530 Subject: [PATCH 060/214] feat(web): extend ABC with supports_crawl and async-extract semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ABC additions to cover the surface area of the remaining four providers (exa, parallel, tavily, firecrawl) which were untouched by the initial spike: 1. supports_crawl() + crawl() — Tavily natively crawls a seed URL via its /crawl endpoint. Exposing supports_crawl=True lets the crawl tool's dispatcher route to Tavily when configured, falling back to the auxiliary-model summarization path otherwise. Firecrawl could add this in a follow-up (the SDK supports it; we just don't surface it as a tool today). 2. Async-or-sync extract() — Parallel's SDK is natively async (AsyncParallel.beta.extract); Exa and Tavily are sync; Firecrawl is sync but called inside asyncio.to_thread() with a 60s timeout. The ABC docstring now permits either shape: implementations declare their own sync/async signature and the dispatcher uses inspect.iscoroutinefunction to detect and await. Also adds get_active_crawl_provider() to web_search_registry mirroring the search/extract resolvers, with web.crawl_backend as the explicit override config key. No behavior change on its own — these are scaffolds for the four remaining provider migrations. --- agent/web_search_provider.py | 70 ++++++++++++++++++++++++++++++++++-- agent/web_search_registry.py | 18 +++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py index 605af9d5cf5..0e8b31547fa 100644 --- a/agent/web_search_provider.py +++ b/agent/web_search_provider.py @@ -99,7 +99,32 @@ class WebSearchProvider(abc.ABC): return True def supports_extract(self) -> bool: - """Return True if this provider implements :meth:`extract`.""" + """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]: @@ -113,20 +138,59 @@ class WebSearchProvider(abc.ABC): f"{self.name} does not support search (override supports_search)" ) - def extract(self, urls: List[str], **kwargs: Any) -> Dict[str, Any]: + 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. - ``kwargs`` may carry forward-compat fields (e.g. ``include_raw``, + 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. diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index 45f2a0f8883..8425c129910 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -114,7 +114,7 @@ _LEGACY_PREFERENCE = ("brave-free", "firecrawl", "searxng", "ddgs") def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: - """Resolve the active provider for a capability ("search" | "extract"). + """Resolve the active provider for a capability ("search" | "extract" | "crawl"). Resolution rules (in order): @@ -147,6 +147,8 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc 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: @@ -218,6 +220,20 @@ def get_active_extract_provider() -> Optional[WebSearchProvider]: 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 — only Tavily implements it among built-in + providers. Most callers should expect ``None`` and fall back to a + different strategy (e.g. summarize-via-LLM). + """ + 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: From ec8449e9c688b1e9cb8d47856e32f0a32a2d391b Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:11:58 +0530 Subject: [PATCH 061/214] =?UTF-8?q?feat(web):=20exa=20plugin=20=E2=80=94?= =?UTF-8?q?=20first=20multi-capability=20migration=20(search=20+=20extract?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates Exa from the inline `_exa_search()` / `_exa_extract()` helpers in tools/web_tools.py to a bundled plugin at plugins/web/exa/. This is the first plugin in this PR to advertise supports_extract=True, exercising the multi-capability ABC path that the initial three migrations (brave_free, ddgs, searxng — all search-only) did not cover. Both Exa methods are sync — the SDK is sync-only. The web_extract_tool dispatcher in tools/web_tools.py will continue to call them inline until Task "dispatch-extract-all" cuts it over to the registry. Behaviour preserved bit-for-bit aside from the ABC method-name change: - is_configured() -> is_available() - provider_name() -> name (property) - "exa" stays as the registered name - Module-level `_exa_client` cache + lazy `from exa_py import Exa` preserved at the new location. - Errors (ValueError for missing API key, ImportError for missing SDK, generic Exception) caught and surfaced as {"success": False, "error": ...} instead of raising. Adds "exa" to _WEB_PLUGIN_SKIPLIST in hermes_cli/tools_config.py so the hardcoded TOOL_CATEGORIES["web"] row and the plugin-injected row don't duplicate during the spike. The skip-list goes away in the cleanup phase along with the hardcoded row. The legacy inline `_exa_search` / `_exa_extract` / `_get_exa_client` / `_exa_client` in tools/web_tools.py are NOT deleted yet — the dispatcher still references them. They go away in the next dispatcher-cutover commit. E2E verified: - Plugin discovers + registers - .supports_search/.supports_extract/.supports_crawl = (True, True, False) - .get_setup_schema() returns the picker row shape - resolve(): explicit exa + EXA_API_KEY -> exa; without key -> exa (registered but unavailable, dispatcher surfaces "EXA_API_KEY not set" error) --- hermes_cli/tools_config.py | 2 +- plugins/web/exa/__init__.py | 15 +++ plugins/web/exa/plugin.yaml | 7 ++ plugins/web/exa/provider.py | 208 ++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 plugins/web/exa/__init__.py create mode 100644 plugins/web/exa/plugin.yaml create mode 100644 plugins/web/exa/provider.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index bb357e63d41..94c1b96a06a 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1586,7 +1586,7 @@ def _plugin_video_gen_providers() -> list[dict]: # removed and this helper becomes the sole source of web-provider picker # rows (matching how Spotify / Google Meet are surfaced today purely from # their plugins). -_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng"}) +_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa"}) def _plugin_web_search_providers() -> list[dict]: diff --git a/plugins/web/exa/__init__.py b/plugins/web/exa/__init__.py new file mode 100644 index 00000000000..d2ef3f16cf6 --- /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 00000000000..1eceefb6ac5 --- /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 00000000000..4daaa5f13dd --- /dev/null +++ b/plugins/web/exa/provider.py @@ -0,0 +1,208 @@ +"""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 cache for the Exa client so we don't reconstruct it per +# call. Matches the legacy `_exa_client` pattern in tools/web_tools.py. +_exa_client: Any = None + + +def _get_exa_client() -> Any: + """Lazy-import and cache an Exa SDK client. + + Mirrors :func:`tools.web_tools._get_exa_client`. Raises ``ValueError`` + when ``EXA_API_KEY`` is unset — the dispatcher catches that and + surfaces a typed error response. + """ + global _exa_client + + if _exa_client is not None: + return _exa_client + + 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 + + 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 + + +def _reset_client_for_tests() -> None: + """Drop the cached Exa client so tests can re-instantiate cleanly.""" + global _exa_client + _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", + }, + ], + } From 48166461093982755afd60166b1b96e2c93c48ed Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:13:40 +0530 Subject: [PATCH 062/214] =?UTF-8?q?feat(web):=20parallel=20plugin=20?= =?UTF-8?q?=E2=80=94=20first=20async-extract=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates Parallel.ai from inline `_parallel_search()` / `_parallel_extract()` in tools/web_tools.py to a bundled plugin at plugins/web/parallel/. First plugin in the codebase to expose an async :meth:`extract`: - search() is sync — Parallel.beta.search - extract() is **async def** — AsyncParallel.beta.extract The ABC's docstring on supports_extract() already permits sync-or-async; this commit is the first to exercise the async path. The web_extract_tool dispatcher (next commit) detects coroutines via inspect.iscoroutinefunction and awaits accordingly. Behavior preserved: - PARALLEL_API_KEY required (raises ValueError if missing → surfaced as {"success": False, "error": "..."} instead) - PARALLEL_SEARCH_MODE env var honored (agentic|fast|one-shot, default agentic), validated via _resolve_search_mode() - Limit capped at 20 server-side via min(limit, 20) - Per-URL failure mode preserved: response.errors[] each become a result dict with an "error" field rather than raising - Module-level _parallel_client / _async_parallel_client caches kept (mirrors legacy singleton pattern) Adds "parallel" to _WEB_PLUGIN_SKIPLIST in hermes_cli/tools_config.py so the picker doesn't double-list. The legacy inline _parallel_search, _parallel_extract, _get_parallel_client, _get_async_parallel_client in tools/web_tools.py are NOT deleted yet — the dispatcher still calls them. They go away when the dispatcher cuts over. E2E verified: - inspect.iscoroutinefunction(p.search) -> False - inspect.iscoroutinefunction(p.extract) -> True - extract() returns a coroutine (not a list) - 5 providers register correctly (brave-free, ddgs, exa, parallel, searxng) --- hermes_cli/tools_config.py | 2 +- plugins/web/parallel/__init__.py | 16 ++ plugins/web/parallel/plugin.yaml | 7 + plugins/web/parallel/provider.py | 265 +++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 plugins/web/parallel/__init__.py create mode 100644 plugins/web/parallel/plugin.yaml create mode 100644 plugins/web/parallel/provider.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 94c1b96a06a..407e24dfca5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1586,7 +1586,7 @@ def _plugin_video_gen_providers() -> list[dict]: # removed and this helper becomes the sole source of web-provider picker # rows (matching how Spotify / Google Meet are surfaced today purely from # their plugins). -_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa"}) +_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa", "parallel"}) def _plugin_web_search_providers() -> list[dict]: diff --git a/plugins/web/parallel/__init__.py b/plugins/web/parallel/__init__.py new file mode 100644 index 00000000000..2a109894dc5 --- /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 00000000000..01bf0da58ef --- /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 00000000000..2dff514feb3 --- /dev/null +++ b/plugins/web/parallel/provider.py @@ -0,0 +1,265 @@ +"""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 client caches mirroring the legacy `tools.web_tools._parallel_client` +# / `_async_parallel_client` pattern. Per-process singletons so we don't +# pay SDK construction cost per call. +_parallel_client: Any = None +_async_parallel_client: Any = None + + +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.""" + global _parallel_client + if _parallel_client is not None: + return _parallel_client + + _ensure_parallel_sdk_installed() + from parallel import Parallel # noqa: WPS433 — deliberately lazy + + 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_client() -> Any: + """Lazy-load + cache the async Parallel client.""" + global _async_parallel_client + if _async_parallel_client is not None: + return _async_parallel_client + + _ensure_parallel_sdk_installed() + from parallel import AsyncParallel # noqa: WPS433 — deliberately lazy + + 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 + + +def _reset_clients_for_tests() -> None: + """Drop both cached clients so tests can re-instantiate cleanly.""" + global _parallel_client, _async_parallel_client + _parallel_client = None + _async_parallel_client = None + + +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", + }, + ], + } From 31fcde876c3730c33a53541931ad073e705cdfef Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:16:02 +0530 Subject: [PATCH 063/214] =?UTF-8?q?feat(web):=20tavily=20plugin=20?= =?UTF-8?q?=E2=80=94=20first=20three-capability=20plugin=20(search=20+=20e?= =?UTF-8?q?xtract=20+=20crawl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates Tavily from inline _tavily_request() / _normalize_tavily_* helpers in tools/web_tools.py to a bundled plugin at plugins/web/tavily/. First plugin in the codebase to advertise supports_crawl=True. Tavily is unique among built-in backends in offering a native /crawl endpoint that walks linked pages from a seed URL with optional natural-language instructions and depth ("basic" or "advanced"). Capabilities: - supports_search() -> True (Tavily /search) - supports_extract() -> True (Tavily /extract) - supports_crawl() -> True (Tavily /crawl) All sync (httpx.post under the hood). The crawl method accepts forward-compat kwargs (instructions, depth, limit) and is gated against unsafe URLs/policy by the dispatcher in web_crawl_tool — exactly as before. Behavior preserved: - TAVILY_API_KEY required (ValueError → typed error response) - TAVILY_BASE_URL env override honored - /crawl requires both body auth AND Bearer header — preserved - failed_results[] and failed_urls[] response keys mapped to per-URL items with error fields rather than raising - max_results capped at 20 server-side Adds "tavily" to _WEB_PLUGIN_SKIPLIST. The legacy inline _tavily_request / _normalize_tavily_search_results / _normalize_tavily_documents / _TAVILY_BASE_URL in tools/web_tools.py are NOT deleted yet — search/extract dispatch and the entire web_crawl_tool function still reference them. They go away when those dispatchers are cut over to the registry. E2E verified: - Tavily registers with all 3 capabilities - Provider list now: brave-free, ddgs, exa, parallel, searxng, tavily --- hermes_cli/tools_config.py | 2 +- plugins/web/tavily/__init__.py | 15 ++ plugins/web/tavily/plugin.yaml | 7 + plugins/web/tavily/provider.py | 285 +++++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 plugins/web/tavily/__init__.py create mode 100644 plugins/web/tavily/plugin.yaml create mode 100644 plugins/web/tavily/provider.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 407e24dfca5..ba779900851 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1586,7 +1586,7 @@ def _plugin_video_gen_providers() -> list[dict]: # removed and this helper becomes the sole source of web-provider picker # rows (matching how Spotify / Google Meet are surfaced today purely from # their plugins). -_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa", "parallel"}) +_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa", "parallel", "tavily"}) def _plugin_web_search_providers() -> list[dict]: diff --git a/plugins/web/tavily/__init__.py b/plugins/web/tavily/__init__.py new file mode 100644 index 00000000000..be0b21dbe78 --- /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 00000000000..7eb1e9fc456 --- /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 00000000000..fc3406d2ce9 --- /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``) — Tavily is the only + built-in backend that natively crawls + +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", + }, + ], + } From 143184e9438c658c1080f45dbfc29e33044ed0d9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:20:16 +0530 Subject: [PATCH 064/214] =?UTF-8?q?feat(web):=20firecrawl=20plugin=20?= =?UTF-8?q?=E2=80=94=20largest=20migration=20(search=20+=20async=20extract?= =?UTF-8?q?=20+=20dual=20auth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates Firecrawl from inline code in tools/web_tools.py to a bundled plugin at plugins/web/firecrawl/. By line count this is the largest of the seven provider migrations: the firecrawl path captured most of the file's vendor-specific complexity. What moved into the plugin (all previously in tools/web_tools.py): Lazy Firecrawl SDK proxy - _load_firecrawl_cls() — caches the imported SDK class - _FirecrawlProxy + Firecrawl singleton — defers ~200ms of SDK imports until first construction or isinstance check. Client construction (dual auth) - _get_direct_firecrawl_config() — direct FIRECRAWL_API_KEY/URL path - _get_firecrawl_gateway_url() — managed Nous tool-gateway URL - _is_tool_gateway_ready() — gateway URL + Nous token check - _has_direct_firecrawl_config() — direct config present? - _get_firecrawl_client() — combined client construction honoring web.use_gateway - check_firecrawl_api_key() — top-level "is firecrawl usable" - _firecrawl_backend_help_suffix() — managed-gateway help string - _raise_web_backend_configuration_error() — typed misconfig error Response shape normalization (vendor-specific) - _to_plain_object(), _normalize_result_list() — SDK→dict helpers - _extract_web_search_results() — handles SDK/direct/gateway shapes - _extract_scrape_payload() — nested-data unwrap for scrape Per-URL extract loop - 60s asyncio.wait_for timeout per URL - Pre-scrape website-policy gate - Post-scrape redirect-aware SSRF re-check - Format-aware content selection (markdown / html / auto) - Per-URL errors returned as {"error": str} entries, no raises Extract is declared `async def` — each URL is scraped in asyncio.to_thread(...). This is the second async-extract plugin after parallel. The plugin re-exports `Firecrawl` (the lazy proxy) and `check_firecrawl_api_key()` so existing tests doing `patch("tools.web_tools.Firecrawl")` or `monkeypatch.setattr(web_tools, "check_firecrawl_api_key", ...)` keep working — tools/web_tools.py re-exports both names in the next dispatcher-cutover commit. Note: web_crawl_tool still has its own Firecrawl crawl path inline (separate from extract); the Firecrawl SDK supports /crawl but we don't expose supports_crawl=True on this plugin yet. Tavily handles crawl today. Adding Firecrawl crawl is a clean follow-up. Adds "firecrawl" to _WEB_PLUGIN_SKIPLIST. E2E verified: - All 7 providers register: brave-free, ddgs, exa, firecrawl, parallel, searxng, tavily - inspect.iscoroutinefunction(firecrawl.extract) -> True - Firecrawl proxy is a callable lazy proxy at module level - check_firecrawl_api_key reflects FIRECRAWL_API_KEY presence --- hermes_cli/tools_config.py | 4 +- plugins/web/firecrawl/__init__.py | 28 ++ plugins/web/firecrawl/plugin.yaml | 7 + plugins/web/firecrawl/provider.py | 565 ++++++++++++++++++++++++++++++ 4 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 plugins/web/firecrawl/__init__.py create mode 100644 plugins/web/firecrawl/plugin.yaml create mode 100644 plugins/web/firecrawl/provider.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index ba779900851..76c17e65cd5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1586,7 +1586,9 @@ def _plugin_video_gen_providers() -> list[dict]: # removed and this helper becomes the sole source of web-provider picker # rows (matching how Spotify / Google Meet are surfaced today purely from # their plugins). -_WEB_PLUGIN_SKIPLIST = frozenset({"brave-free", "ddgs", "searxng", "exa", "parallel", "tavily"}) +_WEB_PLUGIN_SKIPLIST = frozenset({ + "brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", +}) def _plugin_web_search_providers() -> list[dict]: diff --git a/plugins/web/firecrawl/__init__.py b/plugins/web/firecrawl/__init__.py new file mode 100644 index 00000000000..4cb9dd63d0f --- /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 00000000000..063af47d738 --- /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 00000000000..64268448348 --- /dev/null +++ b/plugins/web/firecrawl/provider.py @@ -0,0 +1,565 @@ +"""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 + +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) +# --------------------------------------------------------------------------- + +_firecrawl_client: Any = None +_firecrawl_client_config: Any = None + + +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.""" + from tools.tool_backend_helpers import build_vendor_gateway_url + + return build_vendor_gateway_url("firecrawl") + + +def _is_tool_gateway_ready() -> bool: + """Return True when gateway URL + Nous Subscriber token are available.""" + from tools.managed_tool_gateway import ( + read_nous_access_token, + resolve_managed_tool_gateway, + ) + + 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 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.""" + from tools.tool_backend_helpers import managed_nous_tools_enabled + + if not 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.""" + from tools.tool_backend_helpers import managed_nous_tools_enabled + + 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 _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. + """ + global _firecrawl_client, _firecrawl_client_config + + from tools.managed_tool_gateway import ( + read_nous_access_token, + resolve_managed_tool_gateway, + ) + from tools.tool_backend_helpers import prefers_gateway + + 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 + + _firecrawl_client = Firecrawl(**kwargs) + _firecrawl_client_config = client_config + return _firecrawl_client + + +def _reset_client_for_tests() -> None: + """Drop the cached Firecrawl client so tests can re-instantiate cleanly.""" + global _firecrawl_client, _firecrawl_client_config + _firecrawl_client = None + _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 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`. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Firecrawl search: '%s' (limit=%d)", query, limit) + response = _get_firecrawl_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 ValueError as exc: + return {"success": False, "error": str(exc)} + except ImportError as exc: + return {"success": False, "error": f"Firecrawl SDK not installed: {exc}"} + 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; import inside + # the function so the plugin doesn't pay the cost when never used. + from tools.website_policy import check_website_access + + 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 + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Firecrawl", + "badge": "paid · optional gateway", + "tag": ( + "Mainstream search + extract; 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", + }, + ], + } From b05253ceed5f9d139f4a7d8705f5c97fcf644a2c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:26:42 +0530 Subject: [PATCH 065/214] refactor(web): dispatch all three tools through web_search_registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts over web_search_tool, web_extract_tool, and web_crawl_tool in tools/web_tools.py to dispatch through agent.web_search_registry instead of the legacy hardcoded if-elif backend chains. Per-tool changes: web_search_tool (sync) Replace 5 backend branches (parallel, exa, registry-3-providers, tavily, firecrawl-fallthrough) with a single registry path: 1. _get_search_backend() resolves the configured name 2. _wsp_get_provider(name) for explicit-config-wins semantics 3. get_active_search_provider() fallback for typo / unknown name 4. provider.search(query, limit) — sync for all 7 providers web_extract_tool (async) Replace 4 backend branches (parallel-async, exa-sync, tavily-sync, search-only-error, firecrawl-perurl-loop) with: 1. Same provider resolution as search. 2. When configured backend IS registered but doesn't support extract (search-only providers like brave-free), surface a typed "search-only" error matching the legacy text — tests assert that wording. 3. inspect.iscoroutinefunction(provider.extract) detects sync vs async: parallel + firecrawl are async; exa + tavily are sync. Sync extracts run in asyncio.to_thread() so we don't block. web_crawl_tool (async) Replace tavily-specific branch + search-only-error block with: 1. _wsp_get_provider(backend) — explicit config first 2. Search-only typed error when the configured name doesn't support crawl (matches legacy phrasing) 3. get_active_crawl_provider() fallback otherwise 4. provider.crawl(url, **kwargs) — async-or-sync dispatch as above 5. Response post-processing (LLM summarization, trimming) stays unchanged — it's not provider-specific. When no plugin advertises supports_crawl, falls through to the existing Firecrawl-via-web-summarize path below (unchanged). Test updates (2 tests in tests/tools/test_web_tools_config.py): - test_web_search_clamps_limit_before_backend_call: patch("tools.web_tools._parallel_search") -> patch the registry provider returned by agent.web_search_registry.get_provider - test_search_error_response_does_not_expose_diagnostics: patch("tools.web_tools._get_firecrawl_client") -> same pattern Tests unchanged (still pass): - All TestXBackendWiring classes (test _get_backend / _is_backend_available config-resolution, independent of dispatch) - All TestXSearchOnlyErrors classes (test the search-only error path via web_extract_tool / web_crawl_tool — error text preserved) - 141 passing web tests total, 0 regressions. Dead-code cleanup deferred to a follow-up commit so this diff stays focused on the cutover. After this commit: - tools.web_tools._exa_search / _exa_extract / _parallel_search / _parallel_extract / _tavily_request / _normalize_tavily_* / _get_firecrawl_client / _extract_web_search_results / _extract_scrape_payload / _to_plain_object / _normalize_result_list are no longer called by the dispatchers, but still exist. - The config-resolution layer (_get_backend, _is_backend_available, _is_tool_gateway_ready, _has_direct_firecrawl_config) IS still in use and must stay. - The Firecrawl proxy and check_firecrawl_api_key are still imported by integration tests and patched by unit tests — must stay (or be re-exported from the plugin). --- tests/tools/test_web_tools_config.py | 35 ++- tools/web_tools.py | 378 +++++++++++---------------- 2 files changed, 175 insertions(+), 238 deletions(-) diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 25ef647f7c0..87fc27cc372 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -485,15 +485,28 @@ class TestWebSearchSchema: 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") + # 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_backend", return_value="firecrawl"), \ - patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \ + 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/tools/web_tools.py b/tools/web_tools.py index 80eabe4d8b9..d0d9919d25b 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1229,102 +1229,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 - - # Plugin-backed providers (brave-free, ddgs, searxng) — dispatched - # through agent.web_search_registry. Inline providers (parallel, - # exa, tavily, firecrawl) keep their own branches below until they - # too migrate to plugins. Spike scope: only the three providers - # already living in tools/web_providers/ are moved to plugins; the - # rest follow in the real migration PR. - if backend in {"brave-free", "ddgs", "searxng"}: - from agent.web_search_registry import get_provider as _wsp_get_provider - - provider = _wsp_get_provider(backend) - if provider is None or not provider.supports_search(): - response_data = { - "success": False, - "error": ( - f"Web search provider '{backend}' is not registered. " - "Run `hermes tools` to set up a provider." - ), - } - else: - 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) - _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) @@ -1415,129 +1358,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: @@ -1725,8 +1607,37 @@ 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(): + # Configured name IS registered but doesn't support crawl + # (search-only providers like brave-free / ddgs / searxng). + # Surface a typed error rather than silently switching to a + # different crawl backend. + 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, + ) + if crawl_provider is None: + crawl_provider = get_active_crawl_provider() + + if crawl_provider is not None: # Ensure URL has protocol if not url.startswith(('http://', 'https://')): url = f'https://{url}' @@ -1747,18 +1658,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, - } - if instructions: - payload["instructions"] = instructions - raw = _tavily_request("crawl", payload) - results = _normalize_tavily_documents(raw, fallback_url=url) + 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: + 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', [])) @@ -1809,14 +1730,9 @@ async def web_crawl_tool( _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) + # No registered provider supports crawl. Fall through to the + # Firecrawl-via-summarize path below (legacy behavior) when + # Firecrawl credentials are configured. # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API if not check_firecrawl_api_key(): From 5e54330e27d670dbf922c6d16498ca0c7d6ad08e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:34:28 +0530 Subject: [PATCH 066/214] fix(web): preserve firecrawl crawl + website-policy gate after migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions discovered by running the full tests/tools/ suite after the dispatcher cutover, both fixed in this commit: 1. web_crawl_tool incorrectly errored "search-only" for firecrawl --------------------------------------------------------------------- The cutover treated any provider with supports_crawl()==False as a search-only backend and returned the typed search-only error. But firecrawl can crawl via the legacy multi-page-extract path inside web_crawl_tool — it just doesn't expose supports_crawl on the plugin (adding native firecrawl crawl is a clean follow-up). Fix: only emit the search-only error when the provider supports NEITHER crawl NOR extract (brave-free / ddgs / searxng). When the provider supports extract but not crawl (firecrawl), fall through to the legacy firecrawl-via-extract path below. 2. firecrawl plugin's check_website_access wasn't patchable --------------------------------------------------------------------- The plugin imported `from tools.website_policy import check_website_access` INSIDE the extract() function body, so monkeypatching the name on plugins.web.firecrawl.provider had no effect — the inner import re-bound the name on every call. Fix: hoist the import to module level. Cheap (website_policy itself has no heavy deps) and makes the standard monkeypatch.setattr(firecrawl_provider, "check_website_access", ...) pattern work. Test updates (tests/tools/test_website_policy.py — 4 tests): - test_web_extract_short_circuits_blocked_url - test_web_extract_blocks_redirected_final_url Both: patch the gate at plugins.web.firecrawl.provider (where it runs after migration) and force the firecrawl plugin to be the active extract provider via FIRECRAWL_API_KEY. - test_web_crawl_short_circuits_blocked_url - test_web_crawl_blocks_redirected_final_url Both: unchanged — the dispatcher-level gate at tools.web_tools.py line 1651 still uses the imported `check_website_access` name and the firecrawl-fallthrough path is exercised as before. Verified: 22/22 tests/tools/test_website_policy.py pass. --- plugins/web/firecrawl/provider.py | 7 +++--- tests/tools/test_website_policy.py | 19 ++++++++++++---- tools/web_tools.py | 36 +++++++++++++++++------------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index 64268448348..5c092f4e4e3 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -51,6 +51,7 @@ 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__) @@ -417,9 +418,9 @@ class FirecrawlWebSearchProvider(WebSearchProvider): else: formats = ["markdown", "html"] - # check_website_access is the legacy policy gate; import inside - # the function so the plugin doesn't pay the cost when never used. - from tools.website_policy import check_website_access + # 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]] = [] diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 4573e027650..efc0e500de5 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 @@ async def test_web_extract_blocks_redirected_final_url(monkeypatch): }, } - 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)) diff --git a/tools/web_tools.py b/tools/web_tools.py index d0d9919d25b..2fc9ebdf99f 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1618,22 +1618,26 @@ async def web_crawl_tool( crawl_provider = _wsp_get_provider(backend) if backend else None if crawl_provider is not None and not crawl_provider.supports_crawl(): - # Configured name IS registered but doesn't support crawl - # (search-only providers like brave-free / ddgs / searxng). - # Surface a typed error rather than silently switching to a - # different crawl backend. - 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, - ) + # 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() From 748f3e016b252a7b2a927a32dce04c92d9980021 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:47:22 +0530 Subject: [PATCH 067/214] refactor(web): delete inline vendor helpers, re-export from plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes ~580 lines of dead code from tools/web_tools.py that were superseded by the plugin migration but kept around in the cutover commit to keep the diff focused. Replaces them with thin re-export shims so existing tests and external callers that reach for the legacy ``tools.web_tools.`` paths continue to work transparently. Deleted from tools/web_tools.py -------------------------------- - Lazy Firecrawl SDK proxy (_load_firecrawl_cls, _FirecrawlProxy, _FIRECRAWL_CLS_CACHE, the Firecrawl singleton) - Firecrawl client section (_get_direct_firecrawl_config, _get_firecrawl_gateway_url, _is_tool_gateway_ready, _has_direct_firecrawl_config, _raise_web_backend_configuration_error, _firecrawl_backend_help_suffix, _get_firecrawl_client) - Parallel client section (_get_parallel_client, _get_async_parallel_client, _parallel_client, _async_parallel_client) - Tavily client section (_TAVILY_BASE_URL, _tavily_request, _normalize_tavily_search_results, _normalize_tavily_documents) - Generic SDK normalizers (_to_plain_object, _normalize_result_list, _extract_web_search_results, _extract_scrape_payload) - Exa client section (_get_exa_client, _exa_client, _exa_search, _exa_extract) - Parallel helpers (_parallel_search, _parallel_extract) - Duplicate inline check_firecrawl_api_key Net: tools/web_tools.py drops from 2227 → 1613 lines (-614 lines). Re-exports added at top of tools/web_tools.py --------------------------------------------- - From plugins.web.firecrawl.provider: Firecrawl, _FirecrawlProxy, _FIRECRAWL_CLS_CACHE, _load_firecrawl_cls, _get_direct_firecrawl_config, _get_firecrawl_gateway_url, _is_tool_gateway_ready, _has_direct_firecrawl_config, _firecrawl_backend_help_suffix, _raise_web_backend_configuration_error, _get_firecrawl_client, _to_plain_object, _normalize_result_list, _extract_web_search_results, _extract_scrape_payload, check_firecrawl_api_key - From plugins.web.tavily.provider: _tavily_request, _normalize_tavily_search_results, _normalize_tavily_documents - From plugins.web.parallel.provider: _get_parallel_client, _get_async_parallel_client - From plugins.web.exa.provider: _get_exa_client Plus retained module-level imports for backward-compat with tests: - httpx (tests patch tools.web_tools.httpx for tavily request mocking) - build_vendor_gateway_url, _read_nous_access_token, resolve_managed_tool_gateway, managed_nous_tools_enabled, prefers_gateway (tests patch tools.web_tools.) Plugin indirection pattern (key technique) ------------------------------------------ For functions inside the firecrawl/parallel/exa plugins to honor unit-test patches that target ``tools.web_tools.``, the plugin implementations now do ``import tools.web_tools as _wt`` at call time and read helper names through that module (``_wt._read_nous_access_token``, ``_wt.Firecrawl``, ``_wt.prefers_gateway``, etc.). This makes the existing test patches transparently reach the plugin code without any test changes. The cached client globals (_firecrawl_client, _firecrawl_client_config, _parallel_client, _async_parallel_client, _exa_client) also now live on tools.web_tools so existing test setup_method handlers that reset ``tools.web_tools.__client = None`` between cases keep working. The plugins read/write the cache via getattr/setattr on the web_tools module. Verified -------- - 173/173 targeted web tests pass: test_web_providers.py, test_web_providers_brave_free.py, test_web_providers_ddgs.py, test_web_providers_searxng.py, test_web_tools_config.py, test_web_tools_tavily.py, test_website_policy.py, test_config_null_guard.py - Compile-clean (py_compile.compile passes) - All inline implementations now exist in exactly one place (plugins.web..provider) Follow-up clean-up ------------------ - Drop _WEB_PLUGIN_SKIPLIST + hardcoded TOOL_CATEGORIES["web"] rows (next commit) - Delete tools/web_providers/ directory entirely - Add tests/plugins/web/ coverage - Full tests/tools/ + tests/gateway/ regression sweep before promoting PR --- plugins/web/exa/provider.py | 39 +- plugins/web/firecrawl/provider.py | 67 ++-- plugins/web/parallel/provider.py | 69 ++-- tools/web_tools.py | 614 ++++-------------------------- 4 files changed, 181 insertions(+), 608 deletions(-) diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py index 4daaa5f13dd..d5735967758 100644 --- a/plugins/web/exa/provider.py +++ b/plugins/web/exa/provider.py @@ -40,14 +40,22 @@ _exa_client: Any = None def _get_exa_client() -> Any: """Lazy-import and cache an Exa SDK client. - Mirrors :func:`tools.web_tools._get_exa_client`. Raises ``ValueError`` - when ``EXA_API_KEY`` is unset — the dispatcher catches that and - surfaces a typed error response. + 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. """ - global _exa_client + import tools.web_tools as _wt - if _exa_client is not None: - return _exa_client + 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 @@ -60,22 +68,17 @@ def _get_exa_client() -> Any: from exa_py import Exa # noqa: WPS433 — deliberately lazy - 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 + 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.""" - global _exa_client - _exa_client = None + import tools.web_tools as _wt + + _wt._exa_client = None class ExaWebSearchProvider(WebSearchProvider): diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index 5c092f4e4e3..fdd5e1f3d55 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -136,20 +136,24 @@ def _get_direct_firecrawl_config() -> Optional[tuple]: def _get_firecrawl_gateway_url() -> str: """Return the configured Firecrawl gateway URL.""" - from tools.tool_backend_helpers import build_vendor_gateway_url + import tools.web_tools as _wt - return build_vendor_gateway_url("firecrawl") + return _wt.build_vendor_gateway_url("firecrawl") def _is_tool_gateway_ready() -> bool: - """Return True when gateway URL + Nous Subscriber token are available.""" - from tools.managed_tool_gateway import ( - read_nous_access_token, - resolve_managed_tool_gateway, - ) + """Return True when gateway URL + Nous Subscriber token are available. - return resolve_managed_tool_gateway( - "firecrawl", token_reader=read_nous_access_token + 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 @@ -169,9 +173,9 @@ def check_firecrawl_api_key() -> bool: def _firecrawl_backend_help_suffix() -> str: """Return optional managed-gateway guidance for Firecrawl help text.""" - from tools.tool_backend_helpers import managed_nous_tools_enabled + import tools.web_tools as _wt - if not managed_nous_tools_enabled(): + if not _wt.managed_nous_tools_enabled(): return "" return ( ", or use the Nous Tool Gateway via your subscription " @@ -181,14 +185,14 @@ def _firecrawl_backend_help_suffix() -> str: def _raise_web_backend_configuration_error() -> None: """Raise a clear error for unsupported web backend configuration.""" - from tools.tool_backend_helpers import managed_nous_tools_enabled + 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 managed_nous_tools_enabled(): + 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." @@ -204,21 +208,24 @@ def _get_firecrawl_client() -> Any: direct Firecrawl takes precedence when explicitly configured. Raises ValueError when neither path is usable. - """ - global _firecrawl_client, _firecrawl_client_config - from tools.managed_tool_gateway import ( - read_nous_access_token, - resolve_managed_tool_gateway, - ) - from tools.tool_backend_helpers import prefers_gateway + 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 prefers_gateway("web"): + if direct_config is not None and not _wt.prefers_gateway("web"): kwargs, client_config = direct_config else: - managed_gateway = resolve_managed_tool_gateway( - "firecrawl", token_reader=read_nous_access_token + managed_gateway = _wt.resolve_managed_tool_gateway( + "firecrawl", token_reader=_wt._read_nous_access_token ) if managed_gateway is None: logger.error( @@ -237,12 +244,16 @@ def _get_firecrawl_client() -> Any: managed_gateway.nous_user_token, ) - if _firecrawl_client is not None and _firecrawl_client_config == client_config: - return _firecrawl_client + 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 - _firecrawl_client = Firecrawl(**kwargs) - _firecrawl_client_config = client_config - return _firecrawl_client + # 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: diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 2dff514feb3..71aae39025a 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -37,8 +37,10 @@ from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) # Module-level client caches mirroring the legacy `tools.web_tools._parallel_client` -# / `_async_parallel_client` pattern. Per-process singletons so we don't -# pay SDK construction cost per call. +# / `_async_parallel_client` pattern. For tests, the canonical cache lives on +# tools.web_tools so existing setup_method() handlers that reset +# ``tools.web_tools._parallel_client = None`` keep working — we read/write +# the cache via that module rather than these module-level globals. _parallel_client: Any = None _async_parallel_client: Any = None @@ -62,41 +64,56 @@ def _ensure_parallel_sdk_installed() -> None: def _get_sync_client() -> Any: - """Lazy-load + cache the sync Parallel client.""" - global _parallel_client - if _parallel_client is not None: - return _parallel_client + """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" ) - _parallel_client = Parallel(api_key=api_key) - return _parallel_client - - -def _get_async_client() -> Any: - """Lazy-load + cache the async Parallel client.""" - global _async_parallel_client - if _async_parallel_client is not None: - return _async_parallel_client _ensure_parallel_sdk_installed() from parallel import AsyncParallel # noqa: WPS433 — deliberately lazy - 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 + client = AsyncParallel(api_key=api_key) + _wt._async_parallel_client = client + return client def _reset_clients_for_tests() -> None: @@ -106,6 +123,12 @@ def _reset_clients_for_tests() -> None: _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() diff --git a/tools/web_tools.py b/tools/web_tools.py index 2fc9ebdf99f..9265e57f3ec 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -46,52 +46,56 @@ import os 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 @@ from agent.auxiliary_client import ( 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: @@ -2015,21 +1566,6 @@ async def web_crawl_tool( # 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() From 24fe60faa2c471686803d97e33182ccec8e3ebe5 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:53:44 +0530 Subject: [PATCH 068/214] refactor(tools): drop hardcoded web picker rows + skiplist; plugins are sole source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the seven hardcoded TOOL_CATEGORIES["web"] provider rows that duplicated the plugin-registered providers, and deletes the _WEB_PLUGIN_SKIPLIST that existed to prevent duplicate picker rows during the migration. The Web Search & Extract category now derives its provider rows entirely from agent.web_search_registry via _plugin_web_search_providers(), matching how Spotify, Google Meet, and the image_gen plugins are surfaced. Removed (deduplicated against plugin schemas): - Firecrawl Cloud → plugins.web.firecrawl - Exa → plugins.web.exa - Parallel → plugins.web.parallel - Tavily → plugins.web.tavily - SearXNG → plugins.web.searxng - Brave Search (Free Tier) → plugins.web.brave_free - DuckDuckGo (ddgs) → plugins.web.ddgs (post_setup hook preserved) Retained in TOOL_CATEGORIES["web"]: - Nous Subscription — requires requires_nous_auth + managed_nous_feature + override_env_vars to drive the managed-gateway UX. Not a provider — a different *setup flow* for the firecrawl backend. - Firecrawl Self-Hosted — points firecrawl at a private Docker URL via FIRECRAWL_API_URL only. Same reason: UX setup-flow row, not a provider. These two rows describe alternative auth/billing paths for the firecrawl backend; they intentionally share web_backend="firecrawl" with the plugin row but light up different env-var prompts. Plugin schema extensions ------------------------ - ddgs plugin's get_setup_schema() now emits `post_setup: "ddgs"` so selection still triggers the pip-install hook in _run_post_setup(). - _plugin_web_search_providers() passes `post_setup` through verbatim when present in the schema (other future plugins like camofox / a hypothetical playwright-web plugin can opt in the same way). - Picker rows now carry both `web_backend` (legacy field consumed by setup + selection helpers) and `web_search_plugin_name` (informational marker), so behavior is identical between hardcoded and plugin-registered rows. Net diff -------- - hermes_cli/tools_config.py: -141/+50 lines (~91 lines net) - plugins/web/ddgs/provider.py: +7/-4 (post_setup field + badge polish) Verified -------- - Compile-clean for both files - Picker shows: 2 hardcoded rows (Nous Subscription, Firecrawl Self-Hosted) + 7 plugin rows (alphabetically: Brave Search, DuckDuckGo, Exa, Firecrawl, Parallel, SearXNG, Tavily). DuckDuckGo row carries post_setup="ddgs" for first-time install. - 173 web-specific tests still pass. --- hermes_cli/tools_config.py | 141 +++++++++++------------------------ plugins/web/ddgs/provider.py | 7 +- 2 files changed, 50 insertions(+), 98 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 76c17e65cd5..87474040530 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -245,6 +245,15 @@ TOOL_CATEGORIES = { "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", @@ -256,42 +265,6 @@ TOOL_CATEGORIES = { "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", @@ -301,32 +274,6 @@ TOOL_CATEGORIES = { {"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": { @@ -1577,28 +1524,27 @@ def _plugin_video_gen_providers() -> list[dict]: # Mirror of _plugin_image_gen_providers for web search backends. Surfaces -# plugin-registered web providers (brave-free / ddgs / searxng during the -# spike) so they appear in the "Web Search & Extract" picker row. While -# the legacy TOOL_CATEGORIES entries still cover those names, this helper -# skip-lists them to avoid duplicate rows. -# -# When the migration PR drops the hardcoded entries, the skip-list can be -# removed and this helper becomes the sole source of web-provider picker -# rows (matching how Spotify / Google Meet are surfaced today purely from -# their plugins). -_WEB_PLUGIN_SKIPLIST = frozenset({ - "brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", -}) - - +# 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 looks like a regular ``TOOL_CATEGORIES`` provider - row but carries a ``web_search_plugin_name`` marker so downstream - code can route through ``agent.web_search_registry`` instead of the - legacy hardcoded dispatch. Names already covered by hardcoded picker - rows during the spike are skipped via :data:`_WEB_PLUGIN_SKIPLIST`. + 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 @@ -1612,7 +1558,7 @@ def _plugin_web_search_providers() -> list[dict]: rows: list[dict] = [] for provider in providers: name = getattr(provider, "name", None) - if not name or name in _WEB_PLUGIN_SKIPLIST: + if not name: continue try: schema = provider.get_setup_schema() @@ -1620,15 +1566,18 @@ def _plugin_web_search_providers() -> list[dict]: 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", []), - "web_search_plugin_name": name, - } - ) + 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 @@ -1653,11 +1602,11 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: if cat.get("name") == "Video Generation": visible.extend(_plugin_video_gen_providers()) - # Inject plugin-registered web search backends. During the spike the - # three migrated providers (brave-free, ddgs, searxng) still have - # hardcoded TOOL_CATEGORIES entries — the helper skips them so the - # picker doesn't show duplicates. When the migration PR deletes those - # hardcoded rows, this injection becomes the sole source of truth. + # 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()) diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index 1cc6f9e7b68..e8846236a24 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -95,7 +95,10 @@ class DDGSWebSearchProvider(WebSearchProvider): def get_setup_schema(self) -> Dict[str, Any]: return { "name": "DuckDuckGo (ddgs)", - "badge": "free", - "tag": "No API key — community ddgs package (pip install 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", } From 39b4ebfceaeeb56d1c197dd22028053e5c2c1190 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 00:56:11 +0530 Subject: [PATCH 069/214] refactor(web): delete legacy tools/web_providers/ directory + migrate ABC tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the legacy in-tree provider scaffolding that PR #25182 fully replaced with the plugin architecture: tools/web_providers/__init__.py (6 lines) tools/web_providers/base.py (89 lines — old ABCs) tools/web_providers/ARCHITECTURE.md (73 lines — old design doc) These were the staging-ground ABCs and provider modules that the plugin migration absorbed. All seven web providers now implement the single :class:`agent.web_search_provider.WebSearchProvider` ABC and live under ``plugins/web//``. Nothing else in the tree imports ``tools.web_providers`` — verified via grep before deletion. Test migration (tests/tools/test_web_providers.py) -------------------------------------------------- Rewrote ``TestWebProviderABCs`` to test the new unified ABC at :mod:`agent.web_search_provider`: - test_cannot_instantiate_abc_directly — abstract ``name`` + ``is_available`` - test_concrete_search_only_provider_works — exercise default ``supports_extract=False`` / ``supports_crawl=False`` flags - test_concrete_multi_capability_provider_works — exercise all three capabilities, async extract supported (declared sync here for simplicity; real plugins like parallel + firecrawl use async) - test_search_only_provider_skips_extract_and_crawl — verify ``supports_*()`` flags default to False so search-only providers don't have to implement extract() or crawl() The 9 other tests in the file (per-capability backend selection, DEFAULT_CONFIG merge, dispatcher routing) test public helpers in ``tools.web_tools`` that still exist and pass unchanged. agent/web_search_provider.py docstring updated to reflect that the legacy ABCs no longer exist; the response-shape contract is preserved bit-for-bit so external consumers see no behavioral change. Net diff -------- - tools/web_providers/ removed (-168 lines) - tests/tools/test_web_providers.py rewritten ABC section (+78/-30 net, same coverage, new API) - agent/web_search_provider.py docstring (-3/+5 lines) Verified -------- - 173/173 targeted web tests pass - 12/12 ABC contract tests pass with the new interface - No remaining grep hits for ``tools.web_providers`` outside of intentional historical references in plugin docstrings. --- agent/web_search_provider.py | 13 +-- tests/tools/test_web_providers.py | 119 ++++++++++++++++++++++------ tools/web_providers/ARCHITECTURE.md | 73 ----------------- tools/web_providers/__init__.py | 6 -- tools/web_providers/base.py | 89 --------------------- 5 files changed, 102 insertions(+), 198 deletions(-) delete mode 100644 tools/web_providers/ARCHITECTURE.md delete mode 100644 tools/web_providers/__init__.py delete mode 100644 tools/web_providers/base.py diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py index 0e8b31547fa..ed3f79f270e 100644 --- a/agent/web_search_provider.py +++ b/agent/web_search_provider.py @@ -12,13 +12,14 @@ 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 plugin-facing surface. The legacy -:mod:`tools.web_providers.base` module retains its own ABCs for in-tree -consumers that haven't migrated yet; over time those will all flow through -this provider. +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 (mirrors the legacy contract in ``tools/web_providers/base.py`` -so the tool wrapper does not have to translate): +Response shape (preserved from the legacy contract): Search results:: diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 3c0abb307b0..c64b0a1b621 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -20,50 +20,121 @@ import pytest 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. + """ + + def test_cannot_instantiate_abc_directly(self): + from agent.web_search_provider import WebSearchProvider with pytest.raises(TypeError): WebSearchProvider() # type: ignore[abstract] - def test_cannot_instantiate_extract_provider(self): - from tools.web_providers.base import WebExtractProvider - - with pytest.raises(TypeError): - WebExtractProvider() # 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 extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: - return {"success": True, "data": [{"url": urls[0], "content": "x"}]} + + 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]: + 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 # --------------------------------------------------------------------------- diff --git a/tools/web_providers/ARCHITECTURE.md b/tools/web_providers/ARCHITECTURE.md deleted file mode 100644 index f4a7b335e87..00000000000 --- 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 15134175d21..00000000000 --- 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 21772189191..00000000000 --- 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.""" From e8cee87e8594747710bf600c5a4c0bee33b57bed Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 01:00:18 +0530 Subject: [PATCH 070/214] =?UTF-8?q?test(plugins):=20tests/plugins/web/=20?= =?UTF-8?q?=E2=80=94=20coverage=20for=20the=207-plugin=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 44 focused tests under tests/plugins/web/ covering the surface that the PR #25182 web-provider migration introduced. Complements the existing tests/tools/ coverage which is dispatcher-centric; this file is plugin-centric and tests each plugin + the registry directly. Test classes (44 tests, ~1.1s on 4 workers) ------------------------------------------- TestBundledPluginsRegister (16 tests) - All seven plugins present in the registry after _ensure_plugins_discovered() - Per-plugin parametrized capability-flag assertions (brave-free / ddgs / searxng: search-only; exa / parallel / firecrawl: search + extract; tavily: search + extract + crawl) - Every plugin exposes name + display_name properties - Every plugin returns a picker-compatible get_setup_schema() dict TestIsAvailable (7 tests) - Each premium plugin reports is_available()==False when its env var is absent and True once set (brave-free / searxng / tavily / exa / parallel) - firecrawl recognizes either FIRECRAWL_API_KEY or FIRECRAWL_API_URL as a "configured" signal - ddgs is the always-on fallback and must not raise from is_available() TestRegistryResolution (4 tests) - Option B semantics validated end-to-end: 1. Explicit configured provider wins even when is_available()==False (dispatcher surfaces typed credential errors, no silent switch) 2. Unknown/typo name falls back to first available legacy-preference provider 3. Asking for extract via a search-only backend falls back to an extract-capable available provider (capability-incompatible branch in _resolve()) 4. No config + no credentials → None (or ddgs if installed) TestAsyncExtractDispatch (4 tests) - parallel + firecrawl extract() are coroutine functions (async path in dispatcher uses await) - exa + tavily extract() are sync (dispatcher wraps in asyncio.to_thread) TestErrorResponseShapes (7 tests) - Plugins return typed error dicts (success=False + "error" key) when credentials are missing, never raise - async extract() returns list of per-URL error dicts - tavily crawl() returns {"results": [{"error": ...}]} on missing credentials Design notes ------------ - All tests use real imports of plugin modules — no mocking of provider classes themselves — so they catch drift in the ABC, registry, and glue layer simultaneously. Per the hermes-agent-dev skill's E2E testing guidance. - The autouse _isolate_env fixture clears every web-provider env var before each test so is_available() reflects the test's setup. - Resolution tests use the lower-level _resolve() directly rather than rebuilding the HERMES_HOME config dance — same observable behavior, no sys.modules.pop side-effects that would break the ABC isinstance check inside ctx.register_web_search_provider(). --- tests/plugins/web/__init__.py | 0 .../web/test_web_search_provider_plugins.py | 453 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 tests/plugins/web/__init__.py create mode 100644 tests/plugins/web/test_web_search_provider_plugins.py diff --git a/tests/plugins/web/__init__.py b/tests/plugins/web/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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 00000000000..62f0f15c4d3 --- /dev/null +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -0,0 +1,453 @@ +"""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", True, True, False), + ], + ) + 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] From 21e3a863bbbdb241b1390d0642928d276385298f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 01:37:57 +0530 Subject: [PATCH 071/214] feat(web): firecrawl plugin natively supports crawl; delete legacy inline path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-provider migration originally left firecrawl crawl as the only provider-specific code remaining inline in tools/web_tools.py (~250 lines of Firecrawl-specific crawl orchestration that didn't fit the plugin's existing surface). This commit closes that gap. What this adds -------------- 1. plugins/web/firecrawl/provider.py: implement async ``crawl(url, **kwargs)`` - Accepts the same kwargs as the dispatcher passes to any crawl provider (``instructions``, ``depth``, ``limit``); Firecrawl's /crawl endpoint ignores ``instructions`` and ``depth`` so we log and drop with a clear info message. - Wraps the sync SDK ``crawl()`` call in asyncio.to_thread so the gateway event loop isn't blocked on a multi-page crawl. - Preserves the response-shape normalization across pydantic / typed-object / dict variants that the legacy inline code did. - Preserves per-page website-policy re-check (catches blocked redirects after the SDK returns). - Returns the same {"results": [...]} shape so the dispatcher's shared LLM-summarization post-processing path works unchanged. - Sets supports_crawl() to True so the dispatcher routes through the plugin instead of the legacy fallthrough. 2. tools/web_tools.py: delete the entire legacy firecrawl crawl block that used to run after "No registered provider supports crawl" — ~270 lines including: - check_firecrawl_api_key gate + typed error - inline SSRF + website-policy seed-URL gate (dispatcher already does this) - Firecrawl client setup with crawl_params - 100+ lines of pydantic/dict/typed-object normalization - Per-page LLM-processing loop (kept in the dispatcher's shared post-processing path; that's where it always belonged) - trimming + base64 image cleanup (still done in the dispatcher's shared path) Replaced with a single typed-error branch when no crawl-capable provider is available: "web_crawl has no available backend. Set FIRECRAWL_API_KEY (or FIRECRAWL_API_URL for self-hosted), or set TAVILY_API_KEY for Tavily." Test updates ------------ - tests/tools/test_website_policy.py: - test_web_crawl_short_circuits_blocked_url: dispatcher seed-URL gate still runs on web_tools.check_website_access (no change to that patch), but the firecrawl client lockdown moved to the plugin module — patch firecrawl_provider._get_firecrawl_client instead of web_tools._get_firecrawl_client. The dispatcher short-circuits before the plugin runs, so the test still passes. - test_web_crawl_blocks_redirected_final_url: patch the per-page policy gate at plugins.web.firecrawl.provider.check_website_access (where it now runs) AND on web_tools (where the seed-URL gate still runs). Patch firecrawl_provider._get_firecrawl_client for the FakeCrawlClient injection. Both checks flow through the same fake_check function. - tests/plugins/web/test_web_search_provider_plugins.py: - Update parametrized capability-flag spec: firecrawl supports_crawl is now True. - Add test_firecrawl_crawl_returns_error_dict_when_unconfigured — verifies inspect.iscoroutinefunction(p.crawl) is True and that the async crawl returns a per-page error dict (not a raise) when FIRECRAWL_API_KEY is missing. Verified -------- - 218/218 web tests pass (was 173, +44 plugin tests + 1 new firecrawl crawl test from this commit = 218 with the test deduplication). - Compile-clean (py_compile passes on both files). - Provider capabilities matrix confirmed end-to-end: name search extract crawl async-extract? async-crawl? firecrawl True True True True True tavily True True True False False Both crawl-capable providers exercise the dispatcher's inspect.iscoroutinefunction async-or-sync detection. Net diff -------- - tools/web_tools.py: -254 lines (legacy inline crawl gone) - plugins/web/firecrawl/provider.py: +185 lines (crawl method) - test_website_policy.py: +14/-9 lines (patch locations) - test_web_search_provider_plugins.py: +22/-1 lines (capability flag + new firecrawl crawl test) - Total: -32 net LoC; tools/web_tools.py is now 1509 lines (was 1763 before this commit, 2227 before the migration started). --- plugins/web/firecrawl/provider.py | 187 +++++++++++- .../web/test_web_search_provider_plugins.py | 24 +- tests/tools/test_website_policy.py | 23 +- tools/web_tools.py | 284 +----------------- 4 files changed, 243 insertions(+), 275 deletions(-) diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index fdd5e1f3d55..ec193781096 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -374,6 +374,9 @@ class FirecrawlWebSearchProvider(WebSearchProvider): 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. @@ -559,13 +562,193 @@ class FirecrawlWebSearchProvider(WebSearchProvider): 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": ( - "Mainstream search + extract; supports direct API and Nous " - "tool-gateway routing." + "Full search + extract + crawl; supports direct API and " + "Nous tool-gateway routing." ), "env_vars": [ { diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 62f0f15c4d3..6ea154dee1e 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -96,7 +96,10 @@ class TestBundledPluginsRegister: ("exa", True, True, False), ("parallel", True, True, False), ("tavily", True, True, True), - ("firecrawl", True, True, False), + # 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( @@ -451,3 +454,22 @@ class TestErrorResponseShapes: 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/tools/test_website_policy.py b/tests/tools/test_website_policy.py index efc0e500de5..0e734cbae78 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -454,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", @@ -464,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) @@ -480,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": @@ -512,8 +522,13 @@ async def test_web_crawl_blocks_redirected_final_url(monkeypatch): ] } + # 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/web_tools.py b/tools/web_tools.py index 9265e57f3ec..1f0fd5fe117 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1285,275 +1285,23 @@ async def web_crawl_tool( _debug.save() return cleaned_result - # No registered provider supports crawl. Fall through to the - # Firecrawl-via-summarize path below (legacy behavior) when - # Firecrawl credentials are configured. - - # 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.", - "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 - }) - - 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 = [ + # 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( { - "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 - + "success": False, + "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, + ) + except Exception as e: error_msg = f"Error crawling website: {str(e)}" logger.debug("%s", error_msg) From 657e6d87cc65e14680282b6e2fdc1a9bcf702493 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 02:02:01 +0530 Subject: [PATCH 072/214] fix(web): align _LEGACY_PREFERENCE with legacy 7-provider order + doc cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the plugin migration surfaced one warning and a handful of doc/dead-code cleanups. None affect production behaviour through the main dispatcher (which always calls `tools.web_tools._get_backend()` first and preserves the full 7-provider walk), but direct callers of `agent.web_search_registry.get_active_*_provider()` previously diverged from the legacy order and could return `None` for users with credentials but no explicit `web.backend` config key. Changes ------- 1. `_LEGACY_PREFERENCE` was shipped as a 4-tuple `("brave-free", "firecrawl", "searxng", "ddgs")` while the PR description and the legacy `_get_backend()` candidate order both call for the 7-tuple `(firecrawl, parallel, tavily, exa, searxng, brave-free, ddgs)`. Replaced with the 7-tuple. Verified empirically: with TAVILY+EXA keys and no config, `get_active_search_provider()` now returns tavily (was None); with EXA+PARALLEL it returns parallel (was None); with BRAVE+FIRECRAWL it returns firecrawl (was brave-free). 2. `agent/web_search_registry.py` — module docstring, `_resolve` step-3 docstring, and inline comment all listed the old 4-tuple and claimed "brave-free first because it was the shipped default". The legacy default is `"firecrawl"`. Rewritten to match the new ordering and reference `tools.web_tools._get_backend()` as the source of truth. 3. `agent/web_search_registry.py` — `get_active_crawl_provider` docstring said "only Tavily implements it among built-in providers". Firecrawl also advertises `supports_crawl=True` after the previous commit. Updated to "Tavily and Firecrawl". 4. `plugins/web/tavily/provider.py` — module docstring said "Tavily is the only built-in backend that natively crawls". Updated. 5. `agent/web_search_provider.py` — ABC docstring mentioned only `search` / `extract` capabilities. Added `crawl` for accuracy. 6. `plugins/web/{firecrawl,parallel,exa}/provider.py` — dead plugin-level cache globals (`_firecrawl_client`, `_parallel_client`, `_async_parallel_client`, `_exa_client`) were declared but never read (all reads/writes go through `_wt.*` per the `extracting-inline- helpers-to-plugins` recipe). Removed the dead declarations; the reset-for-tests helpers in firecrawl + parallel now clear the canonical `_wt._` slots, matching the pattern exa already used. Tests ----- 218/218 web-targeted tests still pass (no test changes needed). 4910/4910 in `tests/tools/` still green. --- agent/web_search_provider.py | 11 +++--- agent/web_search_registry.py | 62 +++++++++++++++++++++---------- plugins/web/exa/provider.py | 7 ++-- plugins/web/firecrawl/provider.py | 21 +++++++---- plugins/web/parallel/provider.py | 25 +++++++------ plugins/web/tavily/provider.py | 4 +- 6 files changed, 82 insertions(+), 48 deletions(-) diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py index ed3f79f270e..7223bbf2cfe 100644 --- a/agent/web_search_provider.py +++ b/agent/web_search_provider.py @@ -61,13 +61,14 @@ from typing import Any, Dict, List class WebSearchProvider(abc.ABC): - """Abstract base class for a web search/extract backend. + """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`. The :meth:`supports_search` and - :meth:`supports_extract` capability flags let the registry route each - tool call to the right provider, and let multi-capability providers - (SearXNG, Firecrawl, Tavily, …) advertise both. + :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 diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index 8425c129910..c61c16cadb2 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -11,17 +11,23 @@ Active selection ---------------- The active provider is chosen by configuration with this precedence: -1. ``web.search_backend`` (for search) or ``web.extract_backend`` (for extract) -2. ``web.backend`` (shared fallback) -3. If exactly one capability-eligible provider is registered, use it. -4. Legacy preference order (``brave-free`` → ``firecrawl`` → ``searxng`` → ``ddgs``) - so installs that omitted the config key keep working. +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`` vs ``supports_extract``) is applied -at every step so a search-only provider (``brave-free``) configured as -``web.extract_backend`` correctly falls through. +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 @@ -107,10 +113,21 @@ def _read_config_key(*path: str) -> Optional[str]: return None -# Legacy preference order — preserves behaviour for users who set no config -# at all. brave-free first because it was the shipped default after the -# Brave migration; firecrawl second for back-compat with older configs. -_LEGACY_PREFERENCE = ("brave-free", "firecrawl", "searxng", "ddgs") +# 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]: @@ -130,10 +147,14 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc supports *capability* AND ``is_available()`` reports True, return it. 3. **Legacy preference walk, filtered by availability.** Walk the - :data:`_LEGACY_PREFERENCE` order looking for a provider whose + :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. This is the path that fires when no config key is set — pick - the highest-priority backend the user actually has credentials for. + 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 @@ -179,8 +200,8 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc # 2. + 3. Fallback path — filter by availability so we don't surface # a provider the user has no credentials for. Without this filter, - # brave-free's slot in the legacy preference order would make it - # the "active" provider on a fresh install with no API keys at all. + # 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) @@ -226,9 +247,10 @@ def get_active_crawl_provider() -> Optional[WebSearchProvider]: Reads ``web.crawl_backend`` (preferred) or ``web.backend`` (shared fallback) from config.yaml; falls back per the module docstring. - Crawl is a niche capability — only Tavily implements it among built-in - providers. Most callers should expect ``None`` and fall back to a - different strategy (e.g. summarize-via-LLM). + 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") diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py index d5735967758..0fea6fb5a8b 100644 --- a/plugins/web/exa/provider.py +++ b/plugins/web/exa/provider.py @@ -32,9 +32,10 @@ from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -# Module-level cache for the Exa client so we don't reconstruct it per -# call. Matches the legacy `_exa_client` pattern in tools/web_tools.py. -_exa_client: Any = None +# 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: diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index ec193781096..e7d4d378bdc 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -112,9 +112,11 @@ Firecrawl = _FirecrawlProxy() # --------------------------------------------------------------------------- # Client construction (direct vs managed-gateway) # --------------------------------------------------------------------------- - -_firecrawl_client: Any = None -_firecrawl_client_config: Any = None +# +# 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]: @@ -257,10 +259,15 @@ def _get_firecrawl_client() -> Any: def _reset_client_for_tests() -> None: - """Drop the cached Firecrawl client so tests can re-instantiate cleanly.""" - global _firecrawl_client, _firecrawl_client_config - _firecrawl_client = None - _firecrawl_client_config = 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 # --------------------------------------------------------------------------- diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 71aae39025a..38578e6b52c 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -36,13 +36,11 @@ from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -# Module-level client caches mirroring the legacy `tools.web_tools._parallel_client` -# / `_async_parallel_client` pattern. For tests, the canonical cache lives on -# tools.web_tools so existing setup_method() handlers that reset -# ``tools.web_tools._parallel_client = None`` keep working — we read/write -# the cache via that module rather than these module-level globals. -_parallel_client: Any = None -_async_parallel_client: Any = None +# 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: @@ -117,10 +115,15 @@ def _get_async_client() -> Any: def _reset_clients_for_tests() -> None: - """Drop both cached clients so tests can re-instantiate cleanly.""" - global _parallel_client, _async_parallel_client - _parallel_client = None - _async_parallel_client = 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 diff --git a/plugins/web/tavily/provider.py b/plugins/web/tavily/provider.py index fc3406d2ce9..50e15973fb3 100644 --- a/plugins/web/tavily/provider.py +++ b/plugins/web/tavily/provider.py @@ -5,8 +5,8 @@ capabilities advertised: - ``supports_search()`` -> True (Tavily ``/search``) - ``supports_extract()`` -> True (Tavily ``/extract``) -- ``supports_crawl()`` -> True (Tavily ``/crawl``) — Tavily is the only - built-in backend that natively crawls +- ``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 From 4ca5e724446a2294bbe69090884252eee326f2a7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 02:06:45 +0530 Subject: [PATCH 073/214] fix(web): preserve top-level error envelope on unconfigured systems Surfaced by local E2E behavior-parity testing of PR vs origin/main: the plugin-migrated dispatchers were quietly changing the error envelope shape returned to function-calling models on unconfigured systems. Two findings, both from per-result error wrapping bleeding into the pre-flight configuration error path: 1. **search**: ``firecrawl.search()`` caught the ``ValueError("Web tools are not configured...")`` from ``_get_firecrawl_client()`` and returned it as ``{"success": False, "error": ...}``, losing the legacy ``{"error": "Error searching web: ..."}`` envelope that ``tool_error()`` emits on main. Models that special-case the ``error`` key still detect the failure, but the prefix is part of the legacy contract some users rely on. 2. **crawl**: ``firecrawl.crawl()`` caught the same pre-flight ``ValueError`` and wrapped it as a per-page error inside ``results[0]``. Main short-circuits on ``check_firecrawl_api_key()`` BEFORE dispatching, so its unconfigured response is ``{"success": False, "error": "web_crawl requires Firecrawl..."}`` at the top level. The PR's per-page burying hid the failure inside ``results[]`` where models that check ``result.get("error")`` would miss it. Fix: - ``plugins/web/firecrawl/provider.py``: pull ``_get_firecrawl_client()`` outside the broad ``try`` in ``search()``. Pre-flight ``ValueError`` / ``ImportError`` propagate to the dispatcher's top-level exception handler. In-flight SDK errors still get wrapped as ``{"success": False, ...}``. - ``tools/web_tools.py``: mirror main's upstream availability gate in ``web_crawl_tool``. When the resolved crawl provider is ``is_available()==False``, short-circuit BEFORE dispatching with the same top-level error shape main emits. - ``tests/tools/test_web_providers.py``: 2 regression tests (``TestUnconfiguredErrorEnvelopeParity``) lock in the behavior so future plugin work can't undo this. Verified via local subprocess-based parity test (14/14 scenarios match origin/main shape exactly) and full 210/210 web test suite green. --- plugins/web/firecrawl/provider.py | 28 ++++++++----- tests/tools/test_web_providers.py | 69 +++++++++++++++++++++++++++++++ tools/web_tools.py | 19 +++++++++ 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index e7d4d378bdc..bcc574ffca3 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -390,22 +390,28 @@ class FirecrawlWebSearchProvider(WebSearchProvider): 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: - from tools.interrupt import is_interrupted - - if is_interrupted(): - return {"success": False, "error": "Interrupted"} - - logger.info("Firecrawl search: '%s' (limit=%d)", query, limit) - response = _get_firecrawl_client().search(query=query, limit=limit) + 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 ValueError as exc: - return {"success": False, "error": str(exc)} - except ImportError as exc: - return {"success": False, "error": f"Firecrawl SDK not installed: {exc}"} except Exception as exc: # noqa: BLE001 logger.warning("Firecrawl search error: %s", exc) return {"success": False, "error": f"Firecrawl search failed: {exc}"} diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index c64b0a1b621..67d39e9a999 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -263,3 +263,72 @@ class TestWebSearchUsesSearchBackend: 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/tools/web_tools.py b/tools/web_tools.py index 1f0fd5fe117..e2743248d22 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1192,6 +1192,25 @@ async def web_crawl_tool( 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://')): From d18618f48f18c0af5c4bba889a087557ab53a6df Mon Sep 17 00:00:00 2001 From: Billard <82095453+iacker@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:03:15 +0200 Subject: [PATCH 074/214] fix(honcho): respect HOME-anchored default profile fallback --- plugins/memory/honcho/client.py | 3 ++- tests/honcho_plugin/test_client.py | 40 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 612bcd239ce..de34642911e 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -21,6 +21,7 @@ from dataclasses import dataclass, field 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/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 95180b2dce3..8e011a5f94f 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -6,6 +6,8 @@ import os 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,22 @@ class TestResolveConfigPath: result = resolve_config_path() assert result == local_cfg - def test_falls_back_to_global_when_no_local(self, tmp_path): + def test_falls_back_to_default_profile_when_no_local(self, tmp_path, monkeypatch): 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. fake_home = tmp_path / "fakehome" fake_home.mkdir() + default_cfg = fake_home / ".hermes" / "honcho.json" + default_cfg.parent.mkdir(parents=True) + 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(hermes_home)) + + result = resolve_config_path() + + assert _get_default_hermes_home() == fake_home / ".hermes" + assert result == default_cfg def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): fake_home = tmp_path / "fakehome" @@ -383,6 +389,26 @@ class TestResolveConfigPath: 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): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + fake_home = tmp_path / "fakehome" + fake_home.mkdir() + default_cfg = fake_home / ".hermes" / "honcho.json" + default_cfg.parent.mkdir(parents=True) + default_cfg.write_text(json.dumps({ + "apiKey": "default-key", + "workspace": "default-ws", + })) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_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() From c872f07c47e2a751211d6ab97e816cefcd246ef0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:50:38 -0700 Subject: [PATCH 075/214] fix(tests): exercise profile-mode HERMES_HOME for honcho fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked tests from #6173 set HERMES_HOME outside Path.home()/.hermes, which forces get_default_hermes_root() down its Docker branch and returns HERMES_HOME directly — so _get_default_hermes_home() never resolves to the ~/.hermes directory the tests were trying to assert about. Rewire both tests to use the real profile layout (HERMES_HOME pointing at ~/.hermes/profiles/) so _get_default_hermes_home() resolves back to ~/.hermes and the default-profile fallback is actually exercised. --- tests/honcho_plugin/test_client.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 8e011a5f94f..b6530db9f84 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -352,20 +352,23 @@ class TestResolveConfigPath: assert result == local_cfg def test_falls_back_to_default_profile_when_no_local(self, tmp_path, monkeypatch): - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() + # 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_cfg = fake_home / ".hermes" / "honcho.json" - default_cfg.parent.mkdir(parents=True) + 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"}') monkeypatch.setattr(Path, "home", lambda: fake_home) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) result = resolve_config_path() - assert _get_default_hermes_home() == fake_home / ".hermes" + assert _get_default_hermes_home() == default_home assert result == default_cfg def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): @@ -390,19 +393,21 @@ class TestResolveConfigPath: assert resolve_config_path() == fake_home / ".honcho" / "config.json" def test_from_global_config_uses_default_profile_fallback(self, tmp_path, monkeypatch): - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() + # 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_cfg = fake_home / ".hermes" / "honcho.json" - default_cfg.parent.mkdir(parents=True) + 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(hermes_home)) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) config = HonchoClientConfig.from_global_config() From 8db544b4d09cbbc3244def8dd78001507e4ddb04 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Wed, 8 Apr 2026 16:44:25 +0300 Subject: [PATCH 076/214] fix(clipboard): reject non-png clipboard images when png normalization fails --- hermes_cli/clipboard.py | 20 ++++++++++++--- tests/tools/test_clipboard.py | 47 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index facc8f3c50a..a782c876b26 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -22,6 +22,7 @@ from pathlib import Path 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,14 @@ 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: + return path.read_bytes().startswith(_PNG_SIGNATURE) + except OSError: + return False + + # ── X11 (xclip) ───────────────────────────────────────────────────────── def _xclip_has_image() -> bool: diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 90e2ea847f8..750874400c4 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -39,6 +39,7 @@ from cli import _should_auto_attach_clipboard_image_on_paste 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,9 +394,53 @@ class TestWaylandSave: 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", 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 True + assert _wayland_save(dest) is False + + assert not dest.exists() def test_no_image_types(self, tmp_path): dest = tmp_path / "out.png" From d110ce44933446eff800e6100fc54ccae821c4ad Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:53:09 -0700 Subject: [PATCH 077/214] fix(clipboard): only read PNG signature bytes, not entire file Tighten _is_png_file() to read just the 8-byte PNG magic via path.open() + read(8), instead of slurping the entire image into memory only to check the prefix. --- hermes_cli/clipboard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index a782c876b26..a6b6da7c06a 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -440,7 +440,8 @@ def _convert_to_png(path: Path) -> bool: def _is_png_file(path: Path) -> bool: """Return True when *path* starts with the PNG file signature.""" try: - return path.read_bytes().startswith(_PNG_SIGNATURE) + with path.open("rb") as f: + return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE except OSError: return False From 8f19078c6ad72300676376f5824fcf50cd9b693b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:55:09 -0700 Subject: [PATCH 078/214] =?UTF-8?q?feat(goals):=20/subgoal=20=E2=80=94=20u?= =?UTF-8?q?ser-added=20criteria=20appended=20to=20active=20/goal=20(#25449?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(goals): /subgoal — user-added criteria appended to active /goal Layers a /subgoal command on top of the existing freeform Ralph judge loop. The user can append extra criteria mid-loop; the judge factors them into its done/continue verdict and the continuation prompt surfaces them to the agent. No new tool, no agent self-judging — the existing judge model just sees a richer prompt. Forms: /subgoal show current subgoals /subgoal append a criterion /subgoal remove drop subgoal n (1-based) /subgoal clear wipe all subgoals How it integrates: - GoalState gains `subgoals: List[str]` (default []), backwards-compat for existing state_meta rows. - judge_goal accepts an optional subgoals kwarg; non-empty switches to JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE which lists them as numbered criteria and asks 'is the goal AND every additional criterion satisfied?' - next_continuation_prompt picks CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE when non-empty so the agent sees what to target. - /subgoal is allowed mid-run on the gateway since it only touches the state the judge reads at turn boundary — no race with the running turn. - Status line shows '... , N subgoals' when present. Surface: - hermes_cli/goals.py — field, prompt blocks, manager methods, judge weave - hermes_cli/commands.py — /subgoal CommandDef - cli.py — _handle_subgoal_command - gateway/run.py — _handle_subgoal_command + mid-run dispatch - tests/hermes_cli/test_goals.py — 15 new tests (backcompat, mutation, persistence, prompt template selection, judge-prompt content via mock, status-line rendering) 77 goal-related tests passing across goals + cli + gateway + tui. * fix(goals): slash commands don't preempt the goal-continuation hook Two findings from live-testing /subgoal: 1. Slash commands queued while the agent is running landed in _pending_input (same queue as real user messages). The goal hook's 'is a real user message pending?' check returned True and silently skipped — but the slash command consumes its queue slot via process_command() which never re-fires the goal hook, so the loop stalls indefinitely. Now the hook peeks the queue and only defers when a non-slash payload is present. 2. The with-subgoals judge prompt was too soft — opus 4.7 said 'done, implying all requirements met' without verifying. Tightened to demand specific per-criterion evidence (file contents, output line, command result) and explicitly reject phrases like 'implying it was done.' Live verified: /subgoal injected mid-loop now correctly forces the judge to refuse done until the new criterion is met. Agent gets the continuation prompt with subgoals listed, updates the script, judge confirms done with specific evidence cited. --- cli.py | 109 +++++++++++++++- gateway/run.py | 60 +++++++++ hermes_cli/commands.py | 2 + hermes_cli/goals.py | 150 ++++++++++++++++++++-- tests/hermes_cli/test_goals.py | 224 +++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+), 14 deletions(-) diff --git a/cli.py b/cli.py index f2d0d019df2..5a0b9fbdf2f 100644 --- a/cli.py +++ b/cli.py @@ -7647,6 +7647,8 @@ class HermesCLI: _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": @@ -8245,6 +8247,81 @@ class HermesCLI: 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. @@ -8271,10 +8348,36 @@ class HermesCLI: # 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 diff --git a/gateway/run.py b/gateway/run.py index 95f1d811543..5027c800ea0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6173,6 +6173,12 @@ class GatewayRunner: 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. @@ -6554,6 +6560,9 @@ class GatewayRunner: 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) @@ -9524,6 +9533,57 @@ class GatewayRunner: 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) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 62790bf9c14..b3556d3932d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -104,6 +104,8 @@ COMMAND_REGISTRY: list[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"), diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 6a8a2ae971f..1542b9a7a38 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -33,8 +33,8 @@ import json 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 @@ CONTINUATION_PROMPT_TEMPLATE = ( "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 @@ JUDGE_USER_PROMPT_TEMPLATE = ( "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 @@ class GoalState: @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 @@ class 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 @@ class GoalManager: 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 @@ class GoalManager: 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 @@ class GoalManager: 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 @@ class GoalManager: 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 @@ __all__ = [ "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/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index b5afd716c9e..9d8c3f48fe1 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -514,3 +514,227 @@ class TestJudgeParseFailureAutoPause: 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 From 5f234d4057ffb3ae7bc5e143960d2d2fd44f9c76 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Wed, 8 Apr 2026 17:09:08 +0300 Subject: [PATCH 079/214] fix(cli): harden skin yaml parsing for invalid section types --- hermes_cli/skin_engine.py | 31 +++++++++++++++++++++++----- tests/hermes_cli/test_skin_engine.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 0acb41d6878..f4d894c1e7a 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/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6c23824b9e5..9da6df2bc28 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -199,6 +199,37 @@ class TestUserSkins: # 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.get_spinner_list("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" From 35ce94a2f8ae37bd74b10bcc86c75a7ab2e205d1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:53:50 -0700 Subject: [PATCH 080/214] fix(tests): correct skin engine test API call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged regression test called skin.get_spinner_list() which doesn't exist on SkinConfig. Replace with direct dict access on skin.spinner — same intent (verify default empty spinner is preserved when user override is invalid). --- tests/hermes_cli/test_skin_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 9da6df2bc28..1ed7e35323b 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -226,7 +226,7 @@ class TestUserSkins: assert skin.name == "broken" assert skin.get_color("banner_title") == "#FFD700" assert skin.get_branding("agent_name") == "Hermes Agent" - assert skin.get_spinner_list("waiting_faces") == [] + assert skin.spinner.get("waiting_faces", []) == [] assert skin.tool_emojis == {} assert skin.tool_prefix == "!" From 31b4721791aa163c80b5f78a7fb2f1fb3530d434 Mon Sep 17 00:00:00 2001 From: "mr.Shu" Date: Wed, 8 Apr 2026 15:20:24 +0200 Subject: [PATCH 081/214] fix: simplify ACP approval bridging Previously ACP dangerous-command approvals mixed an invalid ACP payload shape with partial Hermes option mapping, and the callback plumbing was shared across worker threads. This commit uses ACP tool-call updates, preserves Hermes once/session/always semantics, and scopes approval callbacks to the current worker thread. - Build permission requests with `update_tool_call` and unique `perm-check-*` ids in `acp_adapter/permissions.py` - Keep ACP option mapping explicit and fail closed on unknown outcomes or request failures - Set approval callbacks inside the ACP executor worker and read them from thread-local state in `tools/terminal_tool.py` - Replace duplicated ACP bridge coverage with focused tests in `tests/acp/test_permissions.py` and add a thread-local callback test --- acp_adapter/permissions.py | 127 +++++++++++++++++------- tests/acp/test_permissions.py | 175 ++++++++++++++++++++++++---------- 2 files changed, 221 insertions(+), 81 deletions(-) diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index c2e1a598269..44aead28742 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 @@ from acp.schema import ( 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/tests/acp/test_permissions.py b/tests/acp/test_permissions.py index 57e2bd4e5b9..8bbdeeb392a 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) - 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") + scheduled = {} - return result + 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 TestApprovalMapping: - def test_approval_allow_once_maps_correctly(self): - outcome = AllowedOutcome(option_id="allow_once", outcome="selected") - result = _setup_callback(outcome) +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"]] + 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'.""" + assert denied_result == "deny" + assert unknown_result == "deny" + + def test_timeout_returns_deny_and_cancels_future(self): loop = MagicMock(spec=asyncio.AbstractEventLoop) - mock_rp = MagicMock(name="request_permission") - + 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" From 16796acc84c6a92392be937737149d5266ef86a8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 22:56:13 -0700 Subject: [PATCH 082/214] chore(release): add AUTHOR_MAP entry for mrshu Maps mr@shu.io to the mrshu GitHub handle so the release script attributes the salvaged ACP approval bridging commit correctly. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6e5ac99fa3f..d2962d8cb74 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "qiyin.zuo@pcitc.com": "qiyin-code", "oleksii.lisikh@gmail.com": "olisikh", "leone.parise@gmail.com": "leoneparise", + "mr@shu.io": "mrshu", "buraysandro9@gmail.com": "ygd58", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", From 2ff744ae2c4e9f54058c0b1ec42e0511586be574 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 23:04:16 -0700 Subject: [PATCH 083/214] chore(release): add AUTHOR_MAP entries for 25-PR new-contributor batch Pre-stages AUTHOR_MAP for 12 new contributors whose PRs are being salvaged in the upcoming batch: - 1RB (#25462) - ayushere (#25342) - domtriola (#25424) - ephron-ren (#25358) - freqyfreqy (#25423) - fu576 (#25369) - kfa-ai (#25398) - magic524 (#25361) - PaTTeeL (#25359) - pearjelly (#25388) - raymaylee (#25394) - Tianyu199509 (#25421) --- scripts/release.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index d2962d8cb74..e9e4537d2f7 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1015,6 +1015,18 @@ AUTHOR_MAP = { "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) } From 796c8a2d63831a5aed6b727bae6c189448cbada8 Mon Sep 17 00:00:00 2001 From: domtriola <15791290+domtriola@users.noreply.github.com> Date: Wed, 13 May 2026 23:04:50 -0700 Subject: [PATCH 084/214] docs(user-guide): point tirith link to correct repo --- website/docs/user-guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 5ea0c0b1779..89bdb234146 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.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. From 8de26e280ed8126194dbbccaf9969ae5979c0aed Mon Sep 17 00:00:00 2001 From: freqyfreqy <96843562+freqyfreqy@users.noreply.github.com> Date: Wed, 13 May 2026 23:05:13 -0700 Subject: [PATCH 085/214] docs(lsp): replace "git worktree" with "git repository" in LSP docs The word "worktree" (a git subcommand feature for parallel checkouts) was used interchangeably with "repository" in the LSP docs, causing confusion. LSP only requires a git-initialized directory, not an actual worktree. Fixes two instances: section "When LSP runs" and the troubleshooting "Editing a file outside any git repo" heading. --- website/docs/user-guide/features/lsp.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/docs/user-guide/features/lsp.md b/website/docs/user-guide/features/lsp.md index bb54003b11a..c0ed863f7dc 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. From efa97af7e25f0cbef92ed15bbcb47e4788c83058 Mon Sep 17 00:00:00 2001 From: ephron-ren <284216128+ephron-ren@users.noreply.github.com> Date: Wed, 13 May 2026 23:07:03 -0700 Subject: [PATCH 086/214] fix(agent): add Xiaomi MiMo to reasoning_content echo-back providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xiaomi MiMo emits reasoning via OpenAI's reasoning_content field and requires reasoning_content on every assistant tool-call message when replaying history. Without echo-back, subsequent API calls fail with HTTP 400 — same shape as DeepSeek and Kimi/Moonshot thinking modes. Adds _needs_mimo_tool_reasoning() detection (provider == 'xiaomi', 'mimo' in model, or xiaomimimo.com base url) and wires it into the _needs_thinking_reasoning_pad() check. Salvage of #25358 by @ephron-ren (manually re-applied — original branch was severely stale against current main). --- run_agent.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 4f50cb06e4d..590742b2da0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10049,11 +10049,12 @@ class AIAgent: 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: @@ -10085,6 +10086,22 @@ class AIAgent: 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": From b59ed9c6bc564e1158875dc795141405c4ed927d Mon Sep 17 00:00:00 2001 From: 1RB <86501179+1RB@users.noreply.github.com> Date: Wed, 13 May 2026 23:08:47 -0700 Subject: [PATCH 087/214] fix(discord): handle forwarded messages via message_snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord introduced message_snapshots for forwarded messages — text and attachments live inside snap.content / snap.attachments rather than on the parent message. _handle_message wasn't reading them, so forwards showed up empty. Defensively extracts snapshot text (when raw_content is empty) and appends snapshot attachments to the working all_attachments list used for type detection and media routing. hasattr/getattr guards keep this safe on older discord.py installs without the field. Salvage of #25462 by @1RB (manually re-applied — original branch was stale against current main). --- gateway/platforms/discord.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index b1b5012776b..bcca80c5b5f 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -4186,6 +4186,17 @@ class DiscordAdapter(BasePlatformAdapter): 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() @@ -4261,13 +4272,15 @@ class DiscordAdapter(BasePlatformAdapter): 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 @@ -4326,7 +4339,7 @@ class DiscordAdapter(BasePlatformAdapter): 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: From 057f5a31d1b2358c8a1781c102a1e4401770e239 Mon Sep 17 00:00:00 2001 From: AllynSheep <5029547+AllynSheep@users.noreply.github.com> Date: Wed, 13 May 2026 23:10:26 -0700 Subject: [PATCH 088/214] fix(auxiliary): skip providers without credentials immediately When the auxiliary client fallback chain reaches a provider that has no credentials configured (no API key, no pool entry), the current code just returns (None, None) which counts toward the per-call timeout budget on the next attempt. Mark the provider unhealthy with a short TTL so the chain advances quickly to the next viable option. Closes #25384. Salvage of #25395 by @AllynSheep. --- agent/auxiliary_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index de7b6db2b1d..1a966a03129 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 From fd9c1504da51f204506d0b37ec592d5bed059504 Mon Sep 17 00:00:00 2001 From: Tianyu199509 <122434621+Tianyu199509@users.noreply.github.com> Date: Wed, 13 May 2026 23:10:51 -0700 Subject: [PATCH 089/214] fix: gateway PID detection fails on Windows (two issues) - _read_process_cmdline: /proc and 'ps' are unavailable on Windows, so process cmdline was always empty. Add psutil fallback (already a hard dependency used by _pid_exists in the same module). - _record_looks_like_gateway: argv paths use backslashes on Windows but patterns use forward slashes/dots, so the fallback record check always failed. Normalize backslashes to forward slashes before matching. Together these caused get_running_pid() to return None on Windows even when the gateway process is alive, making the dashboard report gateway as 'stopped' despite it functioning normally. --- gateway/status.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/gateway/status.py b/gateway/status.py index 3c619856025..516ea8f385e 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", From bd33a48a5839f235f17ffa1cc2542852ce55067f Mon Sep 17 00:00:00 2001 From: kfa-ai <258095375+kfa-ai@users.noreply.github.com> Date: Wed, 13 May 2026 23:11:14 -0700 Subject: [PATCH 090/214] feat(whatsapp): surface quoted reply metadata --- scripts/whatsapp-bridge/bridge.js | 8 ++++- tests/gateway/test_whatsapp_formatting.py | 39 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 9ab6118da1b..9ff64471e56 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/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 1cb4c7bf3d8..81b1a57c0c9 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 @@ class TestSendChunking: 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 # --------------------------------------------------------------------------- From 00ad3d3c9c862352334c4348534dce3fed77dd9b Mon Sep 17 00:00:00 2001 From: raymaylee <100820567+raymaylee@users.noreply.github.com> Date: Wed, 13 May 2026 23:11:37 -0700 Subject: [PATCH 091/214] fix: show context compaction status --- run_agent.py | 3 +++ tests/run_agent/test_413_compression.py | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/run_agent.py b/run_agent.py index 590742b2da0..3d4f99cbc08 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10341,6 +10341,9 @@ class AIAgent: 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: diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 5410f196e65..3cbd47c0e1b 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -415,6 +415,32 @@ class TestHTTP413Compression: 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 From 71191b7e8e075037a814f77d37d4609e97f12029 Mon Sep 17 00:00:00 2001 From: pearjelly <1700913+pearjelly@users.noreply.github.com> Date: Wed, 13 May 2026 23:12:27 -0700 Subject: [PATCH 092/214] fix(gateway): make Feishu ws connect override sync to preserve context manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Feishu adapter wrapped lark-oapi's Connect() callable to inject ping_interval/ping_timeout overrides, but made the wrapper async. The underlying library uses Connect() as an async context manager (async with Connect(...) as ws:), which requires the call itself to be sync and return an AsyncContextManager — making it async meant the wrapper was awaited eagerly and ws never bound. Restoring the sync wrapper preserves the protocol while still injecting the overrides. Salvage of #25388 by @pearjelly (manually re-applied — original branch was severely stale against current main). --- gateway/platforms/feishu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 6481c8fa31a..8d60046d35d 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -1300,12 +1300,12 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> 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: From f0e46c5e9e8d4f780561554684e33810fc4f2f8f Mon Sep 17 00:00:00 2001 From: fu576 <54306477+fu576@users.noreply.github.com> Date: Wed, 13 May 2026 23:12:50 -0700 Subject: [PATCH 093/214] fix: do not inherit api_mode when delegating across providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-provider delegation (e.g. MiniMax parent → DeepSeek child) must not inherit the parent's api_mode, because each provider uses a different API surface: MiniMax uses 'anthropic_messages' while DeepSeek uses 'chat_completions'. Inheriting the wrong mode causes 404 errors. When the effective provider differs from the parent's provider, derive api_mode from the target provider's defaults instead (None triggers re-derivation). Refs: Bug #20558, PR #20563 --- tools/delegate_tool.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b2c02aedaf8..f4da5127a18 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1017,7 +1017,18 @@ def _build_child_agent( 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 ) From 8199ec38034a675a20278261b76cf0fe42316a7d Mon Sep 17 00:00:00 2001 From: magic524 <99181308+magic524@users.noreply.github.com> Date: Wed, 13 May 2026 23:13:19 -0700 Subject: [PATCH 094/214] fix(gateway): keep QQBot reconnect loop alive --- gateway/platforms/qqbot/adapter.py | 29 +++++++++++++++++++++++++++-- tests/gateway/test_qqbot.py | 3 +++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index b7a306f9b69..086f5e073f5 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -176,6 +176,28 @@ class QQAdapter(BasePlatformAdapter): 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 @@ class QQAdapter(BasePlatformAdapter): 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 @@ class QQAdapter(BasePlatformAdapter): 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 @@ class QQAdapter(BasePlatformAdapter): 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/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index a0c9fa6573c..5d5cac54bd3 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -4,6 +4,7 @@ import asyncio import json import os import sys +from types import SimpleNamespace from unittest import mock import pytest @@ -578,6 +579,7 @@ class TestWaitForReconnection: 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 @@ class TestWaitForReconnection: """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): From 7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378 Mon Sep 17 00:00:00 2001 From: PaTTeeL <9150277+PaTTeeL@users.noreply.github.com> Date: Wed, 13 May 2026 23:13:45 -0700 Subject: [PATCH 095/214] fix(auxiliary): forward custom_providers to compression model context-length detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auxiliary.compression.provider is "auto", the compression model reuses the main model's provider and base_url. The main model's context_length was correctly picking up custom_providers per-model overrides (via _custom_providers stored during __init__), but the auxiliary compression model's context-length detection path in _check_compression_model_feasibility was not passing custom_providers, causing it to skip step 0b and fall through to models.dev. This meant that for providers like NVIDIA NIM where the user has a per-model context_length in custom_providers (e.g. 196608 for minimax-m2.7), the auxiliary model would use the models.dev value (204800) instead of the user-configured one — a subtle discrepancy that could lead to silent compression issues when the auxiliary model doesn't actually support the detected context length. Fix: pass self._custom_providers (already stored as an instance attr during __init__) to the get_model_context_length() call for the auxiliary compression model. --- run_agent.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/run_agent.py b/run_agent.py index 3d4f99cbc08..ac9473a9691 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2204,6 +2204,10 @@ class AIAgent: 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: @@ -3246,6 +3250,7 @@ class AIAgent: # 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 From 55ba02befbb976d2383726f1a44591c8325613f9 Mon Sep 17 00:00:00 2001 From: ayushere <44045943+ayushere@users.noreply.github.com> Date: Wed, 13 May 2026 23:17:14 -0700 Subject: [PATCH 096/214] fix(background-review): silence memory provider teardown output leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background review fork redirected stdout/stderr around run_conversation() so its iteration messages stay silent. But the memory-provider teardown (shutdown_memory_provider() and review_agent.close()) fired in the outer finally block AFTER the redirect_stdout context exited — so provider teardown prints (Honcho disconnect, Hindsight sync, etc.) leaked into the parent terminal at end of every turn. Moves the teardown inside the redirect_stdout scope on the success path (and nulls review_agent so the finally safety-net skips double-shutdown). The finally block is rewritten as an exception-path safety net that re-opens a devnull redirect, since the original 'with' context has already exited by the time finally runs. Salvage of #25342 by @ayushere (manually re-applied + merged conflict with current main's set_thread_tool_whitelist wiring). --- run_agent.py | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/run_agent.py b/run_agent.py index ac9473a9691..d995c607de6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4373,6 +4373,20 @@ class AIAgent: 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_agent = None + # Scan the review agent's messages for successful tool actions # and surface a compact summary to the user. Tool messages # already present in messages_snapshot must be skipped, since @@ -4402,21 +4416,24 @@ class AIAgent: 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 From c76e8795744a00208c683b2c6319902416bce1a8 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Fri, 10 Apr 2026 22:22:47 +0800 Subject: [PATCH 097/214] feat: add NovitaAI as LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NovitaAI as a first-class provider with dedicated model selection flow, live pricing, and authoritative context length resolution. - Register provider in PROVIDER_REGISTRY, HERMES_OVERLAYS, and all alias/label maps (ID: novita, aliases: novita-ai, novitaai) - Add dedicated _model_flow_novita() with 3-tier model list fallback: Novita API → models.dev → static curated list - Fetch live pricing from /v1/models with correct unit conversion (input_token_price_per_m is 0.0001 USD per Mtok) - Add Novita-specific context length resolution (step 4b) in get_model_context_length(), prioritized over models.dev/OpenRouter - Register api.novita.ai in _URL_TO_PROVIDER to prevent early return from the custom-endpoint code path - Add models.dev mapping (novita → novita-ai) - Add default auxiliary model (deepseek/deepseek-v3-0324) - Add NOVITA_API_KEY to test isolation (conftest.py) - Update docs: providers page, env vars reference, CLI reference, .env.example, README, and landing page --- .env.example | 8 +++ README.md | 2 +- agent/model_metadata.py | 25 +++++++- agent/models_dev.py | 1 + hermes_cli/main.py | 33 ++++++++++- hermes_cli/models.py | 59 ++++++++++++++++++- hermes_cli/providers.py | 9 +++ plugins/model-providers/novita/__init__.py | 27 +++++++++ plugins/model-providers/novita/plugin.yaml | 5 ++ website/docs/integrations/providers.md | 24 ++++++++ website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 4 +- 12 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 plugins/model-providers/novita/__init__.py create mode 100644 plugins/model-providers/novita/plugin.yaml diff --git a/.env.example b/.env.example index e6763f18fd2..4dfa7a4e284 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 — multi-model aggregator with pay-per-use pricing +# 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) # ============================================================================= diff --git a/README.md b/README.md index 8b8a078b250..dc44df02232 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) (multi-model, pay-per-use), [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/agent/model_metadata.py b/agent/model_metadata.py index f5e34fc18c6..a10a01e3cc2 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,7 @@ 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", @@ -66,7 +66,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "gmi-cloud", "gmicloud", "xai", "x-ai", "x.ai", "grok", "nvidia", "nim", "nvidia-nim", "nemotron", - "qwen-portal", + "qwen-portal", "novita-ai", "novitaai", }) @@ -104,6 +104,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 +287,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 +364,7 @@ _URL_TO_PROVIDER: Dict[str, str] = { "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 +561,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 +1541,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 d709d7176d4..8fabb276645 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/hermes_cli/main.py b/hermes_cli/main.py index c93fa485c98..4683c8f3126 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4970,6 +4970,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, []) @@ -9269,7 +9300,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", ] diff --git a/hermes_cli/models.py b/hermes_cli/models.py index eb55b59ee5d..b3d2e1cd81b 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -445,6 +445,14 @@ _PROVIDER_MODELS: dict[str, 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 +913,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 (90+ models, pay-per-use)"), 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"), @@ -1014,6 +1023,8 @@ _PROVIDER_ALIASES = { "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", + "novita-ai": "novita", + "novitaai": "novita", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "tencent": "tencent-tokenhub", @@ -1494,7 +1505,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 +1515,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() if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1520,6 +1533,50 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d return {} +def _fetch_novita_pricing(timeout: float = 8.0) -> 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. + """ + 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" + url = base_url.rstrip("/") + "/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: + 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), + } + 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/providers.py b/hermes_cli/providers.py index f766a50ebf9..08fc173dc69 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -156,6 +156,11 @@ HERMES_OVERLAYS: Dict[str, 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", @@ -309,6 +314,10 @@ ALIASES: Dict[str, str] = { "hugging-face": "huggingface", "huggingface-hub": "huggingface", + # novita + "novita-ai": "novita", + "novitaai": "novita", + # xiaomi "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", diff --git a/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py new file mode 100644 index 00000000000..c39087e52d9 --- /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 — multi-model aggregator with pay-per-use pricing", + 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 00000000000..681db199433 --- /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 multi-model aggregator +author: Nous Research diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 93e4ba630d3..28ba035452d 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -20,6 +20,7 @@ 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`, multi-model aggregator) | | **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`) | @@ -546,6 +547,29 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest The base URL can be overridden with `HF_BASE_URL`. +### NovitaAI + +[NovitaAI](https://novita.ai) is a multi-model aggregator with pay-per-use pricing. Access models from DeepSeek, Kimi, MiniMax, GLM, Qwen, and more through a unified OpenAI-compatible API. + +```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`. + ## Custom & Self-Hosted LLM Providers Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4ce8a331a94..4bb361a987e 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -91,7 +91,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`, `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 409ddf8fe35..a427c901ce1 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -67,6 +67,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 — multi-model aggregator ([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 +105,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`, `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) | From 1551ce46a4b65e8388ea6fc3347e802a8705c390 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Mon, 13 Apr 2026 10:30:41 +0800 Subject: [PATCH 098/214] docs: update NovitaAI description to "90+ models, pay-per-use" --- .env.example | 2 +- README.md | 2 +- agent/auxiliary_client.py | 2 +- plugins/model-providers/novita/__init__.py | 2 +- website/docs/integrations/providers.md | 4 ++-- website/docs/reference/environment-variables.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 4dfa7a4e284..747f7542482 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ # ============================================================================= # LLM PROVIDER (NovitaAI) # ============================================================================= -# NovitaAI — multi-model aggregator with pay-per-use pricing +# 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 diff --git a/README.md b/README.md index dc44df02232..58bb5c76e52 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), [NovitaAI](https://novita.ai) (multi-model, pay-per-use), [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) (90+ models, pay-per-use), [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.
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1a966a03129..ee0ec917f5d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4436,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/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py index c39087e52d9..8096686c9b3 100644 --- a/plugins/model-providers/novita/__init__.py +++ b/plugins/model-providers/novita/__init__.py @@ -8,7 +8,7 @@ novita = ProviderProfile( name="novita", aliases=("novita-ai", "novitaai"), display_name="NovitaAI", - description="NovitaAI — multi-model aggregator with pay-per-use pricing", + description="NovitaAI — 90+ models, pay-per-use", signup_url="https://novita.ai/settings/key-management", env_vars=("NOVITA_API_KEY", "NOVITA_BASE_URL"), base_url="https://api.novita.ai/openai/v1", diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 28ba035452d..c25f82c4880 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -20,7 +20,7 @@ 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`, multi-model aggregator) | +| **NovitaAI** | `NOVITA_API_KEY` in `~/.hermes/.env` (provider: `novita`, 90+ models, pay-per-use) | | **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`) | @@ -549,7 +549,7 @@ The base URL can be overridden with `HF_BASE_URL`. ### NovitaAI -[NovitaAI](https://novita.ai) is a multi-model aggregator with pay-per-use pricing. Access models from DeepSeek, Kimi, MiniMax, GLM, Qwen, and more through a unified OpenAI-compatible API. +[NovitaAI](https://novita.ai) is a 90+ model aggregator with pay-per-use pricing. Access models from DeepSeek, Kimi, MiniMax, GLM, Qwen, and more through a unified OpenAI-compatible API. ```bash # Use any available model diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index a427c901ce1..83988729a60 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -67,7 +67,7 @@ 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 — multi-model aggregator ([novita.ai/settings/key-management](https://novita.ai/settings/key-management)) | +| `NOVITA_API_KEY` | NovitaAI API key — 90+ models, pay-per-use ([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) | From 0f0e20ef81709a6dd590b25af380b116db67628c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 12:05:52 +0530 Subject: [PATCH 099/214] test(novita): cache pricing, add provider test coverage, AUTHOR_MAP entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Alex-wuhu's NovitaAI provider commit. Adds: - _pricing_cache hit/write in _fetch_novita_pricing (was missing — every pricing fetch was re-hitting the network), mirroring the fetch_ai_gateway_pricing pattern. force_refresh now also propagates from get_pricing_for_provider. - TestNovitaProvider in tests/hermes_cli/test_api_key_providers.py covering profile load, alias resolution, registry auto-registration, model list parity between main.py and models.py, _URL_TO_PROVIDER, _PROVIDER_PREFIXES, context_size in _CONTEXT_LENGTH_KEYS, pricing unit conversion, and pricing cache behavior. - AUTHOR_MAP entry for yanglongwei06@gmail.com → @Alex-yang00. --- hermes_cli/models.py | 21 ++- scripts/release.py | 1 + tests/hermes_cli/test_api_key_providers.py | 153 +++++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b3d2e1cd81b..da1f5350958 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1516,7 +1516,7 @@ 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() + return _fetch_novita_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1533,19 +1533,31 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d return {} -def _fetch_novita_pricing(timeout: float = 8.0) -> dict[str, dict[str, str]]: +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" - url = base_url.rstrip("/") + "/models" + 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", @@ -1557,6 +1569,7 @@ def _fetch_novita_pricing(timeout: float = 8.0) -> dict[str, dict[str, str]]: 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]] = {} @@ -1574,6 +1587,8 @@ def _fetch_novita_pricing(timeout: float = 8.0) -> dict[str, dict[str, str]]: "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 diff --git a/scripts/release.py b/scripts/release.py index e9e4537d2f7..f9de395d195 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,6 +55,7 @@ AUTHOR_MAP = { "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", diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 291b8b70d46..81859230ab7 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -1099,6 +1099,159 @@ class TestHuggingFaceModels: 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) # ============================================================================= From ddb8d8fa842283ef651a6e4514f8f561f736c72e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 14 May 2026 16:31:12 +0800 Subject: [PATCH 100/214] docs: update NovitaAI provider positioning (#25532) --- README.md | 2 +- hermes_cli/models.py | 2 +- plugins/model-providers/novita/__init__.py | 2 +- plugins/model-providers/novita/plugin.yaml | 2 +- website/docs/integrations/providers.md | 54 ++++++++++--------- .../docs/reference/environment-variables.md | 2 +- 6 files changed, 34 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 58bb5c76e52..7e71632c310 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), [NovitaAI](https://novita.ai) (90+ models, pay-per-use), [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.
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
diff --git a/hermes_cli/models.py b/hermes_cli/models.py index da1f5350958..1ffede636a1 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -913,7 +913,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 (90+ 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"), diff --git a/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py index 8096686c9b3..e49e289a0de 100644 --- a/plugins/model-providers/novita/__init__.py +++ b/plugins/model-providers/novita/__init__.py @@ -8,7 +8,7 @@ novita = ProviderProfile( name="novita", aliases=("novita-ai", "novitaai"), display_name="NovitaAI", - description="NovitaAI — 90+ models, pay-per-use", + 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", diff --git a/plugins/model-providers/novita/plugin.yaml b/plugins/model-providers/novita/plugin.yaml index 681db199433..d572ca616bd 100644 --- a/plugins/model-providers/novita/plugin.yaml +++ b/plugins/model-providers/novita/plugin.yaml @@ -1,5 +1,5 @@ name: novita-provider kind: model-provider version: 1.0.0 -description: NovitaAI multi-model aggregator +description: NovitaAI AI-native cloud for builders and agents author: Nous Research diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index c25f82c4880..b53ab15ed84 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -20,7 +20,7 @@ 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`, 90+ models, pay-per-use) | +| **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`) | @@ -268,6 +268,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 @@ -317,7 +321,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. @@ -333,6 +337,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. @@ -547,29 +574,6 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest The base URL can be overridden with `HF_BASE_URL`. -### NovitaAI - -[NovitaAI](https://novita.ai) is a 90+ model aggregator with pay-per-use pricing. Access models from DeepSeek, Kimi, MiniMax, GLM, Qwen, and more through a unified OpenAI-compatible API. - -```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`. - ## Custom & Self-Hosted LLM Providers Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API. diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 83988729a60..eb2bc816202 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -67,7 +67,7 @@ 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 — 90+ models, pay-per-use ([novita.ai/settings/key-management](https://novita.ai/settings/key-management)) | +| `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) | From c75e1a03f9dacd96f5b822ef2102789c926059e7 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Fri, 8 May 2026 05:16:19 +1000 Subject: [PATCH 101/214] fix(install): preserve pip entry point when re-running on symlinked install setup_path() writes the user-facing hermes shim with `cat >`, which follows existing symlinks. Older installs created `$command_link_dir/hermes` as a symlink to `$HERMES_BIN` (`venv/bin/hermes`), so re-running install.sh stomped the pip entry point with a bash shim that exec'd itself in an infinite loop. `rm -f` the link target before writing so the shim lands at `$command_link_dir/hermes` and the venv entry point is left intact. Adds a regression test that reproduces the symlink-stomp end-to-end (creates the symlink, drives the real shim-write block from setup_path, asserts the venv pip script body survives and the shim is now a regular file). Both new assertions fail on origin/main and pass with the fix. Closes #21454. --- scripts/install.sh | 4 + tests/test_install_sh_symlink_stomp.py | 123 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/test_install_sh_symlink_stomp.py diff --git a/scripts/install.sh b/scripts/install.sh index 25d566c9881..75e8f1eed5b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1281,6 +1281,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" < "$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" From 1dca6a6960f87b07a7d270893ac35211c97913c8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 13 May 2026 23:08:12 -0700 Subject: [PATCH 102/214] feat(discord): render clarify choices as buttons Brings Discord to parity with Telegram on the clarify tool's interactive UX. Overrides BasePlatformAdapter.send_clarify on DiscordAdapter to attach a button view when choices are present. - ClarifyChoiceView: one discord.ui.Button per choice (max 24, Discord's 25-component view cap leaves one slot for Other) plus a final 'Other (type answer)' button. - Numeric click -> tools.clarify_gateway.resolve_gateway_clarify( clarify_id, choice_text) using the canonical choice text from the gateway entry (falls back to the button label if the entry vanished). - Other click -> tools.clarify_gateway.mark_awaiting_text(clarify_id) so the gateway's text-intercept captures the next user message in this session as the response. - Auth via the shared _component_check_auth helper (same OR-semantics as ExecApprovalView / SlashConfirmView / UpdatePromptView / ModelPickerView). - Open-ended (no choices) path renders the prompt as a plain embed and relies on the existing text-intercept resolution. - Single-use: first valid click disables every button and updates the embed footer with who answered and what they chose. No changes to BasePlatformAdapter.send_clarify or the gateway's clarify_callback wiring -- the existing scaffolding already drives all adapters; Discord just inherits the default text fallback today and gains buttons by virtue of this override. Test conftest extended: _FakeEmbed gains add_field() / set_footer() stubs so tests can construct embedded views without monkey-patching per-test. Original PR: #19249 by @LeonSGP43. This is a reshape of the contributor's work onto current main's clarify infrastructure (clarify_id + entry-based resolution shared with Telegram, instead of a parallel on_answer-closure mechanism). The button view structure and UX shape are preserved. Tests: 14 new tests in tests/gateway/test_discord_clarify_buttons.py. 391/391 existing Discord gateway tests still pass. Co-authored-by: LeonSGP43 --- gateway/platforms/discord.py | 263 +++++++++++ tests/gateway/conftest.py | 8 + tests/gateway/test_discord_clarify_buttons.py | 408 ++++++++++++++++++ 3 files changed, 679 insertions(+) create mode 100644 tests/gateway/test_discord_clarify_buttons.py diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index bcca80c5b5f..4793df35c7c 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3896,6 +3896,84 @@ class DiscordAdapter(BasePlatformAdapter): 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 = "", @@ -5138,3 +5216,188 @@ if DISCORD_AVAILABLE: 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/tests/gateway/conftest.py b/tests/gateway/conftest.py index da8a2d33641..b6bcc28c506 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -119,6 +119,14 @@ def _ensure_discord_mock() -> 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_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py new file mode 100644 index 00000000000..b6e21f1f44b --- /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 From 17e0e9d174b22c55d02db42c8ada5a035b220a57 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 07:31:43 -0700 Subject: [PATCH 103/214] fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow (#25750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. --- hermes_cli/main.py | 62 +++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4683c8f3126..09752fed433 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2414,30 +2414,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 +2474,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 From 524490a40937c2a74d7969842a31acaba8d11124 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 07:39:13 -0700 Subject: [PATCH 104/214] fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch. --- scripts/install.ps1 | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e2fe765174c..36cdf76ec70 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 From a6940405201e9642df24ceb7a799347ca002c9b2 Mon Sep 17 00:00:00 2001 From: Phuong Lambert Date: Wed, 13 May 2026 11:51:38 +0700 Subject: [PATCH 105/214] fix(telegram): escape dynamic markdown in callback flows Use MarkdownV2 formatting for Telegram callback follow-ups and interactive prompts where dynamic names or user text can break legacy Markdown parsing. Add regression coverage for reload-mcp, model picker, approval callbacks, and update prompts. --- gateway/platforms/telegram.py | 94 ++++++++++--------- .../gateway/test_telegram_approval_buttons.py | 54 ++++++++++- tests/gateway/test_telegram_format.py | 13 +++ tests/gateway/test_telegram_model_picker.py | 59 ++++++++++++ 4 files changed, 174 insertions(+), 46 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index db25b87497d..03184ac1c20 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2070,7 +2070,7 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2082,7 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2334,13 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2348,7 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2458,14 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2494,14 @@ class TelegramAdapter(BasePlatformAdapter): 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() @@ -2528,22 +2534,22 @@ class TelegramAdapter(BasePlatformAdapter): result_text = f"Error switching model: {exc}" # Edit message to show confirmation, remove buttons - try: - await query.edit_message_text( - text=result_text, - parse_mode=ParseMode.MARKDOWN, - reply_markup=None, - ) - except Exception: - # Markdown parse failure — retry as plain text try: await query.edit_message_text( - text=result_text, - parse_mode=None, + text=self.format_message(result_text), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: - pass + # Markdown parse failure — retry as plain text + try: + await query.edit_message_text( + text=result_text, + parse_mode=None, + reply_markup=None, + ) + except Exception: + pass await query.answer(text="Model switched!") # Clean up state @@ -2571,13 +2577,15 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2668,8 @@ class TelegramAdapter(BasePlatformAdapter): # 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 +2722,8 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2748,8 @@ class TelegramAdapter(BasePlatformAdapter): 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 +2909,8 @@ class TelegramAdapter(BasePlatformAdapter): 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/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index bfbc0bcdb36..f439d97250f 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -195,6 +195,29 @@ class TestTelegramExecApproval: 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 @@ class TestTelegramExecApproval: 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 @@ class TestTelegramApprovalCallback: # 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 55fb118d8f7..90063a01a8b 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -210,6 +210,19 @@ class TestFormatMessageBoldItalic: 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 e7c2cd11a4f..19928ffa128 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/tests/gateway/test_telegram_model_picker.py @@ -43,6 +43,65 @@ 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_retries_without_thread_when_thread_not_found(self): adapter = _make_adapter() From 26deeea830eb4a4aa39651fd7b2fbb523eb2a78d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 07:42:24 -0700 Subject: [PATCH 106/214] fix(telegram): restore model-switch success path + author map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked PR over-indented the edit_message_text block for the mm: (model selected → switch) success path so the confirmation edit lived inside the preceding 'except Exception as exc' branch and only fired when the callback raised. Dedent the try/except back to 12-space indent so it runs after the callback succeeds, restoring the original flow that removes the inline buttons and shows the 'Switched to ...' confirmation. Add a regression test (test_model_selected_edits_message_on_success) that asserts edit_message_text is awaited and the result text is routed through format_message (MARKDOWN_V2 + backtick survival). Add phuongvm to scripts/release.py AUTHOR_MAP. --- gateway/platforms/telegram.py | 22 +++++------ scripts/release.py | 1 + tests/gateway/test_telegram_model_picker.py | 44 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 03184ac1c20..753f8c231e0 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2534,22 +2534,22 @@ class TelegramAdapter(BasePlatformAdapter): result_text = f"Error switching model: {exc}" # Edit message to show confirmation, remove buttons + try: + await query.edit_message_text( + text=self.format_message(result_text), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=None, + ) + except Exception: + # Markdown parse failure — retry as plain text try: await query.edit_message_text( - text=self.format_message(result_text), - parse_mode=ParseMode.MARKDOWN_V2, + text=result_text, + parse_mode=None, reply_markup=None, ) except Exception: - # Markdown parse failure — retry as plain text - try: - await query.edit_message_text( - text=result_text, - parse_mode=None, - reply_markup=None, - ) - except Exception: - pass + pass await query.answer(text="Model switched!") # Clean up state diff --git a/scripts/release.py b/scripts/release.py index f9de395d195..60093b4821a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -715,6 +715,7 @@ AUTHOR_MAP = { "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", diff --git a/tests/gateway/test_telegram_model_picker.py b/tests/gateway/test_telegram_model_picker.py index 19928ffa128..3e1d4cf71e8 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/tests/gateway/test_telegram_model_picker.py @@ -102,6 +102,50 @@ class TestTelegramModelPicker: 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() From 63991bbd9751015f459dbb27e0440b14c1c77e3a Mon Sep 17 00:00:00 2001 From: binhnt92 Date: Tue, 12 May 2026 12:45:26 +0700 Subject: [PATCH 107/214] fix(memory): skip OpenViking upload symlinks --- plugins/memory/openviking/__init__.py | 7 ++++ .../memory/test_openviking_provider.py | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 62078000866..ecb02b3de7e 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -336,10 +336,17 @@ ADD_RESOURCE_SCHEMA = { 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/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 127528205b2..3f609cd1d67 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() From 12f755c9eb56a7927065c305699fc983bc1d998a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 07:55:09 -0700 Subject: [PATCH 108/214] fix(codex-runtime): retire wedged sessions + post-tool watchdog + OAuth refresh classify (#25769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors openclaw beta.8's app-server resilience fixes so a stuck codex subprocess can't burn the full turn deadline and so users get a `codex login` pointer instead of raw RPC errors when their token expires. - TurnResult.should_retire signals the caller to drop+respawn codex. - Deadline-hit path and dead-subprocess detection set should_retire so the next turn doesn't ride a CPU-spinning or auth-broken process. - Post-tool watchdog (post_tool_quiet_timeout=90s): if a tool item completes and codex goes silent past the threshold without further output or turn/completed, fast-fail instead of waiting the full 600s. Resets on any non-tool activity so normal think-after-tool flows are not affected. - and in agent text are treated as terminal — some codex builds tear down a turn that way without emitting turn/completed. - _classify_oauth_failure() inspects RPC error message + stderr tail for invalid_grant / token refresh / 401 / etc. and rewrites user-facing errors to 'run codex login'. Conservative: generic failures still surface verbatim. Fires at turn/start failure, turn/completed failure, and dead-subprocess paths. - thread/start cross-fill: tolerate thread.id, thread.sessionId, top-level sessionId/threadId so future codex schema drift doesn't KeyError us at handshake. - run_agent.py: when run_turn returns should_retire=True OR raises, close + null self._codex_session so the next turn respawns. Tests: +30 cases across session + integration suites. tests/agent/transports/test_codex_app_server_session.py 50/50 pass tests/run_agent/test_codex_app_server_integration.py 27/27 pass Broader codex scope (transports + cli runtime/migration) 376/376 pass --- agent/transports/codex_app_server_session.py | 227 +++++++++- run_agent.py | 23 + .../test_codex_app_server_session.py | 394 ++++++++++++++++++ .../test_codex_app_server_integration.py | 74 ++++ 4 files changed, 711 insertions(+), 7 deletions(-) diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 619cfeabfc1..8775b54edb4 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -63,6 +63,73 @@ class TurnResult: 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 @@ -156,7 +223,26 @@ class CodexAppServerSession: # ~/.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) - self._thread_id = result["thread"]["id"] + # 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], @@ -198,10 +284,18 @@ class CodexAppServerSession: *, 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.""" + 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. + """ self.ensure_started() assert self._client is not None and self._thread_id is not None @@ -221,12 +315,36 @@ class CodexAppServerSession: timeout=10, ) except CodexAppServerError as exc: - result.error = f"turn/start failed: {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 = f"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 f"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(): @@ -234,6 +352,38 @@ class CodexAppServerSession: 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) + result.error = hint or ( + f"codex app-server subprocess exited unexpectedly: " + f"{stderr_blob[-300:] if stderr_blob else ''}" + ) + 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) @@ -252,9 +402,20 @@ class CodexAppServerSession: 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( @@ -282,10 +443,29 @@ class CodexAppServerSession: 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 @@ -297,16 +477,31 @@ class CodexAppServerSession: (note.get("params") or {}).get("turn") or {} ).get("error") if err_obj: - result.error = ( - f"turn ended status={turn_status}: " - f"{err_obj.get('message') or 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 = ( + 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. + # 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 result.error = result.error or f"turn timed out after {turn_timeout}s" + result.should_retire = True return result @@ -515,6 +710,24 @@ def _approval_choice_to_codex_decision(choice: str) -> str: 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: diff --git a/run_agent.py b/run_agent.py index d995c607de6..b60f6c43ce6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -15721,6 +15721,13 @@ class AIAgent: 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}. " @@ -15733,6 +15740,22 @@ class AIAgent: "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. diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index de0b2f60cb8..e74d5a20c18 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -84,6 +84,14 @@ class FakeClient: 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}) @@ -91,6 +99,10 @@ class FakeClient: 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( @@ -500,3 +512,385 @@ class TestApprovalPromptEnrichment: 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/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index 6fc60695d2a..46e47bae13e 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -342,3 +342,77 @@ class TestErrorHandling: 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"] From d8fdec16d5a2a50e5463351af073e4401b6ed0ed Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 07:57:00 -0700 Subject: [PATCH 109/214] chore(release): add AUTHOR_MAP entries for second new-contributor batch Pre-stages AUTHOR_MAP for 7 new contributors in the upcoming batch: - HxT9 (#25760) - evgyur (#25651) - AsoTora (#25624) - oxngon (#25603) - yifengingit (#25589) - vanthinh6886 (#25562) - Arkmusn (#25559) EthanGuo-coder, wesleysimplicio, and zccyman are already in the map. --- scripts/release.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 60093b4821a..09b99a9d995 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1029,6 +1029,13 @@ AUTHOR_MAP = { "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) } From 8ae65d5c8cf13047a4c2723d5eb44a2391b3c932 Mon Sep 17 00:00:00 2001 From: Arkmusn <16034932+Arkmusn@users.noreply.github.com> Date: Thu, 14 May 2026 07:57:24 -0700 Subject: [PATCH 110/214] fix: read approvals.timeout from config in CLI approval callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _approval_callback method in HermesCLI hardcoded timeout=60 instead of reading the approvals.timeout config value. This meant the config setting was silently ignored for CLI interactive prompts. Other approval paths (callbacks.py, tools/approval.py) already read the config correctly — only cli.py was missed. --- cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 5a0b9fbdf2f..f84161dd456 100644 --- a/cli.py +++ b/cli.py @@ -10064,7 +10064,7 @@ class HermesCLI: 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 = { From c03acca508bd06c78761af2653ebef1a1448b307 Mon Sep 17 00:00:00 2001 From: yifengingit <37467487+yifengingit@users.noreply.github.com> Date: Thu, 14 May 2026 07:57:47 -0700 Subject: [PATCH 111/214] fix: use AUTOINCREMENT id for message ordering instead of timestamp On WSL2 (and similar environments), time.time() is not strictly monotonic due to NTP sync or host clock adjustments. When clock regression occurs during a multi-tool flush, later-inserted rows get earlier timestamps, causing ORDER BY timestamp, id to sort them before rows that were written first. This breaks the tool_calls/tool_response adjacency invariant and triggers HTTP 400 from the API. Use ORDER BY id instead, since id (INTEGER PRIMARY KEY AUTOINCREMENT) always reflects true insertion order regardless of system clock behavior. --- hermes_state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index adbdff19ac9..f693f391f78 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1597,10 +1597,10 @@ class SessionDB: 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 @@ class SessionDB: "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() From 1dd33988e26d8f16fb752b3c014a8509b2db569e Mon Sep 17 00:00:00 2001 From: evgyur <120411712+evgyur@users.noreply.github.com> Date: Thu, 14 May 2026 07:58:13 -0700 Subject: [PATCH 112/214] docs: clarify media impact on session context --- website/docs/user-guide/sessions.md | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index b455ea92e37..e90c3f60bcb 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: From 1247ff2dca0dbc68957ee4ad153aa34f165a184d Mon Sep 17 00:00:00 2001 From: AsoTora <36507055+AsoTora@users.noreply.github.com> Date: Thu, 14 May 2026 07:58:37 -0700 Subject: [PATCH 113/214] fix: stop retrying initial MCP auth failures --- tests/tools/test_mcp_tool.py | 34 ++++++++++++++++++++++++++++++++++ tools/mcp_tool.py | 10 ++++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index a10c7f43616..5558a0df48c 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -1592,6 +1592,40 @@ class TestReconnection: 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/tools/mcp_tool.py b/tools/mcp_tool.py index 1e10b276f1e..ee1843043dc 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1499,6 +1499,16 @@ class MCPServerTask: # 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( From f26098e22f17025b9d57b176898c7d60d5b5ce8b Mon Sep 17 00:00:00 2001 From: zccyman <16263913+zccyman@users.noreply.github.com> Date: Thu, 14 May 2026 07:59:06 -0700 Subject: [PATCH 114/214] fix(gateway): enable text-intercept for multi-choice clarify fallback (#25567) --- gateway/platforms/base.py | 12 ++++++++++-- tests/tools/test_clarify_gateway.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 0bf7b9a2ad9..ad9dac170ee 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1774,8 +1774,12 @@ class BasePlatformAdapter(ABC): 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 +1788,10 @@ class BasePlatformAdapter(ABC): 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/tests/tools/test_clarify_gateway.py b/tests/tools/test_clarify_gateway.py index 61ea55c8cfc..86385be3571 100644 --- a/tests/tools/test_clarify_gateway.py +++ b/tests/tools/test_clarify_gateway.py @@ -205,3 +205,23 @@ class TestGatewayTextIntercept: 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") From a952ca3ff6af24f867737094d2d13ab2a3ba3bbe Mon Sep 17 00:00:00 2001 From: vanthinh6886 <89525629+vanthinh6886@users.noreply.github.com> Date: Thu, 14 May 2026 07:59:31 -0700 Subject: [PATCH 115/214] fix: restrict .env file permissions to 0600 Set file mode 0600 on ~/.hermes/.env after creation in the installer and after every write via memory_setup._write_env_vars(). This ensures only the file owner can read/write API keys and tokens, matching standard practice for credential files (.netrc, .aws/credentials, .ssh/config). Fixes #25477 --- hermes_cli/memory_setup.py | 6 ++++++ scripts/install.sh | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 6ae15e08838..1ee5ed2ec8e 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -379,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/scripts/install.sh b/scripts/install.sh index 75e8f1eed5b..1ee5a31ec64 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1426,6 +1426,10 @@ copy_config_templates() { else log_info "~/.hermes/.env already exists, keeping it" fi + # Restrict .env permissions — this file holds API keys and tokens. + # 0600 ensures only the file owner can read/write, matching standard + # practice for credential files (.netrc, .aws/credentials, .ssh/config). + chmod 600 "$HERMES_HOME/.env" configure_browser_env_from_system_browser # Create config.yaml at ~/.hermes/config.yaml (top level, easy to find) From 3adde245b72cd19061d413993c4a56138a023295 Mon Sep 17 00:00:00 2001 From: oxngon <98992931+oxngon@users.noreply.github.com> Date: Thu, 14 May 2026 08:01:27 -0700 Subject: [PATCH 116/214] fix(gateway): forward image attachments to background agent tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway spawned a background agent (e.g. for delegation), media URLs and types from the originating message weren't forwarded — the bg agent saw the prompt but no attached images. Vision-enabled tasks effectively lost their inputs. Forwards media_urls/media_types through the bg-task spawn path and runs the same vision-enrichment step the main flow uses, so the bg agent gets image descriptions inlined into its prompt. Closes #25614. Salvage of #25603 by @oxngon (manually re-applied — original branch was severely stale against current main). --- gateway/run.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 5027c800ea0..6dfef600593 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10355,6 +10355,10 @@ class GatewayRunner: 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( @@ -10362,6 +10366,8 @@ class GatewayRunner: source, task_id, event_message_id=event_message_id, + media_urls=media_urls, + media_types=media_types, ) ) self._background_tasks.add(_task) @@ -10376,10 +10382,15 @@ class GatewayRunner: 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) @@ -10415,6 +10426,23 @@ class GatewayRunner: 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"], @@ -10446,7 +10474,7 @@ class GatewayRunner: ) try: return agent.run_conversation( - user_message=prompt, + user_message=enriched_prompt, task_id=task_id, ) finally: From 364ddd45e8dbfbcdf365794e7ca8e3a3e49de100 Mon Sep 17 00:00:00 2001 From: wesleysimplicio <6108320+wesleysimplicio@users.noreply.github.com> Date: Thu, 14 May 2026 08:01:53 -0700 Subject: [PATCH 117/214] fix(terminal): prevent safety filter false positives on keywords inside quoted strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _foreground_background_guidance() function matched background-wrapper keywords (nohup/disown/setsid) anywhere in the command text, including inside quoted strings, Python -c code, commit messages, and PR body text. Two-layer fix: 1. Strip single-quoted, double-quoted, and backtick-quoted content before pattern matching via _strip_quotes() helper. 2. Tighten the regex to only match keywords at command-start positions (after ^, ;, &, &&, ||, or $() — not mid-argument. Both layers are needed: quote stripping handles the common case of keywords in string literals, and the position-aware regex handles unquoted cases like 'export FOO=setsid' (word boundary match, wrong position). Fixes #20064 --- tools/terminal_tool.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 4d8512c345e..e0d07e80f6e 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), " From 529ec85c77f4f7993c49bca99e647a3b31ee9872 Mon Sep 17 00:00:00 2001 From: wesleysimplicio <6108320+wesleysimplicio@users.noreply.github.com> Date: Thu, 14 May 2026 08:02:18 -0700 Subject: [PATCH 118/214] chore(release): map oswaldb22 noreply email for AUTHOR_MAP Co-Authored-By: Oswald --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 09b99a9d995..1712c327309 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -88,6 +88,7 @@ AUTHOR_MAP = { "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", From 436a0a271e57400a11bd9e918e2eafdf9162146e Mon Sep 17 00:00:00 2001 From: wesleysimplicio <6108320+wesleysimplicio@users.noreply.github.com> Date: Thu, 14 May 2026 08:03:26 -0700 Subject: [PATCH 119/214] test(toolsets): lock web search into default platform coverage Adds regression tests pinning web search into the WhatsApp and api-server default platform-coverage toolsets. Pure test additions, no runtime change. Salvage of the test-addition commit from #25692 by @wesleysimplicio. (The AUTHOR_MAP fixup commit from the same PR landed separately as 529ec85c7.) --- tests/hermes_cli/test_tools_config.py | 6 ++++++ tests/test_toolsets.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index b284d5df199..8a94ce4302f 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/test_toolsets.py b/tests/test_toolsets.py index afd618a92e6..a6f4fc6b72e 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -246,3 +246,11 @@ class TestPluginToolsets: 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") From 72b5dd865865f2d2c9f5b492bcac9dcdaf045d34 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 08:03:40 -0700 Subject: [PATCH 120/214] fix(update): refresh lazy-installed backends on hermes update (#25766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pyproject's [all] extra was slimmed down in May 2026 — ~20 optional backends moved to tools/lazy_deps.py and only install on first use. hermes update runs uv pip install -e .[all] which doesn't touch any of them, so pin bumps in LAZY_DEPS (CVE response, transitive fixes) were silently ignored on already-activated backends. Two changes: 1. _is_satisfied() now parses the spec and checks the installed version against the constraint via packaging.specifiers. Previously it returned True the moment the package name was importable, which made ensure() a name-presence gate rather than a version-pin gate. 2. New active_features() / refresh_active_features() pair: lists every feature with at least one of its packages currently installed, then re-runs ensure() on each. Refresh is invoked at the end of _cmd_update_impl, right after the [all] install completes. Cold backends (never activated) stay quiet — no churn for them. Output during update is one summary block: → Refreshing 4 active lazy backend(s)... ↑ 1 refreshed: provider.anthropic ✓ 3 already current or ⚠ memory.honcho failed to refresh: Failures never raise out of update — backends keep their previously- installed version and we tell the user to rerun once upstream is fixed. security.allow_lazy_installs=false is honored: features get marked "skipped" with the reason shown. Tests: 18 new unit tests covering version-aware satisfaction (exact pin, range, extras blocks, missing package, malformed spec), active feature discovery, and refresh status reporting. All 61 lazy_deps tests pass. --- hermes_cli/main.py | 70 +++++++++++++ tests/tools/test_lazy_deps.py | 179 ++++++++++++++++++++++++++++++++++ tools/lazy_deps.py | 118 +++++++++++++++++++++- 3 files changed, 362 insertions(+), 5 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 09752fed433..a75e4ff40e8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6827,6 +6827,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], *, @@ -7749,6 +7817,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") diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 9beecc0d995..714c5995eaa 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -226,3 +226,182 @@ class TestIsAvailable: 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/tools/lazy_deps.py b/tools/lazy_deps.py index 60883663439..09347e8281c 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str: return m.group(1) if m else spec -def _is_satisfied(spec: str) -> bool: - """Best-effort check: is ``spec`` already satisfied in the current env? +def _specifier_from_spec(spec: str) -> str: + """Extract just the version-specifier portion of a pip spec. - 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. + ``"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: + """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. + + 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: @@ -442,6 +499,57 @@ def feature_install_command(feature: str) -> Optional[str]: 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]], From 26933c2f592bda25df735c555620a2a978cfefb6 Mon Sep 17 00:00:00 2001 From: EthanGuo-coder <188665641+EthanGuo-coder@users.noreply.github.com> Date: Thu, 14 May 2026 08:03:50 -0700 Subject: [PATCH 121/214] fix(agent/gemini-cloudcode): seed delta defaults for reasoning-only stream chunks _make_stream_chunk built delta_kwargs with only `role`, so a reasoning-only chunk produced a SimpleNamespace without a `.content` attribute. Downstream consumers that read `delta.content` then raised AttributeError on Gemini 2.5 Flash, where the thinking delta arrives before any content delta. Seed `content`, `tool_calls`, `reasoning`, and `reasoning_content` as None up front, matching the pattern already used in gemini_native_adapter.py. Key-present arguments still override the defaults. Fixes #24974 References: Related open PR #24984 (luyao618) applies the same 1-line fix; this PR adds a regression test that #24984 omits Co-Authored-By: Claude --- agent/gemini_cloudcode_adapter.py | 8 +++++++- tests/agent/test_gemini_cloudcode.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index 5bc42e3aad7..222327807be 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/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py index dc2b1b15311..480f562aa64 100644 --- a/tests/agent/test_gemini_cloudcode.py +++ b/tests/agent/test_gemini_cloudcode.py @@ -913,6 +913,35 @@ class TestTranslateStreamEvent: 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 From 78b842c995d70fccb7fd1113f85e766c1483e562 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 09:05:31 -0700 Subject: [PATCH 122/214] fix(install): support non-sudo service-user installs on apt distros (#25814) The Debian/Ubuntu branch of install_node_deps() ran 'npx playwright install --with-deps chromium' unconditionally. Playwright invokes sudo interactively to apt-install Chromium's system libraries, which blocks the installer for non-sudo users (systemd service accounts, unprivileged operator users) on an unsatisfiable password prompt. Changes: - install.sh: gate --with-deps behind a sudo capability check on the apt branch (matches the existing Arch/pacman branch pattern). Non-sudo users fall back to 'npx playwright install chromium' alone and the installer prints the exact 'sudo npx playwright install-deps chromium' command an administrator can run separately. - install.sh: add --skip-browser (alias --no-playwright) to skip the Playwright step entirely for headless installs that don't need browser automation. Mirrors the existing --no-venv / --skip-setup shape. - installation.md: add a 'Non-Sudo / System Service User Installs' section covering the admin/service-user split, the --skip-browser flag, and the ~/.local/bin PATH gotcha (the root cause of the 'No module named dotenv' error users hit when running the repo source 'hermes' script with system Python instead of the venv launcher). - test_install_sh_browser_install.py: regression coverage for the --skip-browser flag and the sudo-gate on the apt branch. Reported by @ssilver in Discord. --- scripts/install.sh | 44 +++++++++++++++++--- tests/test_install_sh_browser_install.py | 25 +++++++++++ website/docs/getting-started/installation.md | 37 ++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 1ee5a31ec64..cf24912cc51 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -69,6 +69,7 @@ DETECTED_BROWSER_EXECUTABLE="" # Options USE_VENV=true RUN_SETUP=true +SKIP_BROWSER=false BRANCH="main" # Detect non-interactive mode (e.g. curl | bash) @@ -91,6 +92,10 @@ while [[ $# -gt 0 ]]; do RUN_SETUP=false shift ;; + --skip-browser|--no-playwright) + SKIP_BROWSER=true + shift + ;; --branch) BRANCH="$2" shift 2 @@ -112,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" @@ -1566,6 +1572,13 @@ 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)..." DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" if [ -n "$DETECTED_BROWSER_EXECUTABLE" ]; then @@ -1574,12 +1587,30 @@ install_node_deps() { else 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" && 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" - } + # 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 "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 ;; arch|manjaro|cachyos|endeavouros|garuda) if command -v pacman &> /dev/null; then @@ -1624,6 +1655,7 @@ install_node_deps() { ;; esac fi + fi log_success "Browser engine setup complete" fi diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py index 4e1908e4294..6ec3b565384 100644 --- a/tests/test_install_sh_browser_install.py +++ b/tests/test_install_sh_browser_install.py @@ -32,4 +32,29 @@ def test_playwright_installs_are_timeout_guarded() -> None: 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/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 102f044d501..c8db40a9137 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 | From b08f53a75893ec4dfa6c470e9f27bc039fce6f07 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 09:34:10 -0700 Subject: [PATCH 123/214] skill(comfyui): add template-integrity reference from @purzbeats (#25828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds references/template-integrity.md covering safe conversion of the official comfyui-workflow-templates package from editor format to API format — Reroute bypass via link tracing, dotted dynamic-input keys (values.a, resize_type.width) that must NOT be flattened, server-error "patch don't rebuild" loop, Cloud quirks (302 redirect to signed GCS URL, free-tier 1 concurrent job, 1920x1080 OOM on RTX 5090), and a Discord-compatible ffmpeg stitch recipe (yuv420p + xfade/acrossfade). SKILL.md lists the new reference so the agent loads it when starting from an official template. purzbeats added to author list and to scripts/release.py AUTHOR_MAP. Co-authored-by: purzbeats <97489706+purzbeats@users.noreply.github.com> --- scripts/release.py | 1 + skills/creative/comfyui/SKILL.md | 10 +- .../comfyui/references/template-integrity.md | 243 ++++++++++++++++++ 3 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 skills/creative/comfyui/references/template-integrity.md diff --git a/scripts/release.py b/scripts/release.py index 1712c327309..c16e8341d24 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -71,6 +71,7 @@ AUTHOR_MAP = { "kyanam.preetham@gmail.com": "pkyanam", "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", diff --git a/skills/creative/comfyui/SKILL.md b/skills/creative/comfyui/SKILL.md index 4fbeb603572..e5a8a7c0745 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 00000000000..050e3e6b5cf --- /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`. From 9ed751b96706ffd343ae26531cd0e2152a1c7036 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 09:59:03 -0700 Subject: [PATCH 124/214] fix(whatsapp): drop status broadcasts and channel newsletters before agent dispatch (#25845) WhatsApp pseudo-chats (Status updates / Stories, Channels / Newsletters, broadcast lists) were being routed through the full agent pipeline. A user's gateway.log showed the agent replying to a contact's Story ('status@broadcast') with 345 chars plus title-generation cost, which also shows up in the contact's status feed. Drop these JIDs at _should_process_message() before the policy gate so they're filtered regardless of dm_policy or allowlist state. Covers: - status@broadcast (Stories) - *@newsletter (Channels) - *@broadcast (broadcast lists, future-proofing) The bridge.js already filters these on the fromMe outbound path, but inbound events on self-chat mode skipped that check. Tests: - status@broadcast dropped on open policy - broadcast filter wins over allowlisted senders - real DMs still pass through - helper unit cases (case-insensitive, whitespace-tolerant) 26/26 tests/gateway/test_whatsapp_group_gating.py pass; 59/59 adjacent WhatsApp test suites pass. --- gateway/platforms/whatsapp.py | 29 +++++++- tests/gateway/test_whatsapp_group_gating.py | 75 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 29b78d75d01..5239df3b5ae 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -322,6 +322,26 @@ class WhatsAppAdapter(BasePlatformAdapter): 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 @@ class WhatsAppAdapter(BasePlatformAdapter): 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/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index afe974320c9..206c75830b7 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] From 5ce0067c08a81181c5b550a5bc8fcb0262ece2df Mon Sep 17 00:00:00 2001 From: Stephen Schoettler Date: Thu, 14 May 2026 14:28:14 -0700 Subject: [PATCH 125/214] fix(ci): stabilize shared test state after 21012 --- agent/context_compressor.py | 12 ++++++++++-- run_agent.py | 4 +++- .../test_context_compressor_summary_continuity.py | 2 ++ tests/conftest.py | 8 +++++--- tests/hermes_cli/test_update_autostash.py | 1 + tests/providers/test_plugin_discovery.py | 6 +++--- tests/run_agent/test_compression_feasibility.py | 14 ++++++++++++++ 7 files changed, 38 insertions(+), 9 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index df75b8b88ce..e7a14faf51b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1429,15 +1429,23 @@ The user has requested that this compaction PRIORITISE preserving all informatio 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/run_agent.py b/run_agent.py index b60f6c43ce6..e2605ebee88 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4268,6 +4268,7 @@ class AIAgent: except Exception: pass review_agent = None + review_messages = [] try: with open(os.devnull, "w", encoding="utf-8") as _devnull, \ contextlib.redirect_stdout(_devnull), \ @@ -4385,6 +4386,7 @@ class AIAgent: 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 @@ -4394,7 +4396,7 @@ class AIAgent: # 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, ) diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index d9a27375834..d797b661f01 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/conftest.py b/tests/conftest.py index 5d7f197f195..d9ae0c86ea6 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/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 645b3b24ea4..f7d90245a81 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/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index 9ad6713e3ec..a7cbb7d9030 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -46,14 +46,14 @@ def test_bundled_plugins_discovered(): 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_34_profiles_register(): + """After discovery, the registry must contain exactly 34 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) == 34, f"Expected 34 profiles, got {len(names)}: {names}" # Spot-check representative providers from different categories for required in ( diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index f935821ada9..3e23f3eb5d3 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -16,6 +16,16 @@ from run_agent import AIAgent 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 test_init_feasibility_check_uses_aux_context_override_from_config(): api_key="sk-custom", config_context_length=1_000_000, provider="", + custom_providers=[], ) From d44dafdb4e2ea8874fd309b0b3d0780ba966cada Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Thu, 14 May 2026 21:43:28 +0800 Subject: [PATCH 126/214] fix(telegram): set REQUIRES_EDIT_FINALIZE so final MarkdownV2 edit is not skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the final streamed text is identical to the last plain-text edit, stream_consumer._send_or_edit short-circuits and never calls adapter.edit_message(finalize=True). For Telegram, this skips the plain-text → MarkdownV2 conversion, leaving raw Markdown syntax visible to the user. Set REQUIRES_EDIT_FINALIZE = True on TelegramAdapter so the finalize edit is always delivered, matching the existing DingTalk pattern. Fixes #25710 --- gateway/platforms/telegram.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 753f8c231e0..4c56937e5cb 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 From b4b8509fe81acf36bc1d32b8f586dc5e09e46e72 Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Thu, 14 May 2026 20:40:41 +0800 Subject: [PATCH 127/214] fix(gateway): load streaming config from nested gateway.streaming key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes config set gateway.streaming.*` writes the streaming block nested under a `gateway:` key in config.yaml, but the config loader only checked for a top-level `streaming:` key — silently ignoring the nested variant. Fall back to `yaml_cfg['gateway']['streaming']` when the top-level key is absent, matching the pattern already used for other nested config sections. Closes #25676 --- gateway/config.py | 4 ++ tests/test_gateway_streaming_nested_config.py | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/test_gateway_streaming_nested_config.py diff --git a/gateway/config.py b/gateway/config.py index 39a583e2e79..b3b87e24664 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -735,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 diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py new file mode 100644 index 00000000000..8db8988f40c --- /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" From bc42e62b171c622eab9dc9c2d9860e24feb1fe9f Mon Sep 17 00:00:00 2001 From: VTRiot <105142614+VTRiot@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:06:10 +0900 Subject: [PATCH 128/214] fix(gateway): prevent duplicate final send when only cosmetic edit failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the stream consumer's got_done handler successfully delivers the final response content via _send_or_edit but the subsequent edit (e.g. cursor removal) fails, final_response_sent remains False even though the user has already received the final answer. The gateway's fallback send path then re-delivers the same content, causing the user to see the response twice on Telegram. Introduce a new _final_content_delivered flag on the stream consumer, set by the got_done handler when the final content has reached the user. The _run_agent suppression logic now treats this flag as an additional signal (alongside final_response_sent and response_previewed) that final delivery is already complete. This preserves the existing behavior for intermediate-text-only streams (where already_sent=True but no final content has been delivered) — those still receive the gateway's fallback send, matching the test expectation in test_partial_stream_output_does_not_set_already_sent. Adds TestFinalContentDeliveredSuppression with two cases covering both the suppression (content delivered + edit failed) and the non-suppression (intermediate text only) branches. --- gateway/run.py | 9 ++- gateway/stream_consumer.py | 17 ++++++ .../test_duplicate_reply_suppression.py | 56 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 6dfef600593..77ed7260c3b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16131,6 +16131,7 @@ class GatewayRunner: _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: @@ -16292,12 +16293,16 @@ class GatewayRunner: # 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/stream_consumer.py b/gateway/stream_consumer.py index 558a86bd295..3c761d528ab 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -150,6 +150,10 @@ class GatewayStreamConsumer: 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 @@ class GatewayStreamConsumer: """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 @@ class GatewayStreamConsumer: # 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 @@ class GatewayStreamConsumer: 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/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py index 908e023d883..7e54515d6a6 100644 --- a/tests/gateway/test_duplicate_reply_suppression.py +++ b/tests/gateway/test_duplicate_reply_suppression.py @@ -467,3 +467,59 @@ class TestCancellationHandlerDeliveryConfirmation: 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 From a28add199d3d4bb29482723256f9e6c00f93d213 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 14 May 2026 13:22:08 -0600 Subject: [PATCH 129/214] fix(agent): keep image tool results from poisoning text-only sessions --- run_agent.py | 61 +++++++++++++++++++++++++------- tests/tools/test_computer_use.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/run_agent.py b/run_agent.py index b60f6c43ce6..906f706d08a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9324,6 +9324,46 @@ class AIAgent: ) 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). @@ -11096,14 +11136,10 @@ class AIAgent: # 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, @@ -11518,11 +11554,7 @@ class AIAgent: # 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, @@ -13535,6 +13567,11 @@ class AIAgent: # 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( diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 58700dcaaf2..5b035950348 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -591,6 +591,67 @@ class TestRunAgentMultimodalHelpers: 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? From fe83c4001bb77cdda5c0922805455e2ec9c9ffd5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 14:55:23 -0700 Subject: [PATCH 130/214] fix(codex-app-server): attach redacted stderr tail to generic failures (#25929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When codex app-server fails outside the OAuth-classified path (non-auth turn/start errors, plain TimeoutErrors, generic turn-ended status, subprocess silently exits, hard deadline timeout), the user got a bare 'Internal error' / 'turn/start failed: ...' with no context. Diagnosing config/provider/auth-bridge issues forced a re-run with verbose codex flags. Add a _format_error_with_stderr helper that appends the last few stderr lines via agent.redact.redact_sensitive_text(force=True), and use it at every catch-all error site: - ensure_started() failures (codex init / thread/start) now return a TurnResult.error with should_retire=True instead of bubbling - non-OAuth turn/start CodexAppServerError / TimeoutError - subprocess-died branch (previously dumped raw stderr_blob[-300:] with no redaction — a leak risk) - turn ended with non-completed status - hard turn-timeout deadline OAuth-classified failures and the post-tool quiet watchdog already produce clean hints and stay unchanged. The redactor catches sk-*, gh*_*, Authorization: Bearer, query-string tokens, JWTs, private keys, etc., so provider error payloads can't leak into chat output or trajectories. Inspired by openclaw#80718, adapted for our app-server transport. --- agent/transports/codex_app_server_session.py | 94 ++++++++++++++++--- .../test_codex_app_server_session.py | 80 ++++++++++++++++ 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 8775b54edb4..f0cd0a196c4 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -31,6 +31,7 @@ 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, @@ -40,6 +41,13 @@ 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). @@ -276,6 +284,45 @@ class CodexAppServerSession: 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( @@ -296,12 +343,27 @@ class CodexAppServerSession: Mirrors openclaw beta.8's post-tool completion watchdog (#81697) so a wedged codex doesn't burn the full turn deadline. """ - self.ensure_started() + # 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() - result = TurnResult(thread_id=self._thread_id) # Send turn/start with the user input. Text-only for now (codex # supports rich content but Hermes' text path is the common case). @@ -327,13 +389,17 @@ class CodexAppServerSession: # via `codex login` between turns). result.should_retire = True else: - result.error = f"turn/start failed: {exc}" + 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 f"turn/start timed out: {exc}" + result.error = hint or self._format_error_with_stderr( + "turn/start timed out", exc + ) result.should_retire = True return result @@ -359,10 +425,13 @@ class CodexAppServerSession: if not self._client.is_alive(): stderr_blob = "\n".join(self._client.stderr_tail(60)) hint = _classify_oauth_failure(stderr_blob) - result.error = hint or ( - f"codex app-server subprocess exited unexpectedly: " - f"{stderr_blob[-300:] if stderr_blob else ''}" - ) + 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 @@ -489,8 +558,8 @@ class CodexAppServerSession: result.error = hint result.should_retire = True else: - result.error = ( - f"turn ended status={turn_status}: {err_msg}" + result.error = self._format_error_with_stderr( + f"turn ended status={turn_status}", err_msg ) if not turn_complete and not result.interrupted: @@ -500,7 +569,10 @@ class CodexAppServerSession: # turn shouldn't inherit. self._issue_interrupt(result.turn_id) result.interrupted = True - result.error = result.error or f"turn timed out after {turn_timeout}s" + 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 diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index e74d5a20c18..f51996dd067 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -231,6 +231,86 @@ class TestRunTurn: 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 From 06c6c1f0f2d9872b02f86c6cd8279354aaf4dd9f Mon Sep 17 00:00:00 2001 From: Xu Zhizhong Date: Wed, 13 May 2026 20:25:35 +0800 Subject: [PATCH 131/214] fix(cli): batch resize history replay --- cli.py | 12 ++++++++++-- tests/cli/test_cprint_bg_thread.py | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index f84161dd456..d16f3d956e3 100644 --- a/cli.py +++ b/cli.py @@ -1473,6 +1473,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 +1484,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: diff --git a/tests/cli/test_cprint_bg_thread.py b/tests/cli/test_cprint_bg_thread.py index bb0e59d064e..424fe83882f 100644 --- a/tests/cli/test_cprint_bg_thread.py +++ b/tests/cli/test_cprint_bg_thread.py @@ -258,10 +258,25 @@ def test_replay_output_history_rerenders_callable_entries(monkeypatch): 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_suspend_output_history_blocks_recording(): cli._configure_output_history(True, 10) From 7bf66a07bd0863915e019ec23fc1601628697efa Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:11:28 -0700 Subject: [PATCH 132/214] chore(release): map @1000Delta in AUTHOR_MAP --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index c16e8341d24..ebdf85e64c9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -69,6 +69,8 @@ AUTHOR_MAP = { "datapod.k@gmail.com": "dandacompany", "treydong.zh@gmail.com": "TreyDong", "kyanam.preetham@gmail.com": "pkyanam", + "zhizhong.xu@shopee.com": "1000Delta", + "30397170+1000Delta@users.noreply.github.com": "1000Delta", "127238744+teknium1@users.noreply.github.com": "teknium1", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", "97489706+purzbeats@users.noreply.github.com": "purzbeats", From 62445356822cd449c4235dc8e2f543c88c106a4d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:12:10 -0700 Subject: [PATCH 133/214] fix(voice): remove per-tool-call beep in CLI voice mode (#25967) The spinner already shows tool activity visually; the 1.2 kHz tone on every tool.started event was unwanted noise (especially on WSL2, where each beep also triggers Windows Terminal's bell notification). Removed the play_beep call in _on_tool_progress entirely. Record start/stop beeps (gated by voice.beep_enabled) are unaffected. --- cli.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/cli.py b/cli.py index d16f3d956e3..fa2c378b2c8 100644 --- a/cli.py +++ b/cli.py @@ -9375,7 +9375,7 @@ class HermesCLI: 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 @@ -9454,20 +9454,6 @@ class HermesCLI: ) 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: From ac64d0c2caa1c7d83c2e5022a1b7612f0148021a Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 12 May 2026 17:37:27 +0800 Subject: [PATCH 134/214] fix: preserve ansi output history on resize replay --- cli.py | 9 +++------ tests/cli/test_cprint_bg_thread.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index fa2c378b2c8..1285ba6d205 100644 --- a/cli.py +++ b/cli.py @@ -1415,9 +1415,6 @@ _OUTPUT_HISTORY_REPLAYING = False _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) diff --git a/tests/cli/test_cprint_bg_thread.py b/tests/cli/test_cprint_bg_thread.py index 424fe83882f..f68e1de7c1d 100644 --- a/tests/cli/test_cprint_bg_thread.py +++ b/tests/cli/test_cprint_bg_thread.py @@ -215,13 +215,15 @@ def test_cprint_swallows_prompt_toolkit_import_error(monkeypatch): 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): @@ -277,6 +279,16 @@ def test_replay_output_history_batches_rendered_lines_into_one_print(monkeypatch 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) From f491b07cb2cfe225304b6c5729539475496ed453 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:14:04 -0700 Subject: [PATCH 135/214] chore(release): map @LeonSGP43 commit email in AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ebdf85e64c9..16835ac1170 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -146,6 +146,7 @@ AUTHOR_MAP = { "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", From 2844c888f1bb890a154cd3c25725581ca9d3e62e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:22:44 -0700 Subject: [PATCH 136/214] fix(cli): clamp scrollback box widths + suppress status bar after resize (#25975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the terminal shrinks, already-printed box-drawing rules (response, reasoning, streaming TTS, background-task Panels) reflow into multiple narrower rows — visible as duplicated horizontal separators / ghost lines in scrollback. Similarly, prompt_toolkit redraws a fresh status bar on SIGWINCH on top of one the terminal just reflowed, producing double-bar artifacts on column shrink. Two surgical changes: 1. Decorative scrollback boxes now use a new `HermesCLI._scrollback_box_width()` helper that clamps to `max(32, min(width, 56))`. The live TUI footer is unaffected and still uses the full width. Covers: streaming response box (open + close), reasoning box (open + close, both streaming and post-stream paths), streaming-TTS box close, final-response Rich Panel, and the background-task Rich Panel. 2. `_recover_after_resize()` now also sets a new `_status_bar_suppressed_after_resize` flag so the dynamic status bar and both input separator rules stay hidden until the next user input. The flag is cleared in the process loop the moment the user submits their next prompt, restoring chrome cleanly. Tests: - New `test_input_rules_hide_after_resize_until_next_input` covers the flag's effect on rule heights. - New `test_scrollback_box_width_caps_to_resize_safe_value` covers the helper at floor / cap / mid-range / overflow. - Existing resize-recovery test extended to assert the flag flips. Refs: #18449 #19280 #22976 Salvage of #24403. Co-authored-by: Szymonclawd --- cli.py | 64 ++++++++++++++++++++++++++---- scripts/release.py | 2 + tests/cli/test_cli_force_redraw.py | 8 ++++ tests/cli/test_cli_status_bar.py | 32 +++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index 1285ba6d205..75506adc655 100644 --- a/cli.py +++ b/cli.py @@ -2644,6 +2644,12 @@ class HermesCLI: # 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 @@ -2720,7 +2726,16 @@ class HermesCLI: 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: @@ -2963,10 +2978,34 @@ class HermesCLI: 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 @@ -3476,7 +3515,7 @@ class HermesCLI: # 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}") @@ -3500,7 +3539,7 @@ class HermesCLI: 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 @@ -3691,7 +3730,7 @@ class HermesCLI: 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}") @@ -3792,7 +3831,7 @@ class HermesCLI: # 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: @@ -7890,6 +7929,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) else: _cprint(" (No response generated)") @@ -10549,7 +10589,7 @@ class HermesCLI: 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')} " @@ -10834,7 +10874,7 @@ class HermesCLI: 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}" @@ -10865,7 +10905,7 @@ class HermesCLI: 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; @@ -10881,6 +10921,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) @@ -12914,7 +12955,10 @@ class HermesCLI: # 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. @@ -13083,6 +13127,10 @@ class HermesCLI: 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/scripts/release.py b/scripts/release.py index 16835ac1170..8dca03515ef 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -71,6 +71,8 @@ AUTHOR_MAP = { "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", "127238744+teknium1@users.noreply.github.com": "teknium1", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", "97489706+purzbeats@users.noreply.github.com": "purzbeats", diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index ba5b0a75534..34f5cefe06e 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -79,6 +79,10 @@ class TestForceFullRedraw: 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() events = [] @@ -86,6 +90,8 @@ class TestForceFullRedraw: 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 == [ @@ -97,6 +103,8 @@ class TestForceFullRedraw: 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_status_bar.py b/tests/cli/test_cli_status_bar.py index 16e6699aaac..445626fac9b 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -332,6 +332,38 @@ class TestCLIStatusBar: 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 From 4813aaf0ba5902ea185b1927d30a59647b4c769a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Sat, 2 May 2026 02:17:53 +0800 Subject: [PATCH 137/214] fix(ui-tui): heal same-dimension alt-screen resize drift - Treat same-dimension resize events in alt-screen mode as a repaint signal, because terminal hosts can reflow or restore the physical buffer without changing columns/rows. - Ensure pending resize erases are emitted even when the virtual diff is empty, so stale physical glyphs are still cleared. - Extract alt-screen resize repaint into prepareAltScreenResizeRepaint() for readability. - Add defensive clearTimeout in prepareAltScreenResizeRepaint so rapid resize bursts don't stack redundant delayed repaints. - Add a focused regression test for same-dimension alt-screen resize healing. Addresses #18449 Related to #17961 --- .../hermes-ink/src/ink/ink-resize.test.ts | 50 ++++++++++++ ui-tui/packages/hermes-ink/src/ink/ink.tsx | 77 +++++++++++-------- 2 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts 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 00000000000..31039491f89 --- /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 8a8603cf573..8cdfe781395 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -484,17 +484,22 @@ export default class Ink { private handleResize = () => { const cols = this.options.stdout.columns || 80 const rows = this.options.stdout.rows || 24 + const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows - // 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) { + // 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. @@ -521,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 @@ -573,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 = () => {} @@ -919,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 @@ -940,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 { @@ -1062,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) { From 34fc94d1f401d712e67625a8774294ab6969ecb1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:25:34 -0700 Subject: [PATCH 138/214] chore(release): map @luoyuctl in AUTHOR_MAP --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 8dca03515ef..a681daa49de 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -73,6 +73,8 @@ AUTHOR_MAP = { "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", From ccb5aae0d2b70206556fb57b72f38157cbbdaaa0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:40:48 -0700 Subject: [PATCH 139/214] feat(proxy): local OpenAI-compatible proxy for OAuth providers (#25969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'hermes proxy start' — a local HTTP server that lets external apps (OpenViking, Karakeep, Open WebUI, ...) use a Hermes-managed provider subscription as their LLM endpoint. The proxy attaches the user's real OAuth-resolved credentials to each forwarded request, refreshing them automatically; the client can send any bearer (it gets stripped). Ships with one adapter — Nous Portal. The UpstreamAdapter ABC and registry in hermes_cli/proxy/adapters/ are designed for additional OAuth providers to plug in by name without server changes. Commands: hermes proxy start [--provider nous] [--host 127.0.0.1] [--port 8645] hermes proxy status hermes proxy providers Allowed Portal paths: /v1/chat/completions, /v1/completions, /v1/embeddings, /v1/models. Anything else returns 404 with a clear error pointing at the allowed list. aiohttp is gated like gateway/platforms/api_server.py (try-import, clean runtime error if missing). No new core dependency. Tests: 24 unit tests + 1 separate E2E that spawns the real subprocess and verifies the upstream receives the right bearer with the client's header stripped. --- hermes_cli/main.py | 58 +- hermes_cli/proxy/__init__.py | 20 + hermes_cli/proxy/adapters/__init__.py | 35 ++ hermes_cli/proxy/adapters/base.py | 94 ++++ hermes_cli/proxy/adapters/nous_portal.py | 137 +++++ hermes_cli/proxy/cli.py | 141 +++++ hermes_cli/proxy/server.py | 265 +++++++++ tests/hermes_cli/test_proxy.py | 512 ++++++++++++++++++ website/docs/reference/cli-commands.md | 1 + .../user-guide/features/subscription-proxy.md | 203 +++++++ website/sidebars.ts | 1 + 11 files changed, 1466 insertions(+), 1 deletion(-) create mode 100644 hermes_cli/proxy/__init__.py create mode 100644 hermes_cli/proxy/adapters/__init__.py create mode 100644 hermes_cli/proxy/adapters/base.py create mode 100644 hermes_cli/proxy/adapters/nous_portal.py create mode 100644 hermes_cli/proxy/cli.py create mode 100644 hermes_cli/proxy/server.py create mode 100644 tests/hermes_cli/test_proxy.py create mode 100644 website/docs/user-guide/features/subscription-proxy.md diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a75e4ff40e8..214a1855b30 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") @@ -9385,7 +9396,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "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 @@ -9727,6 +9738,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/proxy/__init__.py b/hermes_cli/proxy/__init__.py new file mode 100644 index 00000000000..c8775990fa6 --- /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 00000000000..163d1e66f98 --- /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 00000000000..5ac8a5dcedd --- /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 00000000000..b72cbd305b3 --- /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 00000000000..83c2d34035b --- /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 00000000000..223bc3bd62d --- /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) + 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/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py new file mode 100644 index 00000000000..0c874facac7 --- /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/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4bb361a987e..a895e1efa74 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. | 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 00000000000..8f0fe31f9ca --- /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/sidebars.ts b/website/sidebars.ts index 6bdd5d296a0..37557df8d11 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -96,6 +96,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/web-dashboard', 'user-guide/features/extending-the-dashboard', + 'user-guide/features/subscription-proxy', ], }, { From e84fe483bc958ef2ce11463d10ee57bdc2ccc5fb Mon Sep 17 00:00:00 2001 From: snav Date: Thu, 14 May 2026 01:46:11 -0400 Subject: [PATCH 140/214] feat(discord): channel history backfill for multi-user sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional channel-context backfill for Discord shared-channel sessions so the agent can see recent messages it missed between its own turns (typically when require_mention=true filters out most traffic). Previously the agent only saw the @mention message that triggered it, which led to disorienting replies in active multi-user channels where the conversation context was invisible. With backfill enabled, a configurable number of recent messages are fetched per-turn and prepended to the trigger message as a context block, kept separate from sender-prefix logic so attribution remains clean. This re-opens the work from #13063 (approved by @OutThisLife on 2026-04-20, closed when I closed the branch to address the simpolism:main head-branch issue plus an ordering bug I caught later in live use). Filing against the freshly-rewritten problem statement in #13054 so the design is grounded in the failure mode rather than the implementation shape. The implementation follows the **push-mode last-self-anchored** design from the two options laid out in #13054. See the issue for the trade-off discussion vs pull-mode (#13120 was an earlier closed PR using that shape). Treating this as a reference implementation — happy to rewrite as last-trigger anchoring or as a hybrid with #13120 if maintainers prefer. Changes: - gateway/platforms/discord.py: - new `_discord_history_backfill()` / `_discord_history_backfill_limit()` helpers (config.extra > env > default), mirroring the existing `_discord_require_mention()` shape - new `_fetch_channel_context()` that scans `channel.history()` backwards from the trigger to the bot's last message (or limit), formats as `[Recent channel messages] / [name] msg / ...`, respects DISCORD_ALLOW_BOTS, skips system messages - per-channel `_last_self_message_id` cache to narrow the fetch window on hot paths (avoids full history scan when the bot has spoken recently) - **IMPORTANT**: passes `oldest_first=False` explicitly to `channel.history()`. 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 this would return stale tool traces and drop the actual final answer the user is asking about. See regression test below. Caught in live use during a Codex tool-trace burst on May 13 2026. - gateway/config.py: discord_history_backfill + discord_history_backfill_limit settings + yaml→env bridge - gateway/platforms/base.py: channel_context field on MessageEvent - gateway/run.py: prepend channel_context after sender-prefix so the [sender name] tag applies to the trigger message alone, not to the backfill - hermes_cli/config.py: defaults for new discord.history_backfill and discord.history_backfill_limit keys - cli-config.yaml.example: documented defaults - tests/gateway/test_discord_free_response.py: 7 new tests covering cold-start backfill, self-message stop boundary, other-bot filtering, cache hot-path narrowing, stale-cache fallback, shared-channel + per-user backfill paths, and the ordering regression test (`test_fetch_channel_context_cache_uses_latest_window_when_after_set`) - tests/gateway/test_config.py: yaml→env bridge tests - tests/gateway/test_session.py: prefix-order edge cases - website/docs/user-guide/messaging/discord.md: env vars + config keys + usage docs Tested on Ubuntu 24.04 — empirically validated in my own multi-bot Discord research server for the past three weeks. Fixes #13054 Supersedes #13063 (closed) --- cli-config.yaml.example | 10 + gateway/config.py | 8 + gateway/platforms/base.py | 6 + gateway/platforms/discord.py | 183 ++++++++++++- gateway/run.py | 6 + hermes_cli/config.py | 2 + tests/gateway/test_config.py | 20 ++ tests/gateway/test_discord_free_response.py | 265 +++++++++++++++++++ tests/gateway/test_session.py | 71 +++++ website/docs/user-guide/messaging/discord.md | 27 ++ 10 files changed, 596 insertions(+), 2 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 13d9ad9c420..c286099a87a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -681,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: false # Recover missed channel messages on mention (default: false) +# 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/gateway/config.py b/gateway/config.py index b3b87e24664..7180f1ddb84 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -941,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/platforms/base.py b/gateway/platforms/base.py index ad9dac170ee..d03bc282ed3 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. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 4793df35c7c..652e8d4af76 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -589,6 +589,10 @@ class DiscordAdapter(BasePlatformAdapter): # 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 @@ class DiscordAdapter(BasePlatformAdapter): 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, @@ -3596,6 +3606,134 @@ class DiscordAdapter(BasePlatformAdapter): 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", "false").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 @@ -4504,9 +4642,49 @@ class DiscordAdapter(BasePlatformAdapter): 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. This means channel messages 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. + # + # This only runs for shared sessions (group_sessions_per_user=False + # or shared threads) where multiple users contribute context the bot + # would otherwise miss. + # + # 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: + _is_shared = ( + (is_thread and not self.config.extra.get("thread_sessions_per_user", False)) + or (not is_thread and not self.config.extra.get("group_sessions_per_user", True)) + ) + _needed_mention = ( + require_mention + and not is_free_channel + and not in_bot_thread + ) + _backfill_enabled = self._discord_history_backfill() + if _is_shared and _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 @@ -4535,6 +4713,7 @@ class DiscordAdapter(BasePlatformAdapter): 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 diff --git a/gateway/run.py b/gateway/run.py index 77ed7260c3b..d986917ebab 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6809,6 +6809,12 @@ class GatewayRunner: 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 = [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 685de3d7341..8bd8e7fa079 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1251,6 +1251,8 @@ DEFAULT_CONFIG = { "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": False, # If True, prepend recent channel scrollback when bot is triggered in a shared channel + "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 diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index aae3c9e5880..cf197bd6f7f 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -409,6 +409,26 @@ class TestLoadGatewayConfig: "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_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 57198b9e73a..cf81961a201 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -62,6 +62,12 @@ class FakeTextChannel: 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"): @@ -99,6 +105,9 @@ def adapter(monkeypatch): "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) @@ -125,6 +134,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.""" @@ -578,3 +629,217 @@ async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypa 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_does_not_backfill(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"] = 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_not_awaited() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context is None + + diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 57a8aefa5e8..b8fd45558cd 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -5,6 +5,7 @@ import pytest 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 @@ class TestBuildSessionContextPrompt: 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/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index a4530148cbf..605e59e2e95 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -437,6 +437,33 @@ 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:** `false` + +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. 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. + +This is most useful for **shared sessions** (`group_sessions_per_user: false`) where multiple users contribute to the same conversation and the bot needs to see what happened between turns. + +```yaml +discord: + history_backfill: true +``` + +> **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` From 4abfb6bc24308653e13b24dd42ea210bf0c7dd64 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:49:01 -0700 Subject: [PATCH 141/214] feat(discord): default history backfill on, expand to per-user + threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to snav's PR #25463 contribution: flip default to on, broaden scope so backfill fires whenever require_mention gates the bot (not just shared-session channels). Why: - The mention-gate creates a session-transcript gap regardless of whether the channel is shared or per-user. In per-user sessions, Alice's session is still missing other participants' messages and her own pre-mention messages — backfill fills both gaps. - Threads naturally scope to thread-only history because discord.py's channel.history() on a thread returns only that thread's messages. - DMs still skip — every DM triggers the bot, so the session transcript is already complete. Changes: - hermes_cli/config.py: discord.history_backfill default → true - gateway/platforms/discord.py: drop the _is_shared gate, keep _is_dm skip and _needed_mention gate; env var DISCORD_HISTORY_BACKFILL default → 'true' - cli-config.yaml.example + website docs: update defaults and prose; add the DISCORD_HISTORY_BACKFILL / _LIMIT env var rows that were documented in the PR description but missing from the env-var table - tests/gateway/test_discord_free_response.py: - flip test_discord_per_user_channel_does_not_backfill → test_discord_per_user_channel_backfills_too (new behavior) - add test_discord_dm_does_not_backfill (DM skip is invariant) - give FakeThread a no-op history() so existing thread tests don't hit a fake discord.Forbidden when backfill now fires on threads too Tests: 160/160 in target files; 400/400 across all tests/gateway/ -k discord. --- cli-config.yaml.example | 2 +- gateway/platforms/discord.py | 23 +++++----- hermes_cli/config.py | 2 +- tests/gateway/test_discord_free_response.py | 47 ++++++++++++++++++-- website/docs/user-guide/messaging/discord.md | 26 +++++++++-- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index c286099a87a..3f98b8868ec 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -689,7 +689,7 @@ platform_toolsets: # 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: false # Recover missed channel messages on mention (default: false) +# history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) # ───────────────────────────────────────────────────────────────────────────── diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 652e8d4af76..a3904630fa9 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3613,7 +3613,7 @@ class DiscordAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() not in ("false", "0", "no", "off") return bool(configured) - return os.getenv("DISCORD_HISTORY_BACKFILL", "false").lower() in ("true", "1", "yes") + 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. @@ -4644,8 +4644,8 @@ class DiscordAdapter(BasePlatformAdapter): # ── History backfill ───────────────────────────────────────── # When require_mention is active, the bot only processes messages - # that @mention it. This means channel messages between bot turns - # are invisible to the session transcript. To recover that context, + # 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 @@ -4653,9 +4653,14 @@ class DiscordAdapter(BasePlatformAdapter): # cold start (no prior bot message found), fetch the last N messages # and stop at the first self-message encountered. # - # This only runs for shared sessions (group_sessions_per_user=False - # or shared threads) where multiple users contribute context the bot - # would otherwise miss. + # 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 @@ -4663,17 +4668,13 @@ class DiscordAdapter(BasePlatformAdapter): _channel_context = None _is_dm = isinstance(message.channel, discord.DMChannel) if not _is_dm: - _is_shared = ( - (is_thread and not self.config.extra.get("thread_sessions_per_user", False)) - or (not is_thread and not self.config.extra.get("group_sessions_per_user", True)) - ) _needed_mention = ( require_mention and not is_free_channel and not in_bot_thread ) _backfill_enabled = self._discord_history_backfill() - if _is_shared and _needed_mention and _backfill_enabled: + if _needed_mention and _backfill_enabled: _backfill_text = await self._fetch_channel_context( message.channel, before=message, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8bd8e7fa079..c3a8152f4a7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1251,7 +1251,7 @@ DEFAULT_CONFIG = { "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": False, # If True, prepend recent channel scrollback when bot is triggered in a shared channel + "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) diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index cf81961a201..c69af3e7781 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -87,6 +87,12 @@ class FakeThread: 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): @@ -820,7 +826,9 @@ async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeyp @pytest.mark.asyncio -async def test_discord_per_user_channel_does_not_backfill(adapter, monkeypatch): +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") @@ -837,9 +845,42 @@ async def test_discord_per_user_channel_does_not_backfill(adapter, monkeypatch): await adapter._handle_message(message) - adapter._fetch_channel_context.assert_not_awaited() + 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 is None + 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/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 605e59e2e95..50f1641f093 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -286,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. | @@ -309,6 +311,8 @@ discord: 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) @@ -439,15 +443,29 @@ Behavior: #### `discord.history_backfill` -**Type:** boolean — **Default:** `false` +**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. 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. +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. -This is most useful for **shared sessions** (`group_sessions_per_user: false`) where multiple users contribute to the same conversation and the bot needs to see what happened between turns. +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 + 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. From ed84637d11412db82c5756a7245d2ee5c1a1ada6 Mon Sep 17 00:00:00 2001 From: HxT9 <58224596+HxT9@users.noreply.github.com> Date: Thu, 14 May 2026 08:04:44 -0700 Subject: [PATCH 142/214] fix(web): make sync-assets script cross-platform The prebuild step used `rm -rf` and `cp -r`, which fail on Windows (`'rm' is not recognized`). Replace with an inline Node one-liner using fs.rmSync / fs.cpSync so the build works on Windows, macOS, and Linux without adding a dependency. --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index e1df1e13205..56262ff2a82 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 -e \"const fs=require('fs');fs.rmSync('public/fonts',{recursive:true,force:true});fs.rmSync('public/ds-assets',{recursive:true,force:true});fs.cpSync('node_modules/@nous-research/ui/dist/fonts','public/fonts',{recursive:true});fs.cpSync('node_modules/@nous-research/ui/dist/assets','public/ds-assets',{recursive:true});\"", "predev": "npm run sync-assets", "prebuild": "npm run sync-assets", "dev": "vite", From 19071529f65f026f29646c221dcf61274e9a0213 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:56:07 -0700 Subject: [PATCH 143/214] fix(lsp): shift baseline diagnostics into post-edit coordinates (#25978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing diagnostics below an edit point used to surface as 'LSP diagnostics introduced by this edit' whenever the edit deleted or inserted lines. The delta-filter key included the diagnostic's range, so the same logical error reported at a different line in the post-edit snapshot looked like a brand new diagnostic. Concrete case: deleting 14 lines in cli.py caused Pyright errors at lines 9873, 10590, 12413, 13004 (unrelated to the edit) to be reported as introduced by it. Fix: build a piecewise-linear line-shift map (via difflib's SequenceMatcher) from pre and post content, and remap baseline diagnostics into post-edit coordinates before the set-difference. Diagnostics in deleted regions drop out cleanly; diagnostics below the edit shift by the right amount; diagnostics above are untouched. The strict (range-aware) equality key stays — so a genuinely new instance of an identical error class at a different line still surfaces as new. Pieces: - agent/lsp/range_shift.py — build_line_shift, shift_diagnostic_range, shift_baseline. Pure functions, no LSP state. - agent/lsp/manager.py — LSPService.get_diagnostics_sync gains an optional line_shift kwarg; baseline is shift_baseline'd before computing the seen-set. _diag_key keeps the strict range key. - tools/file_operations.py — write_file captures pre_content for any LSP-handled extension (not just LINTERS_INPROC) and passes pre/post to _maybe_lsp_diagnostics, which builds the shift map. - New _lsp_handles_extension helper guards the pre_content read. Trade-offs preserved: - Genuinely new same-class errors at different lines still surface (content-only key would have swallowed them). - Pre-existing errors at unshifted positions still get filtered (covered by the strict-key path with no shift). - Best-effort: when pre_content can't be captured (file didn't exist, permissions), the unshifted comparison still catches most pre-existing errors; the edge case it misses is a new file with a non-empty baseline, which is structurally impossible. --- agent/lsp/manager.py | 38 ++++- agent/lsp/range_shift.py | 149 +++++++++++++++++ tests/agent/lsp/test_delta_key.py | 262 ++++++++++++++++++++++++++++++ tests/agent/lsp/test_service.py | 29 ++++ tools/file_operations.py | 92 +++++++++-- 5 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 agent/lsp/range_shift.py create mode 100644 tests/agent/lsp/test_delta_key.py diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index a0d3eb98c30..34c0b0ba92b 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -40,7 +40,7 @@ import os 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 @@ class LSPService: *, 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 @@ class LSPService: 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 @@ class LSPService: 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 @@ class LSPService: 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 00000000000..8efdfc30982 --- /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/tests/agent/lsp/test_delta_key.py b/tests/agent/lsp/test_delta_key.py new file mode 100644 index 00000000000..d20eef1ee72 --- /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 6eed8f7fd99..952a8519adc 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/tools/file_operations.py b/tools/file_operations.py index 4b64421622f..13d9314b912 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -909,19 +909,29 @@ class ShellFileOperations(FileOperations): 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 @@ class ShellFileOperations(FileOperations): # 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 @@ class ShellFileOperations(FileOperations): 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 @@ class ShellFileOperations(FileOperations): 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 @@ class ShellFileOperations(FileOperations): 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: From 0854640537ea1a33b785b142d41e71c6e726cf2a Mon Sep 17 00:00:00 2001 From: ioannis Date: Thu, 14 May 2026 15:46:54 -0700 Subject: [PATCH 144/214] fix(web): cross-platform sync-assets + surface build errors on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Windows-only bugs in the web-dashboard build path. Each is small, scoped, and verified end-to-end on Windows 11 — including under a stock cmd.exe / PowerShell console with its default cp1252 encoding. 1. `sync-assets` shells out to Unix-only commands web/package.json hard-codes `rm -rf … && cp -r …`. Neither exists on Windows cmd.exe. `hermes_cli/main.py::_build_web_ui` runs npm via subprocess (which on Windows defaults to cmd.exe), so the prebuild hook crashed before Vite ever ran and the dashboard never built. Fix: web/scripts/sync-assets.mjs — ~20 lines of Node using fs.rmSync + fs.cpSync (stdlib, Node >= 16.7). No new deps, identical behavior on POSIX and Windows. 2. Build failures were silent _build_web_ui ran both subprocess calls with capture_output=True and never relayed the captured buffers on failure. Users saw 'Web UI build failed' and nothing else — no stdout, no stderr, no hint that the real problem was 'rm is not recognized'. Fix: inner _relay() helper that decodes and prints stdout + stderr (utf-8, errors='replace') whenever a step returns non-zero. Replaces the existing stderr_tail-only relay on the build path; success path is unchanged. (stderr_tail is preserved for the stale-dist fallback branch added by #23817.) Salvaged from #13368 by @johnisag onto current main. Conflict resolution preserves main's improvements: - _run_npm_install_deterministic() (replaces bare subprocess.run for npm install) - npm-build retry-after-sleep for Windows boot-time races (#23817) - stale-dist fallback for non-interactive callers (#23817) Closes #25073, #13368. --- hermes_cli/main.py | 20 ++++++++++++++++++-- web/package.json | 2 +- web/scripts/sync-assets.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 web/scripts/sync-assets.mjs diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 214a1855b30..3c027e908c5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5688,12 +5688,29 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: print("Install Node.js, then run: cd web && npm install && npm run build") return not fatal print("→ 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: + print(text) + r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",)) if r1.returncode != 0: print( 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") return False @@ -5739,8 +5756,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: 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") return False diff --git a/web/package.json b/web/package.json index 56262ff2a82..50456076b64 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "sync-assets": "node -e \"const fs=require('fs');fs.rmSync('public/fonts',{recursive:true,force:true});fs.rmSync('public/ds-assets',{recursive:true,force:true});fs.cpSync('node_modules/@nous-research/ui/dist/fonts','public/fonts',{recursive:true});fs.cpSync('node_modules/@nous-research/ui/dist/assets','public/ds-assets',{recursive:true});\"", + "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 00000000000..19b0bafb6aa --- /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 }); +} From 38ea2a57a522860c19296531c5aa475236747d2d Mon Sep 17 00:00:00 2001 From: ioannis Date: Tue, 21 Apr 2026 07:49:15 +0100 Subject: [PATCH 145/214] fix(web): handle non-UTF8 Windows console encodings in _build_web_ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review pointed out that even with the sync-assets fix applied, _build_web_ui still crashes on a stock Windows console before reaching npm: Python stdout defaults to cp1252 (or similar) and raises UnicodeEncodeError when print() hits the arrow/check glyphs used for status messages (→, ✗, ⚠, ✓). Reproduced locally in PowerShell: $ PYTHONIOENCODING=cp1252 python -c "from hermes_cli.main import _build_web_ui; _build_web_ui(Path('web'), fatal=True)" UnicodeEncodeError: 'charmap' codec can't encode character '\u2192' ... The previous PR body claimed "end-to-end verified on Windows 11", but that was under the venv's default (utf-8) stdout. A plain `py` or PowerShell invocation would still fail before sync-assets ever ran. Fix: inner _say() helper that falls back to text.encode(sys.stdout.encoding, errors="replace") when print() raises UnicodeEncodeError. Glyphs degrade to '?' on ASCII / cp1252 consoles; utf-8 consoles are unaffected. Verified the full build pipeline runs to completion with PYTHONIOENCODING=cp1252. Scoped tightly to _build_web_ui (the function this PR already touches); other call sites in the codebase with the same risk are out of scope. --- hermes_cli/main.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3c027e908c5..e448e2b18ee 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5681,13 +5681,25 @@ 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. @@ -5702,17 +5714,17 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: continue text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() if text: - print(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( @@ -5747,20 +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)") ) _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 From db82c453b9e53643d081b047035b2f134f938377 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:48:18 -0700 Subject: [PATCH 146/214] chore(release): map agorgianitisj@hotmail.com -> johnisag --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index a681daa49de..4ffdb479ea9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -767,6 +767,7 @@ AUTHOR_MAP = { "chayton@sina.com": "ycbai", "longsizhuo@gmail.com": "longsizhuo", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", "276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123", "22549957+li0near@users.noreply.github.com": "li0near", "guoyu801@gmail.com": "li0near", @@ -865,6 +866,7 @@ AUTHOR_MAP = { "dpaluy@users.noreply.github.com": "dpaluy", "psikonetik@gmail.com": "el-analista", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", "hex-clawd@users.noreply.github.com": "hex-clawd", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", "barteq@hacknotes.local": "barteqpl", From 09d970160bb22748fc9ff3e0759d151e4ea3a907 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:53:44 -0700 Subject: [PATCH 147/214] fix(proxy): suppress false-positive windows-footgun on guarded add_signal_handler The call site at line 246 is already wrapped in try/except NotImplementedError (added in #25969). The checker just doesn't peek at surrounding context. Mark with the suppression comment so the blocking check passes. --- hermes_cli/proxy/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 223bc3bd62d..48de784afe4 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -243,7 +243,7 @@ async def run_server( loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): try: - loop.add_signal_handler(sig, stop_event.set) + 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. From d6c488f2dce96a1d1375c8e7e089b54a1e7ae6f4 Mon Sep 17 00:00:00 2001 From: Phil Thomas Date: Wed, 13 May 2026 14:51:06 -0600 Subject: [PATCH 148/214] fix(cli): wire /sessions slash command in the classic CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'sessions' command has been registered in the central command registry since #20805 (May 2025) and surfaces in /help and tab-completion, but the classic CLI's process_command() never had an elif branch for it. The canonical name fell through and printed 'Unknown command: sessions'. The TUI side was wired up correctly via the SessionPicker overlay; only the legacy CLI was missing the dispatch. Adds _handle_sessions_command() which mirrors /resume's no-arg behavior inline (the CLI has no overlay primitive equivalent to the TUI picker): - /sessions and /sessions list → print the recent-sessions table - /sessions → delegates to _handle_resume_command Includes regression tests covering the dispatcher wiring (the original bug) plus the three handler branches. --- cli.py | 34 ++++++++++++++++ tests/cli/test_cli_init.py | 83 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/cli.py b/cli.py index 75506adc655..728309733b6 100644 --- a/cli.py +++ b/cli.py @@ -5961,6 +5961,38 @@ class HermesCLI: 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. @@ -7540,6 +7572,8 @@ class HermesCLI: 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": diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index ee5ffb390d1..8417d64e746 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -319,6 +319,89 @@ class TestHistoryDisplay: 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.""" From 74e47c081fa8f26cd13fe2529fd35884fb4ad8d4 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 15:59:00 -0700 Subject: [PATCH 149/214] chore(release): map phil.thomas@gametime.co -> explainanalyze --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 4ffdb479ea9..ba6dcb648e6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -768,6 +768,7 @@ AUTHOR_MAP = { "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", @@ -867,6 +868,7 @@ AUTHOR_MAP = { "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", From 55622b5525b0fc7de8971cac80a3066bafd27e68 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 16:00:12 -0700 Subject: [PATCH 150/214] chore(release): map phil.thomas@gametime.co -> explainanalyze --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ba6dcb648e6..a67f12577fa 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -68,6 +68,7 @@ AUTHOR_MAP = { "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", From 8ed2ef6f46e9642acfba57b4b8da893a574ecfd0 Mon Sep 17 00:00:00 2001 From: Anadi Jaggia Date: Tue, 12 May 2026 21:45:33 -0700 Subject: [PATCH 151/214] fix(browser): use correct env var for --no-sandbox bypass AGENT_BROWSER_CHROME_FLAGS is not read by agent-browser CLI. The correct env var is AGENT_BROWSER_ARGS, with comma-separated values. This fixes Chrome 'No usable sandbox' crash on Ubuntu 23.10+ systems where AppArmor restricts unprivileged user namespaces. The detection logic was correct but the fix used the wrong environment variable name and space-separated instead of comma-separated args. --- tools/browser_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 79a6c7e6172..e92080e8166 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1892,8 +1892,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. From 4695d2716f60da89152bdc9dfa7d96e54ea7c22e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 16:03:41 -0700 Subject: [PATCH 152/214] fix(browser): honor pre-set AGENT_BROWSER_ARGS and document the bypass Follow-up to the sandbox-bypass env-var fix: - Update the opt-out gate so a user-provided AGENT_BROWSER_ARGS is also respected, not just the legacy AGENT_BROWSER_CHROME_FLAGS. Previously the gate only checked the broken legacy var, so a user who pre-set AGENT_BROWSER_ARGS would still get clobbered by Hermes's auto-injection. - Document AGENT_BROWSER_ARGS in .env.example, the browser feature page, and the env var reference, with notes about the auto-injection on AppArmor-restricted systems (Ubuntu 23.10+, DGX Spark, containers). - Add Anadi Jaggia to AUTHOR_MAP. --- .env.example | 7 +++++++ scripts/release.py | 1 + tools/browser_tool.py | 8 +++++++- website/docs/reference/environment-variables.md | 1 + website/docs/user-guide/features/browser.md | 7 +++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 747f7542482..80e2286caec 100644 --- a/.env.example +++ b/.env.example @@ -281,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/scripts/release.py b/scripts/release.py index a67f12577fa..d981b8b595d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -43,6 +43,7 @@ AUTHOR_MAP = { "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", diff --git a/tools/browser_tool.py b/tools/browser_tool.py index e92080e8166..575beba6c02 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 diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index eb2bc816202..4b581877849 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -135,6 +135,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/)) | diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index e27101a6472..1da4a8f2a36 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 From eabd8c1fd12d6e386d636e564444ef661ce99e81 Mon Sep 17 00:00:00 2001 From: Jeremy Irish Date: Wed, 6 May 2026 16:08:52 -0700 Subject: [PATCH 153/214] fix(cli): fall back to SelectSelector when kqueue can't watch stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS with uv-managed cPython 3.11, the default kqueue selector cannot register fd 0, so prompt_toolkit's loop.add_reader raises OSError(EINVAL) ("[Errno 22] Invalid argument") from kqueue.control() and the agent crashes immediately on startup (#5884, also reported in #6393). Probe KqueueSelector.register(0, EVENT_READ) before launching prompt_toolkit. If it fails, install an event-loop policy that returns a SelectorEventLoop backed by SelectSelector — select() works fine on stdin in this Python build, so add_reader succeeds and the agent launches normally. Also extend the existing #6393 fallback handler to recognize EINVAL / EBADF / "Invalid argument" so that any future selector failure on stdin shows the friendly "reinstall Python via pyenv or Homebrew" guidance instead of an opaque traceback. Verified on macOS (Darwin 24.6.0) with uv-managed cPython 3.11.15: the kqueue probe fails, the policy switch fires, and `hermes` launches cleanly. No effect on platforms where kqueue can register fd 0. --- cli.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index 728309733b6..af179c86c13 100644 --- a/cli.py +++ b/cli.py @@ -13401,6 +13401,30 @@ class HermesCLI: self._print_exit_summary() return + # On macOS with uv-managed Python, kqueue's selector cannot register + # fd 0, raising OSError(EINVAL) from kqueue.control() when prompt_toolkit + # calls loop.add_reader (#6393). Probe kqueue and, if it can't watch + # stdin, switch to a SelectSelector-backed event loop policy. + if sys.platform == "darwin": + try: + import selectors as _selectors + if hasattr(_selectors, "KqueueSelector"): + _kq = _selectors.KqueueSelector() + try: + _kq.register(0, _selectors.EVENT_READ) + _kq.unregister(0) + finally: + _kq.close() + except (OSError, ValueError, KeyError): + import asyncio as _aio_probe + import selectors as _selectors + + class _SelectEventLoopPolicy(_aio_probe.DefaultEventLoopPolicy): + def new_event_loop(self): + return _aio_probe.SelectorEventLoop(_selectors.SelectSelector()) + + _aio_probe.set_event_loop_policy(_SelectEventLoopPolicy()) + # Run the application with patch_stdout for proper output handling try: with patch_stdout(): @@ -13421,12 +13445,20 @@ class HermesCLI: except (KeyError, OSError) as _stdin_err: # Catch selector registration failures from broken stdin (#6393) # and I/O errors from broken stdout during interrupt (#13710). - if isinstance(_stdin_err, OSError) and getattr(_stdin_err, "errno", None) == errno.EIO: + _errno = getattr(_stdin_err, "errno", None) if isinstance(_stdin_err, OSError) else None + _msg = str(_stdin_err) + if _errno == errno.EIO: pass # suppress broken-stdout I/O errors on interrupt (#13710) - elif "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err): + elif ( + _errno in (errno.EINVAL, errno.EBADF) + or "is not registered" in _msg + or "Bad file descriptor" in _msg + or "Invalid argument" in _msg + ): print( f"\nError: stdin is not usable ({_stdin_err}).\n" - "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS)\n" + "where kqueue cannot register fd 0.\n" "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" ) else: From d3d5916089eeefe5f076b005901d1d5f9aa13eea Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 20:14:13 -0700 Subject: [PATCH 154/214] chore(release): add AUTHOR_MAP entry for outdoorsea --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index d981b8b595d..80cb65ff9ca 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", "oleksii.lisikh@gmail.com": "olisikh", + "jeremy@geocaching.com": "outdoorsea", "leone.parise@gmail.com": "leoneparise", "mr@shu.io": "mrshu", "buraysandro9@gmail.com": "ygd58", From e8b9f5ff9a19f399229856e9fd5d0823a1275927 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Thu, 14 May 2026 20:10:36 -0700 Subject: [PATCH 155/214] fix(aux): surface Nous auth-unavailable warning in auxiliary client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the auxiliary client falls through Nous (e.g. no stored auth, or runtime credential mint failed), users currently see only `debug`-level lines, so the next provider in the fallback chain takes over silently. Promote the no-auth path to a warning that tells operators to run `hermes auth`, and add a debug breadcrumb on the rarer mint-failed-but-stored-auth-still-present fallback path so the existing behavior (use the raw stored token) is preserved while staying investigable. Salvaged from #23881 by @0xharryriddle. The contributor's original patch also short-circuited the second branch with a return, which broke the pool-entry fallback path covered by `test_try_nous_uses_pool_entry` — kept the warning intent, dropped the return so the fallback still works. Dropped the contributor's changes to `hermes_cli/goals.py` because the goal-pause path is unreachable when the auxiliary client is None (`judge_goal` returns `parse_failed=False`, which resets `consecutive_parse_failures`), so the reason string they added never surfaces in the pause message. Refs #23876 --- agent/auxiliary_client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index ee0ec917f5d..96ad615bf6f 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1456,8 +1456,21 @@ 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: + logger.warning( + "Auxiliary Nous client unavailable: no Nous authentication found " + "(run: hermes auth)." + ) _mark_provider_unhealthy("nous", ttl=60) return None, None + if runtime is None and nous: + # Runtime credential mint failed but stored Nous auth is still present. + # Falls back to the raw stored token below; surface a debug line so + # operators investigating expired/invalid sessions have a breadcrumb, + # without blocking the fallback path the rest of this function relies on. + logger.debug( + "Auxiliary Nous: runtime credential mint failed; falling back to " + "stored auth.json token." + ) global auxiliary_is_nous auxiliary_is_nous = True logger.debug("Auxiliary client: Nous Portal") From 4c94396206965580e808ceb39ae1fe007511a898 Mon Sep 17 00:00:00 2001 From: mr-r0b0t Date: Thu, 14 May 2026 14:43:27 -0500 Subject: [PATCH 156/214] feat: add ACP registry metadata for Zed --- acp_adapter/auth.py | 48 ++++++++- acp_adapter/entry.py | 57 ++++++++++- acp_adapter/server.py | 33 +++---- acp_registry/agent.json | 19 ++-- acp_registry/icon.svg | 31 ++---- docs/plans/acp-registry-zed-integration.md | 97 +++++++++++++++++++ hermes_cli/main.py | 29 +++++- packages/hermes-agent-acp/README.md | 26 +++++ .../hermes-agent-acp/bin/hermes-agent-acp.js | 66 +++++++++++++ packages/hermes-agent-acp/package.json | 24 +++++ .../hermes-agent-acp/test/launcher.test.js | 23 +++++ tests/acp/test_auth.py | 48 ++++++++- tests/acp/test_entry.py | 35 ++++++- tests/acp/test_registry_manifest.py | 96 ++++++++++++++++++ tests/acp/test_server.py | 54 +++++++++++ website/docs/developer-guide/acp-internals.md | 5 +- website/docs/user-guide/features/acp.md | 67 ++++++++++--- 17 files changed, 683 insertions(+), 75 deletions(-) create mode 100644 docs/plans/acp-registry-zed-integration.md create mode 100644 packages/hermes-agent-acp/README.md create mode 100755 packages/hermes-agent-acp/bin/hermes-agent-acp.js create mode 100644 packages/hermes-agent-acp/package.json create mode 100644 packages/hermes-agent-acp/test/launcher.test.js create mode 100644 tests/acp/test_registry_manifest.py diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py index a33b5a93938..7b2556fd062 100644 --- a/acp_adapter/auth.py +++ b/acp_adapter/auth.py @@ -1,8 +1,11 @@ -"""ACP auth helpers — detect the currently configured Hermes provider.""" +"""ACP auth helpers — detect and advertise Hermes authentication methods.""" from __future__ import annotations -from typing import Optional +from typing import Any, Optional + + +TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup" def detect_provider() -> Optional[str]: @@ -22,3 +25,44 @@ def detect_provider() -> Optional[str]: def has_provider() -> bool: """Return True if Hermes can resolve any runtime provider credentials.""" return detect_provider() is not None + + +def build_auth_methods() -> list[Any]: + """Return registry-compatible ACP auth methods for Hermes. + + The official ACP registry validates that agents advertise at least one + usable auth method during the initial handshake. A fresh Zed install may + not have Hermes provider credentials configured yet, so Hermes always + advertises a terminal setup method. When credentials are already present, + it also advertises the resolved provider as the default agent-managed + runtime credential method. + """ + from acp.schema import AuthMethodAgent, TerminalAuthMethod + + methods: list[Any] = [] + provider = detect_provider() + if provider: + methods.append( + AuthMethodAgent( + id=provider, + name=f"{provider} runtime credentials", + description=( + "Authenticate Hermes using the currently configured " + f"{provider} runtime credentials." + ), + ) + ) + + methods.append( + TerminalAuthMethod( + id=TERMINAL_SETUP_AUTH_METHOD_ID, + name="Configure Hermes provider", + description=( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + type="terminal", + args=["--setup"], + ) + ) + return methods diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index cc7f835f7e0..48e677a6522 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -24,6 +24,7 @@ except ModuleNotFoundError: # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. pass +import argparse import asyncio import logging import sys @@ -107,8 +108,62 @@ def _load_env() -> None: ) -def main() -> None: +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="hermes-acp", + description="Run Hermes Agent as an ACP stdio server.", + ) + parser.add_argument("--version", action="store_true", help="Print Hermes version and exit") + parser.add_argument( + "--check", + action="store_true", + help="Verify ACP dependencies and adapter imports, then exit", + ) + parser.add_argument( + "--setup", + action="store_true", + help="Run interactive Hermes provider/model setup for ACP terminal auth", + ) + return parser.parse_args(argv) + + +def _print_version() -> None: + from hermes_cli import __version__ as hermes_version + + print(hermes_version) + + +def _run_check() -> None: + import acp # noqa: F401 + from acp_adapter.server import HermesACPAgent # noqa: F401 + + print("Hermes ACP check OK") + + +def _run_setup() -> None: + from hermes_cli.main import main as hermes_main + + old_argv = sys.argv[:] + try: + sys.argv = [old_argv[0] if old_argv else "hermes", "model"] + hermes_main() + finally: + sys.argv = old_argv + + +def main(argv: list[str] | None = None) -> None: """Entry point: load env, configure logging, run the ACP agent.""" + args = _parse_args(argv) + if args.version: + _print_version() + return + if args.check: + _run_check() + return + if args.setup: + _run_setup() + return + _setup_logging() _load_env() diff --git a/acp_adapter/server.py b/acp_adapter/server.py index c61bb80e471..20c4d7cdb4f 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -57,13 +57,7 @@ from acp.schema import ( UserMessageChunk, ) -# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0 -try: - from acp.schema import AuthMethodAgent -except ImportError: - from acp.schema import AuthMethod as AuthMethodAgent # type: ignore[attr-defined] - -from acp_adapter.auth import detect_provider +from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, detect_provider from acp_adapter.events import ( make_message_cb, make_step_cb, @@ -744,16 +738,7 @@ class HermesACPAgent(acp.Agent): resolved_protocol_version = ( protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION ) - provider = detect_provider() - auth_methods = None - if provider: - auth_methods = [ - AuthMethodAgent( - id=provider, - name=f"{provider} runtime credentials", - description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.", - ) - ] + auth_methods = build_auth_methods() client_name = client_info.name if client_info else "unknown" logger.info( @@ -784,10 +769,18 @@ class HermesACPAgent(acp.Agent): # server has provider credentials configured — harmless under # Hermes' threat model (ACP is stdio-only, local-trust), but poor # API hygiene and confusing if ACP ever grows multi-method auth. - provider = detect_provider() - if not provider: + if not isinstance(method_id, str): return None - if not isinstance(method_id, str) or method_id.strip().lower() != provider: + normalized_method = method_id.strip().lower() + provider = detect_provider() + + if normalized_method == TERMINAL_SETUP_AUTH_METHOD_ID: + # Terminal auth launches Hermes setup/model selection out-of-band. + # Only report success once that flow has produced usable runtime + # credentials for the normal ACP session. + return AuthenticateResponse() if provider else None + + if not provider or normalized_method != provider: return None return AuthenticateResponse() diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 492a84445d4..f6d9d7a574e 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,12 +1,15 @@ { - "schema_version": 1, - "name": "hermes-agent", - "display_name": "Hermes Agent", - "description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support", - "icon": "icon.svg", + "id": "hermes-agent", + "name": "Hermes Agent", + "version": "0.13.0", + "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", + "repository": "https://github.com/NousResearch/hermes-agent", + "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", + "authors": ["Nous Research"], + "license": "MIT", "distribution": { - "type": "command", - "command": "hermes", - "args": ["acp"] + "npx": { + "package": "@nousresearch/hermes-agent-acp@0.13.0" + } } } diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg index fc08ec05190..f42c0daea45 100644 --- a/acp_registry/icon.svg +++ b/acp_registry/icon.svg @@ -1,25 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/docs/plans/acp-registry-zed-integration.md b/docs/plans/acp-registry-zed-integration.md new file mode 100644 index 00000000000..05358f7afed --- /dev/null +++ b/docs/plans/acp-registry-zed-integration.md @@ -0,0 +1,97 @@ +# Hermes Agent ACP Registry + Zed Integration Implementation Plan + +> For Hermes: Use subagent-driven-development skill to implement this plan task-by-task. + +Goal: Make Hermes Agent installable from Zed's official ACP Registry, so users can add Hermes from Zed's agent panel without manual custom `agent_servers` settings. + +Architecture: Use the official `agentclientprotocol/registry` flow instead of the deprecated Zed Agent Server Extension path. Ship a registry-compatible launcher distribution, advertise valid ACP auth methods during every handshake, validate against official registry schema and auth CI, then submit a registry PR for `hermes-agent`. + +Tech Stack: Hermes Agent Python package, ACP adapter (`hermes acp` / `hermes-acp`), npm launcher package, official ACP Registry JSON schema, Zed external agent UI. + +--- + +## Compliance constraints + +- Zed v0.221.x+ prefers the ACP Registry for external agents; do not use Zed Agent Server Extensions for distribution. +- Registry repo layout is top-level `hermes-agent/agent.json` and `hermes-agent/icon.svg`, not `agents/hermes-agent/`. +- Registry metadata must use the official schema: `id`, `name`, `version`, `description`, `distribution`, optional `repository`, `website`, `authors`, `license`. +- Distribution must be exactly one supported type unless intentionally adding another: `binary`, `npx`, or `uvx`. +- Hermes must advertise at least one valid `authMethods` entry on a clean first-run handshake. No-provider/no-auth is not compliant. +- Terminal Auth must be explicit and deterministic: `id: hermes-setup`, `type: terminal`, `args: ["--setup"]`. +- `icon.svg` must be 16x16, square, monochrome, and use only `currentColor` / `none` for fill/stroke; no gradients, hardcoded colors, or `url(#...)` paints. +- ACP server mode must reserve stdout for JSON-RPC only. Diagnostics/logs go to stderr. `--version`, `--check`, and `--setup` are not server mode and may print normally. +- Published npm package must exist and be runnable before the upstream registry PR references it. + +--- + +## Tasks + +1. Verify/implement ACP auth methods. + - Always return terminal setup auth from `initialize()`. + - Return configured provider auth in addition when provider credentials are resolvable. + - Add tests for provider auth, terminal fallback auth, and authenticate behavior before/after provider setup. + +2. Add non-interactive ACP commands. + - `hermes acp --version` + - `hermes acp --check` + - `hermes acp --setup` + - Same behavior through `hermes-acp`. + +3. Build npm launcher package. + - Package: `@nousresearch/hermes-agent-acp@`. + - Command: `uvx --from 'hermes-agent[acp]==' hermes-acp ...args`. + - Fallback: `uv tool run --from ...` when only `uv` exists. + - Forward all args, including `--setup`, `--version`, and `--check`. + - Preserve stdio in server mode. + - Print actionable stderr error when `uv`/`uvx` is missing. + +4. Replace local registry metadata. + - Convert `acp_registry/agent.json` from old command-style local format to official registry schema. + - Replace `acp_registry/icon.svg` with compliant 16x16 currentColor icon. + - Add tests rejecting old fields (`schema_version`, `display_name`, `distribution.type`, `distribution.command`) and unknown distribution keys. + +5. Update docs. + - Zed docs show official ACP Registry install first: Add Agent / `zed: acp registry` -> search Hermes Agent -> install. + - Manual `agent_servers` JSON remains only as local-development fallback. + - Docs include `uv` prerequisite and `hermes acp --check` troubleshooting. + - Developer internals mention npm launcher and terminal setup auth. + +6. Validate locally. + - `python -m pytest tests/acp/test_auth.py tests/acp/test_server.py tests/acp/test_entry.py tests/acp/test_registry_manifest.py -q` + - `(cd packages/hermes-agent-acp && npm test)` + - `(cd packages/hermes-agent-acp && npm pack --dry-run)` + - `hermes acp --version` + - `hermes acp --check` + +7. Validate against official registry tooling before PR. + - In a clone/fork of `agentclientprotocol/registry`, copy files into top-level `hermes-agent/`. + - Run official dry-run build, e.g. `uv run --with jsonschema .github/workflows/build_registry.py --dry-run`. + - Run official auth check if available, e.g. `.github/workflows/scripts/run-registry-docker.sh python3 .github/workflows/verify_agents.py --auth-check`. + - Fix any schema/auth issues before submitting. + +8. Publish and submit. + - Publish `@nousresearch/hermes-agent-acp@`. + - Verify published package: + - `npx @nousresearch/hermes-agent-acp@ --version` + - `npx @nousresearch/hermes-agent-acp@ --check` + - ACP initialize/authMethods smoke test through the published package. + - Open PR to `agentclientprotocol/registry` adding `hermes-agent/agent.json` and `hermes-agent/icon.svg`. + +9. End-to-end Zed verification. + - Install Hermes Agent through Zed's ACP Registry. + - Start a Hermes thread. + - Verify workspace cwd, file tools, terminal tools, tool rendering, and approval prompts. + +--- + +## Acceptance criteria + +- Hermes appears in Zed's official ACP Registry UI. +- Install starts Hermes without custom Zed settings. +- Registry CI passes schema and auth validation. +- ACP stdout remains JSON-RPC only; all logs go to stderr. +- `authMethods` are present and valid on clean first run. +- Terminal Auth can launch Hermes provider/model setup with `--setup`. +- Zed workspace cwd is honored by Hermes file and terminal tools. +- Docs describe registry install first and manual custom config second. +- Package/release automation prevents registry entries from pointing at unpublished versions. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e448e2b18ee..6b770edaf28 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11699,16 +11699,39 @@ Examples: description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)", ) _add_accept_hooks_flag(acp_parser) + acp_parser.add_argument( + "--version", + action="store_true", + dest="acp_version", + help="Print Hermes ACP version and exit", + ) + acp_parser.add_argument( + "--check", + action="store_true", + help="Verify ACP dependencies and adapter imports, then exit", + ) + acp_parser.add_argument( + "--setup", + action="store_true", + help="Run interactive Hermes provider/model setup for ACP terminal auth", + ) def cmd_acp(args): """Launch Hermes Agent as an ACP server.""" try: from acp_adapter.entry import main as acp_main - acp_main() + acp_argv = [] + if getattr(args, "acp_version", False): + acp_argv.append("--version") + if getattr(args, "check", False): + acp_argv.append("--check") + if getattr(args, "setup", False): + acp_argv.append("--setup") + acp_main(acp_argv) except ImportError: - print("ACP dependencies not installed.") - print("Install them with: pip install -e '.[acp]'") + print("ACP dependencies not installed.", file=sys.stderr) + print("Install them with: pip install -e '.[acp]'", file=sys.stderr) sys.exit(1) acp_parser.set_defaults(func=cmd_acp) diff --git a/packages/hermes-agent-acp/README.md b/packages/hermes-agent-acp/README.md new file mode 100644 index 00000000000..b3e9eea0afa --- /dev/null +++ b/packages/hermes-agent-acp/README.md @@ -0,0 +1,26 @@ +# @nousresearch/hermes-agent-acp + +ACP launcher for Hermes Agent. + +This package is intended for clients such as Zed that install agents through the official ACP Registry. It launches the Python Hermes ACP server with: + +```bash +uvx --from 'hermes-agent[acp]==0.13.0' hermes-acp +``` + +## Requirements + +- Node.js 18+ +- `uv` or `uvx` on PATH +- Hermes provider credentials configured with `hermes model`, or through Hermes' normal `~/.hermes/.env` / `~/.hermes/config.yaml` setup + +## Commands + +```bash +npx @nousresearch/hermes-agent-acp@0.13.0 --version +npx @nousresearch/hermes-agent-acp@0.13.0 --check +npx @nousresearch/hermes-agent-acp@0.13.0 --setup +npx @nousresearch/hermes-agent-acp@0.13.0 +``` + +Normal no-argument mode reserves stdout for ACP JSON-RPC traffic. Diagnostics are emitted on stderr by Hermes. diff --git a/packages/hermes-agent-acp/bin/hermes-agent-acp.js b/packages/hermes-agent-acp/bin/hermes-agent-acp.js new file mode 100755 index 00000000000..b9d571d3550 --- /dev/null +++ b/packages/hermes-agent-acp/bin/hermes-agent-acp.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +'use strict'; + +const { spawn, spawnSync } = require('node:child_process'); + +const HERMES_AGENT_VERSION = '0.13.0'; +const HERMES_SPEC = `hermes-agent[acp]==${HERMES_AGENT_VERSION}`; + +function commandExists(command) { + const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); + return !result.error && result.status === 0; +} + +function buildCommand(argv, exists = commandExists) { + if (exists('uvx')) { + return { + command: 'uvx', + args: ['--from', HERMES_SPEC, 'hermes-acp', ...argv], + }; + } + + if (exists('uv')) { + return { + command: 'uv', + args: ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', ...argv], + }; + } + + return null; +} + +function main() { + const argv = process.argv.slice(2); + const command = buildCommand(argv); + + if (!command) { + console.error('Hermes Agent ACP requires uv or uvx to launch the Python package.'); + console.error('Install uv from https://docs.astral.sh/uv/getting-started/installation/'); + console.error('Then retry this agent from Zed.'); + process.exit(127); + } + + const child = spawn(command.command, command.args, { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', (error) => { + console.error(`Failed to start Hermes Agent ACP: ${error.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { buildCommand, HERMES_AGENT_VERSION, HERMES_SPEC }; diff --git a/packages/hermes-agent-acp/package.json b/packages/hermes-agent-acp/package.json new file mode 100644 index 00000000000..224bb275b77 --- /dev/null +++ b/packages/hermes-agent-acp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nousresearch/hermes-agent-acp", + "version": "0.13.0", + "description": "ACP launcher for Hermes Agent", + "bin": { + "hermes-agent-acp": "bin/hermes-agent-acp.js" + }, + "files": [ + "bin/", + "README.md" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/NousResearch/hermes-agent.git", + "directory": "packages/hermes-agent-acp" + }, + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/packages/hermes-agent-acp/test/launcher.test.js b/packages/hermes-agent-acp/test/launcher.test.js new file mode 100644 index 00000000000..7a338305e56 --- /dev/null +++ b/packages/hermes-agent-acp/test/launcher.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildCommand, HERMES_SPEC } = require('../bin/hermes-agent-acp.js'); + +test('uses uvx when available and forwards args', () => { + const command = buildCommand(['--version'], (name) => name === 'uvx'); + + assert.equal(command.command, 'uvx'); + assert.deepEqual(command.args, ['--from', HERMES_SPEC, 'hermes-acp', '--version']); +}); + +test('falls back to uv tool run and forwards setup args', () => { + const command = buildCommand(['--setup'], (name) => name === 'uv'); + + assert.equal(command.command, 'uv'); + assert.deepEqual(command.args, ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', '--setup']); +}); + +test('returns null when neither uvx nor uv is available', () => { + assert.equal(buildCommand([], () => false), null); +}); diff --git a/tests/acp/test_auth.py b/tests/acp/test_auth.py index ffb07463f8d..0610d3e3350 100644 --- a/tests/acp/test_auth.py +++ b/tests/acp/test_auth.py @@ -1,6 +1,11 @@ """Tests for acp_adapter.auth — provider detection.""" -from acp_adapter.auth import has_provider, detect_provider +from acp_adapter.auth import ( + TERMINAL_SETUP_AUTH_METHOD_ID, + build_auth_methods, + has_provider, + detect_provider, +) class TestHasProvider: @@ -54,3 +59,44 @@ class TestDetectProvider: monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom) assert detect_provider() is None + + def test_detect_provider_strips_and_lowercases_provider(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda: {"provider": " OpenRouter ", "api_key": " sk-or-test "}, + ) + assert detect_provider() == "openrouter" + + +class TestBuildAuthMethods: + def test_build_auth_methods_returns_provider_and_terminal_when_configured(self, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter") + + methods = build_auth_methods() + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods] + + assert payloads[0]["id"] == "openrouter" + assert payloads[0]["name"] == "openrouter runtime credentials" + assert any(payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID for payload in payloads) + terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID) + assert terminal["type"] == "terminal" + assert terminal["args"] == ["--setup"] + + def test_build_auth_methods_returns_terminal_setup_when_unconfigured(self, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None) + + methods = build_auth_methods() + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods] + + assert payloads == [ + { + "args": ["--setup"], + "description": ( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + "id": TERMINAL_SETUP_AUTH_METHOD_ID, + "name": "Configure Hermes provider", + "type": "terminal", + } + ] diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 760522c312a..4c7e55f1d4b 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -15,6 +15,39 @@ def test_main_enables_unstable_protocol(monkeypatch): monkeypatch.setattr(entry, "_load_env", lambda: None) monkeypatch.setattr(acp, "run_agent", fake_run_agent) - entry.main() + entry.main([]) assert calls["kwargs"]["use_unstable_protocol"] is True + + +def test_main_version_prints_without_starting_server(monkeypatch, capsys): + monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server"))) + + entry.main(["--version"]) + + output = capsys.readouterr().out.strip() + assert output + assert "Starting hermes-agent ACP adapter" not in output + + +def test_main_check_prints_ok_without_starting_server(monkeypatch, capsys): + monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server"))) + + entry.main(["--check"]) + + assert capsys.readouterr().out.strip() == "Hermes ACP check OK" + + +def test_main_setup_runs_model_configuration(monkeypatch): + calls = {} + + def fake_hermes_main(): + import sys + + calls["argv"] = sys.argv[:] + + monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main) + + entry.main(["--setup"]) + + assert calls["argv"][1:] == ["model"] diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py new file mode 100644 index 00000000000..134cb5415ae --- /dev/null +++ b/tests/acp/test_registry_manifest.py @@ -0,0 +1,96 @@ +"""Tests for ACP Registry metadata shipped with Hermes.""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "acp_registry" / "agent.json" +ICON = ROOT / "acp_registry" / "icon.svg" +FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"} +ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"} + + +def _manifest() -> dict: + return json.loads(MANIFEST.read_text(encoding="utf-8")) + + +def _pyproject_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return data["project"]["version"] + + +def test_agent_json_matches_official_registry_required_fields(): + data = _manifest() + + assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data) + assert data["id"] == "hermes-agent" + assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"]) + assert data["name"] == "Hermes Agent" + assert data["description"] + assert data["repository"] == "https://github.com/NousResearch/hermes-agent" + assert data["website"].startswith("https://hermes-agent.nousresearch.com/") + assert data["authors"] == ["Nous Research"] + assert data["license"] == "MIT" + assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS + + +def test_agent_json_uses_npx_distribution_without_local_command_fields(): + data = _manifest() + + assert set(data["distribution"]) == {"npx"} + assert set(data["distribution"]["npx"]) == {"package"} + assert data["distribution"]["npx"]["package"] == ( + f"@nousresearch/hermes-agent-acp@{data['version']}" + ) + assert "type" not in data["distribution"] + assert "command" not in data["distribution"] + assert "args" not in data["distribution"] + + +def test_agent_json_version_matches_pyproject(): + assert _manifest()["version"] == _pyproject_version() + + +def test_npm_launcher_versions_match_pyproject_and_manifest(): + version = _pyproject_version() + package = json.loads( + (ROOT / "packages" / "hermes-agent-acp" / "package.json").read_text(encoding="utf-8") + ) + launcher = (ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js").read_text( + encoding="utf-8" + ) + + assert package["version"] == version + assert f"const HERMES_AGENT_VERSION = '{version}';" in launcher + assert _manifest()["distribution"]["npx"]["package"] == ( + f"@nousresearch/hermes-agent-acp@{version}" + ) + + +def test_icon_svg_is_16x16_current_color(): + root = ET.fromstring(ICON.read_text(encoding="utf-8")) + + assert root.attrib["viewBox"] == "0 0 16 16" + assert root.attrib["width"] == "16" + assert root.attrib["height"] == "16" + + +def test_icon_svg_has_no_hardcoded_colors_or_gradients(): + text = ICON.read_text(encoding="utf-8") + + assert "linearGradient" not in text + assert "radialGradient" not in text + assert "url(#" not in text + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text) + + root = ET.fromstring(text) + for element in root.iter(): + for attr in ("fill", "stroke"): + value = element.attrib.get(attr) + if value is not None: + assert value in {"currentColor", "none"} diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index a4dad4aefa8..6e2039d2b24 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -33,6 +33,7 @@ from acp.schema import ( UsageUpdate, UserMessageChunk, ) +from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID from acp_adapter.server import HermesACPAgent, HERMES_VERSION from acp_adapter.session import SessionManager from hermes_state import SessionDB @@ -92,6 +93,41 @@ class TestInitialize: assert "list" in session_caps assert "resume" in session_caps + @pytest.mark.asyncio + async def test_initialize_advertises_provider_and_terminal_auth_methods(self, agent, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter") + monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: "openrouter") + + resp = await agent.initialize(protocol_version=1) + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods] + + assert payloads[0]["id"] == "openrouter" + assert payloads[0]["name"] == "openrouter runtime credentials" + terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID) + assert terminal["type"] == "terminal" + assert terminal["args"] == ["--setup"] + + @pytest.mark.asyncio + async def test_initialize_advertises_terminal_setup_auth_when_no_provider(self, agent, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None) + monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: None) + + resp = await agent.initialize(protocol_version=1) + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods] + + assert payloads == [ + { + "args": ["--setup"], + "description": ( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + "id": TERMINAL_SETUP_AUTH_METHOD_ID, + "name": "Configure Hermes provider", + "type": "terminal", + } + ] + # --------------------------------------------------------------------------- # authenticate @@ -135,6 +171,24 @@ class TestAuthenticate: resp = await agent.authenticate(method_id="openrouter") assert resp is None + @pytest.mark.asyncio + async def test_authenticate_accepts_terminal_setup_after_provider_configured(self, agent, monkeypatch): + monkeypatch.setattr( + "acp_adapter.server.detect_provider", + lambda: "openrouter", + ) + resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID) + assert isinstance(resp, AuthenticateResponse) + + @pytest.mark.asyncio + async def test_authenticate_rejects_terminal_setup_without_provider(self, agent, monkeypatch): + monkeypatch.setattr( + "acp_adapter.server.detect_provider", + lambda: None, + ) + resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID) + assert resp is None + # --------------------------------------------------------------------------- # new_session / cancel / load / resume diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md index 2ef552e266c..f688869033d 100644 --- a/website/docs/developer-guide/acp-internals.md +++ b/website/docs/developer-guide/acp-internals.md @@ -24,12 +24,15 @@ Key implementation files: ```text hermes acp / hermes-acp / python -m acp_adapter -> acp_adapter.entry.main() + -> parse --version / --check / --setup before server startup -> load ~/.hermes/.env -> configure stderr logging -> construct HermesACPAgent -> acp.run_agent(agent, use_unstable_protocol=True) ``` +The Zed ACP Registry path launches the same adapter through `npx @nousresearch/hermes-agent-acp@`, which delegates to `uvx --from 'hermes-agent[acp]==' hermes-acp`. + Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr. ## Major components @@ -146,7 +149,7 @@ Instead it reuses Hermes' runtime resolver: - `acp_adapter/auth.py` - `hermes_cli/runtime_provider.py` -So ACP advertises and uses the currently configured Hermes provider/credentials. +So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session. ## Working directory binding diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 1822f7adfad..b55664191c3 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -45,6 +45,14 @@ This installs the `agent-client-protocol` dependency and enables: - `hermes-acp` - `python -m acp_adapter` +For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses the npm launcher package `@nousresearch/hermes-agent-acp`, which runs: + +```bash +uvx --from 'hermes-agent[acp]==' hermes-acp +``` + +Make sure `uv` or `uvx` is available on `PATH` before using the registry install path. + ## Launching the ACP server Any of the following starts Hermes in ACP mode: @@ -63,6 +71,13 @@ python -m acp_adapter Hermes logs to stderr so stdout remains reserved for ACP JSON-RPC traffic. +For non-interactive checks: + +```bash +hermes acp --version +hermes acp --check +``` + ## Editor setup ### VS Code @@ -90,7 +105,19 @@ If you want to define Hermes manually, add it through VS Code settings under `ac ### Zed -Example settings snippet: +Zed v0.221.x and newer installs external agents through the official ACP Registry. + +1. Open the Agent Panel. +2. Click **Add Agent**, or run the `zed: acp registry` command. +3. Search for **Hermes Agent**. +4. Install it and start a new Hermes external-agent thread. + +Prerequisites: + +- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`. +- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==' hermes-acp`. + +For local development before the registry entry is available, use a custom agent server in Zed settings: ```json { @@ -98,9 +125,9 @@ Example settings snippet: "hermes-agent": { "type": "custom", "command": "hermes", - "args": ["acp"], - }, - }, + "args": ["acp"] + } + } } ``` @@ -114,18 +141,23 @@ Use an ACP-compatible plugin and point it at: ## Registry manifest -The ACP registry manifest lives at: +The source copy of Hermes' official ACP Registry metadata lives at: ```text acp_registry/agent.json +acp_registry/icon.svg ``` -It advertises a command-based agent whose launch command is: +The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`. + +The registry entry uses an `npx` distribution: ```text -hermes acp +npx @nousresearch/hermes-agent-acp@ ``` +The launcher then runs `hermes-acp` from the matching Python package version. + ## Configuration and credentials ACP mode uses the same Hermes configuration as the CLI: @@ -135,7 +167,7 @@ ACP mode uses the same Hermes configuration as the CLI: - `~/.hermes/skills/` - `~/.hermes/state.db` -Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. +Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup. ## Session behavior @@ -171,29 +203,36 @@ On timeout or error, the approval bridge denies the request. Check: -- the editor is pointed at the correct `acp_registry/` path -- Hermes is installed and on your PATH -- the ACP extra is installed (`pip install -e '.[acp]'`) +- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**. +- For manual/local development, verify the custom `agent_servers` command points to `hermes acp`. +- Hermes is installed and on your PATH. +- The ACP extra is installed (`pip install -e '.[acp]'`). +- `uv` or `uvx` is installed if launching from the official Zed registry entry. ### ACP starts but immediately errors Try these checks: ```bash +hermes acp --version +hermes acp --check hermes doctor hermes status -hermes acp ``` ### Missing credentials -ACP mode does not have its own login flow. It uses Hermes' existing provider setup. Configure credentials with: +ACP mode uses Hermes' existing provider setup. Configure credentials with: ```bash hermes model ``` -or by editing `~/.hermes/.env`. +or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup. + +### Zed registry launcher cannot find uv + +Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed. ## See also From d36413211449057c28aaaab52a2be5133bc59ef7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 20:15:37 -0700 Subject: [PATCH 157/214] chore(release): bump ACP Registry assets in lockstep with pyproject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP Registry manifest (acp_registry/agent.json), the npm launcher package.json, and the launcher's HERMES_AGENT_VERSION constant must all match pyproject.toml exactly — tests/acp/test_registry_manifest.py enforces this lockstep. Without a release-script hook, the next weekly version bump fails that test until someone hand-edits four files. Extend update_version_files() to drive the ACP bump alongside __init__.py and pyproject.toml, and add tests covering the lockstep and the missing-files no-op path. Also map adam.manning@gmail.com -> am423 for the salvage commit. --- scripts/release.py | 47 ++++++ tests/scripts/test_release_acp_registry.py | 159 +++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/scripts/test_release_acp_registry.py diff --git a/scripts/release.py b/scripts/release.py index 80cb65ff9ca..17a8dffd31e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -21,6 +21,7 @@ Usage: """ import argparse +import json import re import shutil import subprocess @@ -33,6 +34,13 @@ REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" +# ACP Registry assets that must stay version-locked with pyproject.toml. +# tests/acp/test_registry_manifest.py enforces this lockstep, so the release +# bump touches all four files atomically. +ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" +ACP_NPM_PACKAGE_JSON = REPO_ROOT / "packages" / "hermes-agent-acp" / "package.json" +ACP_NPM_LAUNCHER = REPO_ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js" + # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping # ────────────────────────────────────────────────────────────────────── @@ -56,6 +64,7 @@ AUTHOR_MAP = { "jeremy@geocaching.com": "outdoorsea", "leone.parise@gmail.com": "leoneparise", "mr@shu.io": "mrshu", + "adam.manning@gmail.com": "am423", "buraysandro9@gmail.com": "ygd58", "yanglongwei06@gmail.com": "Alex-yang00", "teknium@nousresearch.com": "teknium1", @@ -1153,6 +1162,44 @@ def update_version_files(semver: str, calver_date: str): ) PYPROJECT_FILE.write_text(pyproject) + # Update ACP Registry manifest + npm launcher (must stay version-locked + # with pyproject — enforced by tests/acp/test_registry_manifest.py). + _update_acp_registry_versions(semver) + + +def _update_acp_registry_versions(semver: str) -> None: + """Bump the ACP Registry manifest, npm package, and launcher in lockstep. + + Skips silently if any of the files are missing — the ACP Registry assets + landed mid-cycle and older release branches may not have them. + """ + if ACP_REGISTRY_MANIFEST.exists(): + manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8")) + manifest["version"] = semver + npx = manifest.get("distribution", {}).get("npx", {}) + if "package" in npx: + npx["package"] = f"@nousresearch/hermes-agent-acp@{semver}" + # Preserve trailing newline + 2-space indent the file already uses. + ACP_REGISTRY_MANIFEST.write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + + if ACP_NPM_PACKAGE_JSON.exists(): + package = json.loads(ACP_NPM_PACKAGE_JSON.read_text(encoding="utf-8")) + package["version"] = semver + ACP_NPM_PACKAGE_JSON.write_text( + json.dumps(package, indent=2) + "\n", encoding="utf-8" + ) + + if ACP_NPM_LAUNCHER.exists(): + launcher = ACP_NPM_LAUNCHER.read_text(encoding="utf-8") + launcher = re.sub( + r"const HERMES_AGENT_VERSION\s*=\s*'[^']+';", + f"const HERMES_AGENT_VERSION = '{semver}';", + launcher, + ) + ACP_NPM_LAUNCHER.write_text(launcher, encoding="utf-8") + def build_release_artifacts(semver: str) -> list[Path]: """Build sdist/wheel artifacts for the current release. diff --git a/tests/scripts/test_release_acp_registry.py b/tests/scripts/test_release_acp_registry.py new file mode 100644 index 00000000000..a2e71bd0b19 --- /dev/null +++ b/tests/scripts/test_release_acp_registry.py @@ -0,0 +1,159 @@ +"""Tests for the ACP Registry version-lockstep bump in scripts/release.py. + +The official ACP Registry manifest, the @nousresearch/hermes-agent-acp npm +package, and the npm launcher's HERMES_AGENT_VERSION constant must all match +``pyproject.toml`` exactly — ``tests/acp/test_registry_manifest.py`` enforces +this at lint time. The release script is the single place that bumps them in +lockstep with pyproject; if that bump ever silently breaks, weekly releases +fail the manifest test until someone hand-edits four files. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + + +def _load_release_module(monkeypatch, tmp_root: Path): + """Import scripts/release.py with REPO_ROOT pinned to a temp tree.""" + spec = importlib.util.spec_from_file_location( + "_release_under_test", + Path(__file__).resolve().parents[2] / "scripts" / "release.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Repoint every REPO_ROOT-derived path at our temp tree. + monkeypatch.setattr(module, "REPO_ROOT", tmp_root) + monkeypatch.setattr( + module, "ACP_REGISTRY_MANIFEST", tmp_root / "acp_registry" / "agent.json" + ) + monkeypatch.setattr( + module, + "ACP_NPM_PACKAGE_JSON", + tmp_root / "packages" / "hermes-agent-acp" / "package.json", + ) + monkeypatch.setattr( + module, + "ACP_NPM_LAUNCHER", + tmp_root / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js", + ) + return module + + +def _write_fixture(root: Path, version: str) -> None: + """Write the three ACP-registry files we expect release.py to bump.""" + manifest_dir = root / "acp_registry" + manifest_dir.mkdir(parents=True) + (manifest_dir / "agent.json").write_text( + json.dumps( + { + "id": "hermes-agent", + "name": "Hermes Agent", + "version": version, + "description": "test", + "distribution": { + "npx": {"package": f"@nousresearch/hermes-agent-acp@{version}"} + }, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + package_dir = root / "packages" / "hermes-agent-acp" + (package_dir / "bin").mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps( + { + "name": "@nousresearch/hermes-agent-acp", + "version": version, + "bin": {"hermes-agent-acp": "bin/hermes-agent-acp.js"}, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (package_dir / "bin" / "hermes-agent-acp.js").write_text( + f"const HERMES_AGENT_VERSION = '{version}';\n" + f"const HERMES_SPEC = `hermes-agent[acp]==${{HERMES_AGENT_VERSION}}`;\n", + encoding="utf-8", + ) + + +def test_update_acp_registry_versions_bumps_all_three_files(monkeypatch, tmp_path): + _write_fixture(tmp_path, "0.13.0") + module = _load_release_module(monkeypatch, tmp_path) + + module._update_acp_registry_versions("0.14.0") + + manifest = json.loads( + (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") + ) + assert manifest["version"] == "0.14.0" + assert ( + manifest["distribution"]["npx"]["package"] + == "@nousresearch/hermes-agent-acp@0.14.0" + ) + + package = json.loads( + ( + tmp_path / "packages" / "hermes-agent-acp" / "package.json" + ).read_text(encoding="utf-8") + ) + assert package["version"] == "0.14.0" + + launcher = ( + tmp_path / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js" + ).read_text(encoding="utf-8") + assert "const HERMES_AGENT_VERSION = '0.14.0';" in launcher + assert "0.13.0" not in launcher + + +def test_update_acp_registry_versions_is_silent_when_files_missing( + monkeypatch, tmp_path +): + """Older release branches predate the ACP Registry assets — must no-op.""" + module = _load_release_module(monkeypatch, tmp_path) + + # No fixture written; function should not raise. + module._update_acp_registry_versions("0.14.0") + + +def test_update_version_files_bumps_acp_assets_alongside_pyproject( + monkeypatch, tmp_path +): + """End-to-end: update_version_files() is the function release.py actually + calls, so it must drive the ACP bump too.""" + _write_fixture(tmp_path, "0.13.0") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "hermes-agent"\nversion = "0.13.0"\n', encoding="utf-8" + ) + version_dir = tmp_path / "hermes_cli" + version_dir.mkdir() + (version_dir / "__init__.py").write_text( + '__version__ = "0.13.0"\n__release_date__ = "2026-05-14"\n', + encoding="utf-8", + ) + + module = _load_release_module(monkeypatch, tmp_path) + monkeypatch.setattr(module, "VERSION_FILE", version_dir / "__init__.py") + monkeypatch.setattr(module, "PYPROJECT_FILE", tmp_path / "pyproject.toml") + + module.update_version_files("0.14.0", "2026-05-21") + + pyproject_text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + assert 'version = "0.14.0"' in pyproject_text + + manifest = json.loads( + (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") + ) + assert manifest["version"] == "0.14.0" + assert ( + manifest["distribution"]["npx"]["package"] + == "@nousresearch/hermes-agent-acp@0.14.0" + ) From 5af672c7530263544a9f5e2479f3853d83b3b798 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 15 May 2026 10:36:38 +0530 Subject: [PATCH 158/214] chore: remove Atropos RL environments and tinker-atropos integration (#26106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove Atropos RL environments, tools, tests, skill, and tinker-atropos submodule Delete: - environments/ (43 files — base env, agent loop, tool call parsers, benchmarks) - rl_cli.py (standalone RL training CLI) - tools/rl_training_tool.py (all 10 rl_* tools) - tests: test_rl_training_tool, test_tool_call_parsers, test_managed_server_tool_support, test_agent_loop, test_agent_loop_vllm, test_agent_loop_tool_calling, test_terminalbench2_env_security - optional-skills/mlops/hermes-atropos-environments/ - tinker-atropos git submodule + .gitmodules * chore: remove RL/Atropos references from Python source - toolsets.py: remove rl toolset block + update comment - model_tools.py: remove rl_tools group + update async bridging comment - hermes_cli/tools_config.py: remove RL display entry, _DEFAULT_OFF_TOOLSETS, setup block, and rl_training post-setup handler - tools/budget_config.py: remove RL environment reference in docstring - tests/test_model_tools.py: remove rl_tools from expected groups - tests/run_agent/test_streaming_tool_call_repair.py: fix stale cross-reference * chore: remove rl/yc-bench extras and tinker-atropos refs from pyproject.toml - Remove rl extra (atroposlib, tinker, fastapi, uvicorn, wandb) - Remove yc-bench extra - Remove rl_cli from py-modules - Remove [tool.ty.src] exclude for tinker-atropos - Remove [tool.ruff] exclude for tinker-atropos - Regenerate uv.lock * chore: remove tinker-atropos from install/setup scripts - setup-hermes.sh: remove entire tinker-atropos submodule install block - scripts/install.sh: remove both tinker-atropos blocks (Termux + standard) - scripts/install.ps1: remove tinker-atropos block - nix/hermes-agent.nix: remove tinker-atropos pip install line * chore: remove RL references from cli-config.yaml.example * docs: remove Atropos/RL references from README, CONTRIBUTING, AGENTS.md * docs: remove RL/Atropos references from website - Delete: environments.md, rl-training.md, mlops-hermes-atropos-environments.md - sidebars.ts: remove rl-training and environments sidebar entries - optional-skills-catalog.md: remove hermes-atropos-environments row - tools-reference.md: remove entire rl toolset section - toolsets-reference.md: remove rl row + update example - integrations/index.md: remove RL Training bullet - architecture.md: remove environments/ from tree + RL section - contributing.md: remove tinker-atropos setup - updating.md: remove tinker-atropos install + stale submodule update * chore: remove remaining RL/Atropos stragglers - hermes_cli/config.py: remove TINKER_API_KEY + WANDB_API_KEY env var defs - hermes_cli/doctor.py: remove Submodules check section (tinker-atropos) - hermes_cli/setup.py: remove RL Training status check - hermes_cli/status.py: remove Tinker + WandB from API key status display - agent/display.py: remove both rl_* tool preview/activity blocks - website/docs: remove RL references from providers.md + env-variables.md - tests: remove TINKER_API_KEY from conftest, set_config_value, setup_script * chore: remove RL training section from .env.example --- .env.example | 18 - .gitmodules | 3 - AGENTS.md | 1 - CONTRIBUTING.md | 4 - README.md | 4 +- README.zh-CN.md | 8 +- agent/display.py | 24 - cli-config.yaml.example | 6 +- environments/README.md | 324 ---- environments/__init__.py | 36 - environments/agent_loop.py | 534 ------ environments/agentic_opd_env.py | 1214 -------------- environments/benchmarks/__init__.py | 0 environments/benchmarks/tblite/README.md | 73 - environments/benchmarks/tblite/__init__.py | 0 environments/benchmarks/tblite/default.yaml | 39 - environments/benchmarks/tblite/local.yaml | 38 - .../benchmarks/tblite/local_vllm.yaml | 40 - environments/benchmarks/tblite/run_eval.sh | 42 - environments/benchmarks/tblite/tblite_env.py | 119 -- .../benchmarks/terminalbench_2/__init__.py | 0 .../benchmarks/terminalbench_2/default.yaml | 42 - .../benchmarks/terminalbench_2/run_eval.sh | 42 - .../terminalbench_2/terminalbench2_env.py | 1016 ----------- environments/benchmarks/yc_bench/README.md | 115 -- environments/benchmarks/yc_bench/__init__.py | 0 environments/benchmarks/yc_bench/default.yaml | 43 - environments/benchmarks/yc_bench/run_eval.sh | 34 - .../benchmarks/yc_bench/yc_bench_env.py | 848 ---------- environments/hermes_base_env.py | 714 -------- environments/hermes_swe_env/__init__.py | 0 environments/hermes_swe_env/default.yaml | 34 - environments/hermes_swe_env/hermes_swe_env.py | 229 --- environments/patches.py | 35 - environments/terminal_test_env/__init__.py | 0 environments/terminal_test_env/default.yaml | 34 - .../terminal_test_env/terminal_test_env.py | 292 ---- environments/tool_call_parsers/__init__.py | 120 -- .../tool_call_parsers/deepseek_v3_1_parser.py | 72 - .../tool_call_parsers/deepseek_v3_parser.py | 89 - .../tool_call_parsers/glm45_parser.py | 109 -- .../tool_call_parsers/glm47_parser.py | 35 - .../tool_call_parsers/hermes_parser.py | 75 - .../tool_call_parsers/kimi_k2_parser.py | 93 - .../tool_call_parsers/llama_parser.py | 96 -- .../tool_call_parsers/longcat_parser.py | 69 - .../tool_call_parsers/mistral_parser.py | 137 -- .../tool_call_parsers/qwen3_coder_parser.py | 163 -- environments/tool_call_parsers/qwen_parser.py | 19 - environments/tool_context.py | 473 ------ environments/web_research_env.py | 719 -------- hermes_cli/config.py | 19 +- hermes_cli/doctor.py | 22 - hermes_cli/setup.py | 8 - hermes_cli/status.py | 2 - hermes_cli/tools_config.py | 37 +- model_tools.py | 11 +- nix/hermes-agent.nix | 1 - .../hermes-atropos-environments/SKILL.md | 303 ---- .../references/agentresult-fields.md | 59 - .../references/atropos-base-env.md | 65 - .../references/usage-patterns.md | 199 --- pyproject.toml | 14 +- rl_cli.py | 446 ----- scripts/install.ps1 | 14 - scripts/install.sh | 12 - setup-hermes.sh | 16 - tests/conftest.py | 1 - .../test_terminalbench2_env_security.py | 164 -- tests/hermes_cli/test_set_config_value.py | 2 - tests/hermes_cli/test_setup_hermes_script.py | 1 - tests/run_agent/test_agent_loop.py | 505 ------ .../run_agent/test_agent_loop_tool_calling.py | 552 ------ tests/run_agent/test_agent_loop_vllm.py | 359 ---- .../test_streaming_tool_call_repair.py | 2 +- tests/test_model_tools.py | 2 +- .../tools/test_managed_server_tool_support.py | 178 -- tests/tools/test_rl_training_tool.py | 142 -- tests/tools/test_tool_call_parsers.py | 274 --- tinker-atropos | 1 - tools/budget_config.py | 1 - tools/rl_training_tool.py | 1396 --------------- toolsets.py | 15 +- uv.lock | 1494 +---------------- website/docs/developer-guide/architecture.md | 8 +- website/docs/developer-guide/contributing.md | 3 - website/docs/developer-guide/environments.md | 520 ------ website/docs/getting-started/updating.md | 4 +- website/docs/integrations/index.md | 1 - website/docs/integrations/providers.md | 1 - .../docs/reference/environment-variables.md | 2 - .../docs/reference/optional-skills-catalog.md | 1 - website/docs/reference/tools-reference.md | 15 - website/docs/reference/toolsets-reference.md | 3 +- .../docs/user-guide/features/rl-training.md | 234 --- .../mlops-hermes-atropos-environments.md | 323 ---- website/sidebars.ts | 2 - 97 files changed, 18 insertions(+), 15690 deletions(-) delete mode 100644 .gitmodules delete mode 100644 environments/README.md delete mode 100644 environments/__init__.py delete mode 100644 environments/agent_loop.py delete mode 100644 environments/agentic_opd_env.py delete mode 100644 environments/benchmarks/__init__.py delete mode 100644 environments/benchmarks/tblite/README.md delete mode 100644 environments/benchmarks/tblite/__init__.py delete mode 100644 environments/benchmarks/tblite/default.yaml delete mode 100644 environments/benchmarks/tblite/local.yaml delete mode 100644 environments/benchmarks/tblite/local_vllm.yaml delete mode 100755 environments/benchmarks/tblite/run_eval.sh delete mode 100644 environments/benchmarks/tblite/tblite_env.py delete mode 100644 environments/benchmarks/terminalbench_2/__init__.py delete mode 100644 environments/benchmarks/terminalbench_2/default.yaml delete mode 100755 environments/benchmarks/terminalbench_2/run_eval.sh delete mode 100644 environments/benchmarks/terminalbench_2/terminalbench2_env.py delete mode 100644 environments/benchmarks/yc_bench/README.md delete mode 100644 environments/benchmarks/yc_bench/__init__.py delete mode 100644 environments/benchmarks/yc_bench/default.yaml delete mode 100755 environments/benchmarks/yc_bench/run_eval.sh delete mode 100644 environments/benchmarks/yc_bench/yc_bench_env.py delete mode 100644 environments/hermes_base_env.py delete mode 100644 environments/hermes_swe_env/__init__.py delete mode 100644 environments/hermes_swe_env/default.yaml delete mode 100644 environments/hermes_swe_env/hermes_swe_env.py delete mode 100644 environments/patches.py delete mode 100644 environments/terminal_test_env/__init__.py delete mode 100644 environments/terminal_test_env/default.yaml delete mode 100644 environments/terminal_test_env/terminal_test_env.py delete mode 100644 environments/tool_call_parsers/__init__.py delete mode 100644 environments/tool_call_parsers/deepseek_v3_1_parser.py delete mode 100644 environments/tool_call_parsers/deepseek_v3_parser.py delete mode 100644 environments/tool_call_parsers/glm45_parser.py delete mode 100644 environments/tool_call_parsers/glm47_parser.py delete mode 100644 environments/tool_call_parsers/hermes_parser.py delete mode 100644 environments/tool_call_parsers/kimi_k2_parser.py delete mode 100644 environments/tool_call_parsers/llama_parser.py delete mode 100644 environments/tool_call_parsers/longcat_parser.py delete mode 100644 environments/tool_call_parsers/mistral_parser.py delete mode 100644 environments/tool_call_parsers/qwen3_coder_parser.py delete mode 100644 environments/tool_call_parsers/qwen_parser.py delete mode 100644 environments/tool_context.py delete mode 100644 environments/web_research_env.py delete mode 100644 optional-skills/mlops/hermes-atropos-environments/SKILL.md delete mode 100644 optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md delete mode 100644 optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md delete mode 100644 optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md delete mode 100644 rl_cli.py delete mode 100644 tests/environments/benchmarks/test_terminalbench2_env_security.py delete mode 100644 tests/run_agent/test_agent_loop.py delete mode 100644 tests/run_agent/test_agent_loop_tool_calling.py delete mode 100644 tests/run_agent/test_agent_loop_vllm.py delete mode 100644 tests/tools/test_managed_server_tool_support.py delete mode 100644 tests/tools/test_rl_training_tool.py delete mode 100644 tests/tools/test_tool_call_parsers.py delete mode 160000 tinker-atropos delete mode 100644 tools/rl_training_tool.py delete mode 100644 website/docs/developer-guide/environments.md delete mode 100644 website/docs/user-guide/features/rl-training.md delete mode 100644 website/docs/user-guide/skills/optional/mlops/mlops-hermes-atropos-environments.md diff --git a/.env.example b/.env.example index 80e2286caec..812986dca30 100644 --- a/.env.example +++ b/.env.example @@ -394,24 +394,6 @@ IMAGE_TOOLS_DEBUG=false # CONTEXT_COMPRESSION_THRESHOLD=0.85 # Compress at 85% of context limit # Model is set via compression.summary_model in config.yaml (default: google/gemini-3-flash-preview) -# ============================================================================= -# RL TRAINING (Tinker + Atropos) -# ============================================================================= -# Run reinforcement learning training on language models using the Tinker API. -# Requires the rl-server to be running (from tinker-atropos package). - -# Tinker API Key - RL training service -# Get at: https://tinker-console.thinkingmachines.ai/keys -# TINKER_API_KEY= - -# Weights & Biases API Key - Experiment tracking and metrics -# Get at: https://wandb.ai/authorize -# WANDB_API_KEY= - -# RL API Server URL (default: http://localhost:8080) -# Change if running the rl-server on a different host/port -# RL_API_URL=http://localhost:8080 - # ============================================================================= # SKILLS HUB (GitHub integration for skill search/install/publish) # ============================================================================= diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 76580d6e8e5..00000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "tinker-atropos"] - path = tinker-atropos - url = https://github.com/nousresearch/tinker-atropos diff --git a/AGENTS.md b/AGENTS.md index da9f903eefb..d5d32f99c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,6 @@ hermes-agent/ ├── tui_gateway/ # Python JSON-RPC backend for the TUI ├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration) ├── cron/ # Scheduler — jobs.py, scheduler.py -├── environments/ # RL training environments (Atropos) ├── scripts/ # run_tests.sh, release.py, auxiliary scripts ├── website/ # Docusaurus docs site └── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4bbc3c67c70..9cbc26112f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,9 +91,6 @@ export VIRTUAL_ENV="$(pwd)/venv" # Install with all extras (messaging, cron, CLI menus, dev tools) uv pip install -e ".[all,dev]" -# Optional: RL training submodule -# git submodule update --init tinker-atropos && uv pip install -e "./tinker-atropos" - # Optional: browser tools npm install ``` @@ -196,7 +193,6 @@ hermes-agent/ │ ├── skills/ # Bundled skills (copied to ~/.hermes/skills/ on install) ├── optional-skills/ # Official optional skills (discoverable via hub, not activated by default) -├── environments/ # RL training environments (Atropos integration) ├── tests/ # Test suite ├── website/ # Documentation site (hermes-agent.nousresearch.com) │ diff --git a/README.md b/README.md index 7e71632c310..efe5515f4d8 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
Scheduled automations
- +
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.
Delegates and parallelizesSpawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.
Runs anywhere, not just your laptopSeven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.
Research-readyBatch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models.
Research-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models.
--- @@ -175,8 +175,6 @@ uv pip install -e ".[all,dev]" scripts/run_tests.sh ``` -> **RL Training (optional):** The RL/Atropos integration (`environments/`) — see [`CONTRIBUTING.md`](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#development-setup) for the full setup. - --- ## Community diff --git a/README.zh-CN.md b/README.zh-CN.md index ea7fea8dcce..9a964574413 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@ 定时自动化内置 cron 调度器,支持向任何平台投递。日报、夜间备份、周审计——全部用自然语言描述,无人值守运行。 委派与并行生成隔离子代理处理并行工作流。编写 Python 脚本通过 RPC 调用工具,将多步管道压缩为零上下文开销的轮次。 随处运行六种终端后端——本地、Docker、SSH、Daytona、Singularity 和 Modal。Daytona 和 Modal 提供 Serverless 持久化——代理环境空闲时休眠、按需唤醒,空闲期间几乎零成本。$5 VPS 或 GPU 集群都能跑。 -研究就绪批量轨迹生成、Atropos RL 环境、轨迹压缩——用于训练下一代工具调用模型。 +研究就绪批量轨迹生成、轨迹压缩——用于训练下一代工具调用模型。 --- @@ -161,12 +161,6 @@ uv pip install -e ".[all,dev]" python -m pytest tests/ -q ``` -> **RL 训练(可选):** 如需参与 RL/Tinker-Atropos 集成开发: -> ```bash -> git submodule update --init tinker-atropos -> uv pip install -e "./tinker-atropos" -> ``` - --- ## 社区 diff --git a/agent/display.py b/agent/display.py index 6c5c970aeff..cdfc88f46a3 100644 --- a/agent/display.py +++ b/agent/display.py @@ -240,21 +240,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - msg = msg[:17] + "..." return f"to {target}: \"{msg}\"" - if tool_name.startswith("rl_"): - rl_previews = { - "rl_list_environments": "listing envs", - "rl_select_environment": args.get("name", ""), - "rl_get_current_config": "reading config", - "rl_edit_config": f"{args.get('field', '')}={args.get('value', '')}", - "rl_start_training": "starting", - "rl_check_status": args.get("run_id", "")[:16], - "rl_stop_training": f"stopping {args.get('run_id', '')[:16]}", - "rl_get_results": args.get("run_id", "")[:16], - "rl_list_runs": "listing runs", - "rl_test_inference": f"{args.get('num_steps', 3)} steps", - } - return rl_previews.get(tool_name) - key = primary_args.get(tool_name) if not key: for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"): @@ -981,15 +966,6 @@ def get_cute_tool_message( if action == "list": return _wrap(f"┊ ⏰ cron listing {dur}") return _wrap(f"┊ ⏰ cron {action} {args.get('job_id', '')} {dur}") - if tool_name.startswith("rl_"): - rl = { - "rl_list_environments": "list envs", "rl_select_environment": f"select {args.get('name', '')}", - "rl_get_current_config": "get config", "rl_edit_config": f"set {args.get('field', '?')}", - "rl_start_training": "start training", "rl_check_status": f"status {args.get('run_id', '?')[:12]}", - "rl_stop_training": f"stop {args.get('run_id', '?')[:12]}", "rl_get_results": f"results {args.get('run_id', '?')[:12]}", - "rl_list_runs": "list runs", "rl_test_inference": "test inference", - } - return _wrap(f"┊ 🧪 rl {rl.get(tool_name, tool_name.replace('rl_', ''))} {dur}") if tool_name == "execute_code": code = args.get("code", "") first_line = code.strip().split("\n")[0] if code.strip() else "" diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 3f98b8868ec..f5fb7156380 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -457,7 +457,7 @@ prompt_caching: # Two stores: MEMORY.md (agent's notes) and USER.md (user profile). # Character limits keep the memory small and focused. The agent manages # pruning -- when at the limit, it must consolidate or replace entries. -# Disabled by default in batch_runner and RL environments. +# Disabled by default in batch_runner. # memory: # Agent's personal notes: environment facts, conventions, things learned @@ -715,10 +715,9 @@ platform_toolsets: # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key) # cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) -# rl - rl_list_environments, rl_start_training, etc. (requires TINKER_API_KEY) # # PRESETS (curated bundles): -# hermes-cli - All of the above except rl + send_message +# hermes-cli - All of the above except send_message # hermes-telegram - terminal, file, web, vision, image_gen, tts, browser, # skills, todo, cronjob, send_message # hermes-discord - Same as hermes-telegram @@ -744,7 +743,6 @@ platform_toolsets: # session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization) # tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI, MiniMax, Mistral) # cronjob - Schedule and manage automated tasks (CLI-only) -# rl - RL training tools (Tinker-Atropos) # # Composite toolsets: # debugging - terminal + web + file (for troubleshooting) diff --git a/environments/README.md b/environments/README.md deleted file mode 100644 index 3936e1f35bc..00000000000 --- a/environments/README.md +++ /dev/null @@ -1,324 +0,0 @@ -# Hermes-Agent Atropos Environments - -This directory contains the integration layer between **hermes-agent's** tool-calling capabilities and the **Atropos** RL training framework. It provides everything needed to run agentic LLMs through multi-turn tool-calling loops, score their output with arbitrary reward functions, and feed results into Atropos for training or evaluation. - -## Architecture Overview - -``` - Atropos Framework - ┌───────────────────────┐ - │ BaseEnv │ (atroposlib) - │ - Server management │ - │ - Worker scheduling │ - │ - Wandb logging │ - │ - CLI (serve/process/ │ - │ evaluate) │ - └───────────┬───────────┘ - │ inherits - ┌───────────┴───────────┐ - │ HermesAgentBaseEnv │ hermes_base_env.py - │ - Terminal backend │ - │ - Tool resolution │ - │ - Agent loop │ - │ - ToolContext │ - │ - Async patches │ - └───────────┬───────────┘ - │ inherits - ┌─────────────────┼─────────────────┐ - │ │ │ - TerminalTestEnv HermesSweEnv TerminalBench2EvalEnv - (stack testing) (SWE training) (TB2 benchmark eval) -``` - -### Inheritance Chain - -**BaseEnv** (from `atroposlib`) is the Atropos base class. It provides: -- Server management (OpenAI-compatible API servers, VLLM, SGLang) -- Worker scheduling for parallel rollouts -- Wandb integration for metrics and rollout logging -- CLI interface with three subcommands: `serve`, `process`, `evaluate` -- `evaluate_log()` for saving eval results to JSON + samples.jsonl - -**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics: -- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, ssh, singularity, modal, daytona, vercel_sandbox) -- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`) -- Implements `collect_trajectory()` which runs the full agent loop and computes rewards -- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer) -- Applies monkey patches for async-safe tool operation at import time - -Concrete environments inherit from `HermesAgentBaseEnv` and implement: -- `setup()` -- Load dataset, initialize state -- `get_next_item()` -- Return the next item for rollout -- `format_prompt()` -- Convert a dataset item into the user message -- `compute_reward()` -- Score the rollout using ToolContext -- `evaluate()` -- Periodic evaluation logic - -## Core Components - -### Agent Loop (`agent_loop.py`) - -`HermesAgentLoop` is the reusable multi-turn agent engine. It runs the same pattern as hermes-agent's `run_agent.py`: - -1. Send messages + tools to the API via `server.chat_completion()` -2. If the response contains `tool_calls`, execute each one via `handle_function_call()` (which delegates to `tools/registry.py`'s `dispatch()`) -3. Append tool results to the conversation and go back to step 1 -4. If the response has no tool_calls, the agent is done - -Tool calls are executed in a thread pool (`run_in_executor`) so backends that use `asyncio.run()` internally (Modal, Docker) don't deadlock inside Atropos's event loop. - -Returns an `AgentResult` containing the full conversation history, turn count, reasoning content per turn, tool errors, and optional ManagedServer state (for Phase 2). - -### Tool Context (`tool_context.py`) - -`ToolContext` is a per-rollout handle that gives reward/verification functions direct access to **all** hermes-agent tools, scoped to the rollout's `task_id`. The same `task_id` means the terminal/browser session is the SAME one the model used during its rollout -- all state (files, processes, browser tabs) is preserved. - -```python -async def compute_reward(self, item, result, ctx: ToolContext): - # Run tests in the model's terminal sandbox - test = ctx.terminal("pytest -v") - if test["exit_code"] == 0: - return 1.0 - - # Check if a file was created - content = ctx.read_file("/workspace/solution.py") - if content.get("content"): - return 0.5 - - # Download files locally for verification (binary-safe) - ctx.download_file("/remote/output.bin", "/local/output.bin") - - return 0.0 -``` - -Available methods: -- **Terminal**: `terminal(command, timeout)` -- run shell commands -- **Files**: `read_file(path)`, `write_file(path, content)`, `search(query, path)` -- **Transfers**: `upload_file()`, `upload_dir()`, `download_file()`, `download_dir()` -- binary-safe file transfers between host and sandbox -- **Web**: `web_search(query)`, `web_extract(urls)` -- **Browser**: `browser_navigate(url)`, `browser_snapshot()` -- **Generic**: `call_tool(name, args)` -- call any hermes-agent tool by name -- **Cleanup**: `cleanup()` -- release all resources (called automatically after `compute_reward`) - -### Patches (`patches.py`) - -**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested. - -**Solution**: `ModalEnvironment` uses a dedicated `_AsyncWorker` background thread with its own event loop. The calling code sees a sync interface, but internally all async Modal SDK calls happen on the worker thread so they don't conflict with Atropos's loop. This is built directly into `tools/environments/modal.py` — no monkey-patching required. - -`patches.py` is now a no-op (kept for backward compatibility with imports). - -### Tool Call Parsers (`tool_call_parsers/`) - -Client-side parsers that extract structured `tool_calls` from raw model output text. Used in **Phase 2** (VLLM server type) where ManagedServer's `/generate` endpoint returns raw text without tool call parsing. - -Each parser is a standalone reimplementation of the corresponding VLLM parser's `extract_tool_calls()` logic. No VLLM dependency -- only standard library (`re`, `json`, `uuid`) and `openai` types. - -Available parsers: -- `hermes` -- Hermes/ChatML `` XML format -- `mistral` -- Mistral `[TOOL_CALLS]` format -- `llama3_json` -- Llama 3 JSON tool calling -- `qwen` -- Qwen tool calling format -- `qwen3_coder` -- Qwen3 Coder format -- `deepseek_v3` -- DeepSeek V3 format -- `deepseek_v3_1` -- DeepSeek V3.1 format -- `kimi_k2` -- Kimi K2 format -- `longcat` -- Longcat format -- `glm45` / `glm47` -- GLM model formats - -Usage: -```python -from environments.tool_call_parsers import get_parser - -parser = get_parser("hermes") -content, tool_calls = parser.parse(raw_model_output) -``` - -In Phase 1 (OpenAI server type), these parsers are not needed -- the server handles tool call parsing natively. - -## Two-Phase Operation - -### Phase 1: OpenAI Server (Evaluation / SFT Data Generation) - -Uses `server.chat_completion()` with `tools=` parameter. The server (VLLM, SGLang, OpenRouter, OpenAI) handles tool call parsing natively. Returns `ChatCompletion` objects with structured `tool_calls`. - -- Good for: evaluation, SFT data generation, testing -- Run with: `serve` (with `run-api`), `process`, or `evaluate` subcommands -- Placeholder tokens are created for the Atropos pipeline - -### Phase 2: VLLM ManagedServer (Full RL Training) - -Uses ManagedServer for exact token IDs + logprobs via `/generate`. Client-side tool call parser (from `tool_call_parsers/`) reconstructs structured `tool_calls` from raw output. - -- Good for: full RL training with GRPO/PPO -- Run with: `serve` subcommand -- Real tokens, masks, and logprobs flow through the pipeline - -## Directory Structure - -``` -environments/ -├── README.md # This file -├── __init__.py # Package exports -├── hermes_base_env.py # Abstract base (HermesAgentBaseEnv) -├── agent_loop.py # Multi-turn agent engine (HermesAgentLoop) -├── tool_context.py # Per-rollout tool access for reward functions -├── patches.py # Async-safety patches for Modal backend -│ -├── tool_call_parsers/ # Phase 2 client-side parsers -│ ├── __init__.py # Registry + base class -│ ├── hermes_parser.py -│ ├── mistral_parser.py -│ ├── llama_parser.py -│ ├── qwen_parser.py -│ ├── qwen3_coder_parser.py -│ ├── deepseek_v3_parser.py -│ ├── deepseek_v3_1_parser.py -│ ├── kimi_k2_parser.py -│ ├── longcat_parser.py -│ ├── glm45_parser.py -│ └── glm47_parser.py -│ -├── terminal_test_env/ # Stack validation environment -│ └── terminal_test_env.py -│ -├── hermes_swe_env/ # SWE-bench style training environment -│ └── hermes_swe_env.py -│ -└── benchmarks/ # Evaluation benchmarks - ├── terminalbench_2/ # 89 terminal tasks, Modal sandboxes - │ └── terminalbench2_env.py - ├── tblite/ # 100 calibrated tasks (fast TB2 proxy) - │ └── tblite_env.py - └── yc_bench/ # Long-horizon strategic benchmark - └── yc_bench_env.py -``` - -## Concrete Environments - -### TerminalTestEnv (`terminal_test_env/`) - -A self-contained environment with inline tasks (no external dataset needed) for validating the full stack end-to-end. Each task asks the model to create a file at a known path, and the verifier checks the content matches. - -```bash -# Serve mode (needs run-api) -run-api -python environments/terminal_test_env/terminal_test_env.py serve - -# Process mode (no run-api, saves to JSONL) -python environments/terminal_test_env/terminal_test_env.py process \ - --env.data_path_to_save_groups terminal_test_output.jsonl -``` - -### HermesSweEnv (`hermes_swe_env/`) - -SWE-bench style training environment. The model gets a coding task, uses terminal + file + web tools to solve it, and the reward function runs tests in the same Modal sandbox. - -```bash -python environments/hermes_swe_env/hermes_swe_env.py serve \ - --openai.model_name YourModel \ - --env.dataset_name bigcode/humanevalpack \ - --env.terminal_backend modal -``` - -### TerminalBench2EvalEnv (`benchmarks/terminalbench_2/`) - -**Eval-only** environment for the Terminal-Bench 2.0 benchmark (89 tasks). Each task gets a pre-built Docker Hub image, a natural language instruction, and a test suite. The agent uses terminal + file tools to solve the task, then the test suite verifies correctness. - -Follows the standard Atropos eval pattern (like GPQA, MMLU, etc.): -- Run via `evaluate` subcommand (no `run-api` needed) -- `setup()` loads the dataset, `evaluate()` runs all tasks -- `rollout_and_score_eval()` handles per-task agent loop + test verification -- Downloads verifier output locally for reliable reward checking (Harbor pattern) - -```bash -# Run full benchmark -python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ - --openai.model_name anthropic/claude-opus-4.6 - -# Run subset of tasks -python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ - --openai.model_name anthropic/claude-opus-4.6 \ - --env.task_filter fix-git,git-multibranch - -# Skip specific tasks -python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ - --openai.model_name anthropic/claude-opus-4.6 \ - --env.skip_tasks heavy-task,slow-task -``` - -## Creating a New Environment - -### Training Environment - -1. Create a new directory under `environments/` -2. Create your env file inheriting from `HermesAgentBaseEnv` -3. Implement the four abstract methods + `evaluate()` - -```python -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig - -class MyEnvConfig(HermesAgentEnvConfig): - pass # Add custom fields as needed - -class MyEnv(HermesAgentBaseEnv): - name = "my-env" - env_config_cls = MyEnvConfig - - @classmethod - def config_init(cls): - env_config = MyEnvConfig( - enabled_toolsets=["terminal", "file"], - terminal_backend="modal", - # ... other config - ) - server_configs = [APIServerConfig(...)] - return env_config, server_configs - - async def setup(self): - self.dataset = load_dataset(...) - self.iter = 0 - - async def get_next_item(self): - item = self.dataset[self.iter % len(self.dataset)] - self.iter += 1 - return item - - def format_prompt(self, item): - return item["instruction"] - - async def compute_reward(self, item, result, ctx): - # ctx gives you full tool access to the rollout's sandbox - test = ctx.terminal("pytest -v") - return 1.0 if test["exit_code"] == 0 else 0.0 - - async def evaluate(self, *args, **kwargs): - # Periodic evaluation logic - ... - -if __name__ == "__main__": - MyEnv.cli() -``` - -### Eval-Only Environment (Benchmark) - -For eval benchmarks, follow the pattern in `terminalbench2_env.py`: -1. Create under `environments/benchmarks/your-benchmark/` -2. Inherit from `HermesAgentBaseEnv` -3. Set eval-only config: `eval_handling=STOP_TRAIN`, `steps_per_eval=1`, `total_steps=1` -4. Stub the training methods (`collect_trajectories`, `score`) -5. Implement `rollout_and_score_eval()` and `evaluate()` -6. Run with `evaluate` subcommand - -## Key Config Fields - -| Field | Description | Default | -|-------|-------------|---------| -| `enabled_toolsets` | Which hermes toolsets to enable | `None` (all) | -| `disabled_toolsets` | Toolsets to disable | `None` | -| `distribution` | Probabilistic toolset distribution name | `None` | -| `max_agent_turns` | Max LLM calls per rollout | `30` | -| `agent_temperature` | Sampling temperature | `1.0` | -| `terminal_backend` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity` | `local` | -| `system_prompt` | System message for the agent | `None` | -| `tool_call_parser` | Parser name for Phase 2 | `hermes` | -| `eval_handling` | `STOP_TRAIN`, `LIMIT_TRAIN`, `NONE` | `STOP_TRAIN` | diff --git a/environments/__init__.py b/environments/__init__.py deleted file mode 100644 index 282bc06b0b3..00000000000 --- a/environments/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Hermes-Agent Atropos Environments - -Provides a layered integration between hermes-agent's tool-calling capabilities -and the Atropos RL training framework. - -Core layers: - - agent_loop: Reusable multi-turn agent loop with standard OpenAI-spec tool calling - - tool_context: Per-rollout tool access handle for reward/verification functions - - hermes_base_env: Abstract base environment (BaseEnv subclass) for Atropos - - tool_call_parsers: Client-side tool call parser registry for Phase 2 (VLLM /generate) - -Concrete environments: - - terminal_test_env/: Simple file-creation tasks for testing the stack - - hermes_swe_env/: SWE-bench style tasks with Modal sandboxes - -Benchmarks (eval-only): - - benchmarks/terminalbench_2/: Terminal-Bench 2.0 evaluation -""" - -try: - from environments.agent_loop import AgentResult, HermesAgentLoop - from environments.tool_context import ToolContext - from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -except ImportError: - # atroposlib not installed — environments are unavailable but - # submodules like tool_call_parsers can still be imported directly. - pass - -__all__ = [ - "AgentResult", - "HermesAgentLoop", - "ToolContext", - "HermesAgentBaseEnv", - "HermesAgentEnvConfig", -] diff --git a/environments/agent_loop.py b/environments/agent_loop.py deleted file mode 100644 index 7ca3a0f6ddb..00000000000 --- a/environments/agent_loop.py +++ /dev/null @@ -1,534 +0,0 @@ -""" -HermesAgentLoop -- Reusable Multi-Turn Agent Engine - -Runs the hermes-agent tool-calling loop using standard OpenAI-spec tool calling. -Works with any server that returns ChatCompletion objects with tool_calls: - - Phase 1: OpenAI server type (VLLM, SGLang, OpenRouter, OpenAI API) - - Phase 2: ManagedServer with client-side tool call parser - -The loop passes tools= and checks response.choices[0].message.tool_calls, -identical to hermes-agent's run_agent.py. Tool execution is dispatched via -handle_function_call() from model_tools.py. -""" - -import asyncio -import concurrent.futures -import json -import logging -import os -import uuid -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Set - -from model_tools import handle_function_call -from tools.terminal_tool import get_active_env -from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget - -# Thread pool for running sync tool calls that internally use asyncio.run() -# (e.g., the Modal/Docker/Daytona terminal backends). Running them in a separate -# thread gives them a clean event loop so they don't deadlock inside Atropos's loop. -# Size must be large enough for concurrent eval tasks (e.g., 89 TB2 tasks all -# making tool calls). Too small = thread pool starvation, tasks queue for minutes. -# Resized at runtime by HermesAgentBaseEnv.__init__ via resize_tool_pool(). -_tool_executor = concurrent.futures.ThreadPoolExecutor(max_workers=128) - - -def resize_tool_pool(max_workers: int): - """ - Replace the global tool executor with a new one of the given size. - - Called by HermesAgentBaseEnv.__init__ based on config.tool_pool_size. - Safe to call before any tasks are submitted. - """ - global _tool_executor - old_executor = _tool_executor - _tool_executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - old_executor.shutdown(wait=False) - logger.info("Tool thread pool resized to %d workers", max_workers) - -logger = logging.getLogger(__name__) - - -@dataclass -class ToolError: - """Record of a tool execution error during the agent loop.""" - - turn: int # Which turn the error occurred on - tool_name: str # Which tool was called - arguments: str # The arguments passed (truncated) - error: str # The error message - tool_result: str # The raw result returned to the model - - -@dataclass -class AgentResult: - """Result of running the agent loop.""" - - # Full conversation history in OpenAI message format - messages: List[Dict[str, Any]] - # ManagedServer.get_state() if available (Phase 2), None otherwise - managed_state: Optional[Dict[str, Any]] = None - # How many LLM calls were made - turns_used: int = 0 - # True if model stopped calling tools naturally (vs hitting max_turns) - finished_naturally: bool = False - # Extracted reasoning content per turn (from PR #297 helpers) - reasoning_per_turn: List[Optional[str]] = field(default_factory=list) - # Tool errors encountered during the loop - tool_errors: List[ToolError] = field(default_factory=list) - - -def _extract_reasoning_from_message(message) -> Optional[str]: - """ - Extract reasoning content from a ChatCompletion message. - - Handles multiple provider formats: - 1. message.reasoning_content field (some providers) - 2. message.reasoning field (some providers) - 3. message.reasoning_details[].text (OpenRouter style) - - Note: block extraction from content is NOT done here -- that's - handled by the response already in Phase 1 (server does it) or by - ManagedServer's patch in Phase 2. - - Args: - message: The assistant message from ChatCompletion response - - Returns: - Extracted reasoning text, or None if not found - """ - # Check reasoning_content field (common across providers) - if hasattr(message, "reasoning_content") and message.reasoning_content: - return message.reasoning_content - - # Check reasoning field - if hasattr(message, "reasoning") and message.reasoning: - return message.reasoning - - # Check reasoning_details (OpenRouter style) - if hasattr(message, "reasoning_details") and message.reasoning_details: - for detail in message.reasoning_details: - if hasattr(detail, "text") and detail.text: - return detail.text - if isinstance(detail, dict) and detail.get("text"): - return detail["text"] - - return None - - -class HermesAgentLoop: - """ - Runs hermes-agent's tool-calling loop using standard OpenAI-spec tool calling. - - Same pattern as run_agent.py: - - Pass tools= to the API - - Check response.choices[0].message.tool_calls - - Dispatch via handle_function_call() - - Works identically with any server type -- OpenAI, VLLM, SGLang, OpenRouter, - or ManagedServer with a parser. The server determines how tool_calls get - populated on the response. - """ - - def __init__( - self, - server, - tool_schemas: List[Dict[str, Any]], - valid_tool_names: Set[str], - max_turns: int = 30, - task_id: Optional[str] = None, - temperature: float = 1.0, - max_tokens: Optional[int] = None, - extra_body: Optional[Dict[str, Any]] = None, - budget_config: Optional["BudgetConfig"] = None, - ): - """ - Initialize the agent loop. - - Args: - server: Server object with chat_completion() method (OpenAIServer, - ManagedServer, ServerManager, etc.) - tool_schemas: OpenAI-format tool definitions from get_tool_definitions() - valid_tool_names: Set of tool names the model is allowed to call - max_turns: Maximum number of LLM calls before stopping - task_id: Unique ID for terminal/browser session isolation - temperature: Sampling temperature for generation - max_tokens: Max tokens per generation (None for server default) - extra_body: Extra parameters passed to the OpenAI client's create() call. - Used for OpenRouter provider preferences, transforms, etc. - e.g. {"provider": {"ignore": ["DeepInfra"]}} - budget_config: Tool result persistence budget. Controls per-tool - thresholds, per-turn aggregate budget, and preview size. - If None, uses DEFAULT_BUDGET (current hardcoded values). - """ - from tools.budget_config import DEFAULT_BUDGET - self.server = server - self.tool_schemas = tool_schemas - self.valid_tool_names = valid_tool_names - self.max_turns = max_turns - self.task_id = task_id or str(uuid.uuid4()) - self.temperature = temperature - self.max_tokens = max_tokens - self.extra_body = extra_body - self.budget_config = budget_config or DEFAULT_BUDGET - - async def run(self, messages: List[Dict[str, Any]]) -> AgentResult: - """ - Execute the full agent loop using standard OpenAI tool calling. - - Args: - messages: Initial conversation messages (system + user). - Modified in-place as the conversation progresses. - - Returns: - AgentResult with full conversation history, managed state, and metadata - """ - reasoning_per_turn = [] - tool_errors: List[ToolError] = [] - - # Per-loop TodoStore for the todo tool (ephemeral, dies with the loop) - from tools.todo_tool import TodoStore, todo_tool as _todo_tool - _todo_store = TodoStore() - - # Extract user task from first user message for browser_snapshot context - _user_task = None - for msg in messages: - if msg.get("role") == "user": - content = msg.get("content", "") - if isinstance(content, str) and content.strip(): - _user_task = content.strip()[:500] # Cap to avoid huge strings - break - - import time as _time - - for turn in range(self.max_turns): - turn_start = _time.monotonic() - - # Build the chat_completion kwargs - chat_kwargs = { - "messages": messages, - "n": 1, - "temperature": self.temperature, - } - - # Only pass tools if we have them - if self.tool_schemas: - chat_kwargs["tools"] = self.tool_schemas - - # Only pass max_tokens if explicitly set - if self.max_tokens is not None: - chat_kwargs["max_tokens"] = self.max_tokens - - # Inject extra_body for provider-specific params (e.g., OpenRouter - # provider preferences like banned/preferred providers, transforms) - if self.extra_body: - chat_kwargs["extra_body"] = self.extra_body - - # Make the API call -- standard OpenAI spec - api_start = _time.monotonic() - try: - response = await self.server.chat_completion(**chat_kwargs) - except Exception as e: - api_elapsed = _time.monotonic() - api_start - logger.error("API call failed on turn %d (%.1fs): %s", turn + 1, api_elapsed, e) - return AgentResult( - messages=messages, - managed_state=self._get_managed_state(), - turns_used=turn + 1, - finished_naturally=False, - reasoning_per_turn=reasoning_per_turn, - tool_errors=tool_errors, - ) - - api_elapsed = _time.monotonic() - api_start - - if not response or not response.choices: - logger.warning("Empty response on turn %d (api=%.1fs)", turn + 1, api_elapsed) - return AgentResult( - messages=messages, - managed_state=self._get_managed_state(), - turns_used=turn + 1, - finished_naturally=False, - reasoning_per_turn=reasoning_per_turn, - tool_errors=tool_errors, - ) - - assistant_msg = response.choices[0].message - - # Extract reasoning content from the response (all provider formats) - reasoning = _extract_reasoning_from_message(assistant_msg) - reasoning_per_turn.append(reasoning) - - # Check for tool calls -- standard OpenAI spec. - # Fallback: if response has no structured tool_calls but content - # contains raw tool call tags (e.g. ), parse them using - # hermes-agent's standalone parsers. This handles the case where - # ManagedServer's ToolCallTranslator couldn't parse because vLLM - # isn't installed. - if ( - not assistant_msg.tool_calls - and assistant_msg.content - and self.tool_schemas - and "" in (assistant_msg.content or "") - ): - try: - from environments.tool_call_parsers import get_parser - fallback_parser = get_parser("hermes") - parsed_content, parsed_calls = fallback_parser.parse( - assistant_msg.content - ) - if parsed_calls: - assistant_msg.tool_calls = parsed_calls - if parsed_content is not None: - assistant_msg.content = parsed_content - logger.debug( - "Fallback parser extracted %d tool calls from raw content", - len(parsed_calls), - ) - except Exception: - pass # Fall through to no tool calls - - if assistant_msg.tool_calls: - # Normalize tool calls to dicts — they may come as objects - # (OpenAI API) or dicts (vLLM ToolCallTranslator). - def _tc_to_dict(tc): - if isinstance(tc, dict): - return { - "id": tc.get("id", f"call_{uuid.uuid4().hex[:8]}"), - "type": "function", - "function": { - "name": tc.get("function", {}).get("name", tc.get("name", "")), - "arguments": tc.get("function", {}).get("arguments", tc.get("arguments", "{}")), - }, - } - return { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments, - }, - } - - # Build the assistant message dict for conversation history - msg_dict: Dict[str, Any] = { - "role": "assistant", - "content": assistant_msg.content or "", - "tool_calls": [_tc_to_dict(tc) for tc in assistant_msg.tool_calls], - } - - # Preserve reasoning_content for multi-turn chat template handling - # (e.g., Kimi-K2's template renders blocks differently - # for history vs. the latest turn based on this field) - if reasoning: - msg_dict["reasoning_content"] = reasoning - - messages.append(msg_dict) - - # Execute each tool call via hermes-agent's dispatch - for tc in assistant_msg.tool_calls: - # Handle both object (OpenAI) and dict (vLLM) formats - if isinstance(tc, dict): - tool_name = tc.get("function", {}).get("name", tc.get("name", "")) - tool_args_raw = tc.get("function", {}).get("arguments", tc.get("arguments", "{}")) - else: - tool_name = tc.function.name - tool_args_raw = tc.function.arguments - - # Validate tool name - if tool_name not in self.valid_tool_names: - tool_result = json.dumps( - { - "error": f"Unknown tool '{tool_name}'. " - f"Available tools: {sorted(self.valid_tool_names)}" - } - ) - tool_errors.append(ToolError( - turn=turn + 1, tool_name=tool_name, - arguments=tool_args_raw[:200], - error=f"Unknown tool '{tool_name}'", - tool_result=tool_result, - )) - logger.warning( - "Model called unknown tool '%s' on turn %d", - tool_name, turn + 1, - ) - else: - # Parse arguments - try: - args = json.loads(tool_args_raw) - except json.JSONDecodeError as e: - args = None - tool_result = json.dumps( - {"error": f"Invalid JSON in tool arguments: {e}. Please retry with valid JSON."} - ) - tool_errors.append(ToolError( - turn=turn + 1, tool_name=tool_name, - arguments=tool_args_raw[:200], - error=f"Invalid JSON: {e}", - tool_result=tool_result, - )) - logger.warning( - "Invalid JSON in tool call arguments for '%s': %s", - tool_name, tool_args_raw[:200], - ) - - # Dispatch tool only if arguments parsed successfully - if args is not None: - try: - if tool_name == "terminal": - backend = os.getenv("TERMINAL_ENV", "local") - cmd_preview = args.get("command", "")[:80] - logger.info( - "[%s] $ %s", self.task_id[:8], cmd_preview, - ) - - tool_submit_time = _time.monotonic() - - # Todo tool -- handle locally (needs per-loop TodoStore) - if tool_name == "todo": - tool_result = _todo_tool( - todos=args.get("todos"), - merge=args.get("merge", False), - store=_todo_store, - ) - tool_elapsed = _time.monotonic() - tool_submit_time - elif tool_name == "memory": - tool_result = json.dumps({"error": "Memory is not available in RL environments."}) - tool_elapsed = _time.monotonic() - tool_submit_time - elif tool_name == "session_search": - tool_result = json.dumps({"error": "Session search is not available in RL environments."}) - tool_elapsed = _time.monotonic() - tool_submit_time - else: - # Run tool calls in a thread pool so backends that - # use asyncio.run() internally (modal, docker, daytona) get - # a clean event loop instead of deadlocking. - loop = asyncio.get_running_loop() - # Capture current tool_name/args for the lambda - _tn, _ta, _tid = tool_name, args, self.task_id - tool_result = await loop.run_in_executor( - _tool_executor, - lambda: handle_function_call( - _tn, _ta, task_id=_tid, - user_task=_user_task, - ), - ) - tool_elapsed = _time.monotonic() - tool_submit_time - - # Log slow tools and thread pool stats for debugging - pool_active = _tool_executor._work_queue.qsize() - if tool_elapsed > 30: - logger.warning( - "[%s] turn %d: %s took %.1fs (pool queue=%d)", - self.task_id[:8], turn + 1, tool_name, - tool_elapsed, pool_active, - ) - except Exception as e: - tool_result = json.dumps( - {"error": f"Tool execution failed: {type(e).__name__}: {str(e)}"} - ) - tool_errors.append(ToolError( - turn=turn + 1, tool_name=tool_name, - arguments=tool_args_raw[:200], - error=f"{type(e).__name__}: {str(e)}", - tool_result=tool_result, - )) - logger.error( - "Tool '%s' execution failed on turn %d: %s", - tool_name, turn + 1, e, - ) - - # Also check if the tool returned an error in its JSON result - try: - result_data = json.loads(tool_result) - if isinstance(result_data, dict): - err = result_data.get("error") - exit_code = result_data.get("exit_code") - if err and exit_code and exit_code < 0: - tool_errors.append(ToolError( - turn=turn + 1, tool_name=tool_name, - arguments=tool_args_raw[:200], - error=str(err), - tool_result=tool_result[:500], - )) - except (json.JSONDecodeError, TypeError): - pass - - tc_id = tc.get("id", "") if isinstance(tc, dict) else tc.id - tool_result = maybe_persist_tool_result( - content=tool_result, - tool_name=tool_name, - tool_use_id=tc_id, - env=get_active_env(self.task_id), - config=self.budget_config, - ) - - messages.append( - { - "role": "tool", - "tool_call_id": tc_id, - "content": tool_result, - } - ) - - num_tcs = len(assistant_msg.tool_calls) - if num_tcs > 0: - enforce_turn_budget( - messages[-num_tcs:], - env=get_active_env(self.task_id), - config=self.budget_config, - ) - - turn_elapsed = _time.monotonic() - turn_start - logger.info( - "[%s] turn %d: api=%.1fs, %d tools, turn_total=%.1fs", - self.task_id[:8], turn + 1, api_elapsed, - len(assistant_msg.tool_calls), turn_elapsed, - ) - - else: - # No tool calls -- model is done - msg_dict = { - "role": "assistant", - "content": assistant_msg.content or "", - } - if reasoning: - msg_dict["reasoning_content"] = reasoning - messages.append(msg_dict) - - turn_elapsed = _time.monotonic() - turn_start - logger.info( - "[%s] turn %d: api=%.1fs, no tools (finished), turn_total=%.1fs", - self.task_id[:8], turn + 1, api_elapsed, turn_elapsed, - ) - - return AgentResult( - messages=messages, - managed_state=self._get_managed_state(), - turns_used=turn + 1, - finished_naturally=True, - reasoning_per_turn=reasoning_per_turn, - tool_errors=tool_errors, - ) - - # Hit max turns without the model stopping - logger.info("Agent hit max_turns (%d) without finishing", self.max_turns) - return AgentResult( - messages=messages, - managed_state=self._get_managed_state(), - turns_used=self.max_turns, - finished_naturally=False, - reasoning_per_turn=reasoning_per_turn, - tool_errors=tool_errors, - ) - - def _get_managed_state(self) -> Optional[Dict[str, Any]]: - """ - Get ManagedServer state if the server supports it. - - Returns state dict with SequenceNodes containing tokens/logprobs/masks, - or None if the server doesn't support get_state() (e.g., regular OpenAI server). - """ - if hasattr(self.server, "get_state"): - return self.server.get_state() - return None diff --git a/environments/agentic_opd_env.py b/environments/agentic_opd_env.py deleted file mode 100644 index c6ed88756bf..00000000000 --- a/environments/agentic_opd_env.py +++ /dev/null @@ -1,1214 +0,0 @@ -""" -AgenticOPDEnv — On-Policy Distillation for Agentic Tool-Calling Tasks -===================================================================== - -First Atropos environment to populate the distill_token_ids / distill_logprobs -fields on ScoredDataGroup, enabling on-policy distillation (OPD) training. - -Key idea (from OpenClaw-RL, Princeton 2026): - Every time an agent receives a next-state signal (tool result, error trace, - test verdict), that signal contains hindsight information about how the - agent's PREVIOUS response could have been better. This environment: - - 1. Runs standard agentic rollouts (tool-calling agent loop) - 2. Walks the conversation to find (assistant_turn, next_state) pairs - 3. Uses an LLM judge to extract "hints" from next-state signals - 4. Builds an enhanced prompt (original context + hint) - 5. Scores the student's response tokens under the enhanced distribution - using VLLM's prompt_logprobs (via Atropos's get_logprobs API) - 6. Packages the teacher's top-K predictions as distill_token_ids / - distill_logprobs on the ScoredDataGroup - -The trainer then computes per-token advantages: - A_t = teacher_logprob(token_t) - student_logprob(token_t) - Positive → teacher approves this token (upweight) - Negative → teacher disapproves (downweight) - -This gives dense, token-level training signal from every tool interaction, -instead of just a scalar reward at the end of the trajectory. - -Task: Coding tasks with test verification (rich next-state signals from -test results, error messages, terminal output). Falls back to built-in -coding problems if no HuggingFace dataset is configured. - -Requirements: - - VLLM backend (server_type: vllm) — needed for prompt logprob scoring - - Phase 2 mode (ManagedServer) — needed for token-level tracking - -Usage: - # Process mode (offline data generation with OPD) - python environments/agentic_opd_env.py process \\ - --env.total_steps 10 --env.group_size 2 \\ - --env.data_path_to_save_groups output.jsonl \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name Qwen/Qwen3-4B - - # Serve mode (connected to Atropos trainer) - python environments/agentic_opd_env.py serve \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name Qwen/Qwen3-4B - - # Evaluate mode - python environments/agentic_opd_env.py evaluate \\ - --env.eval_size 10 \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name Qwen/Qwen3-4B - -Reference: Wang et al., "OpenClaw-RL: Train Any Agent Simply by Talking" - arXiv:2603.10165, March 2026 -""" - -from __future__ import annotations - -import asyncio -import copy -import json -import logging -import os -import random -import re -import sys -import time -import uuid -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple, Union - -from pydantic import Field - -# Ensure hermes-agent root is on path -_repo_root = Path(__file__).resolve().parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from atroposlib.envs.base import ScoredDataGroup, ScoredDataItem -from atroposlib.envs.server_handling.server_manager import APIServerConfig -from atroposlib.type_definitions import Item - -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from environments.agent_loop import AgentResult, HermesAgentLoop -from environments.tool_context import ToolContext - -logger = logging.getLogger(__name__) - - -# ═══════════════════════════════════════════════════════════════════════ -# Built-in coding tasks (fallback when no HF dataset is configured) -# ═══════════════════════════════════════════════════════════════════════ - -BUILTIN_CODING_TASKS = [ - { - "task": "Write a Python function `fizzbuzz(n)` that returns a list of strings from 1 to n. " - "For multiples of 3 return 'Fizz', for multiples of 5 return 'Buzz', " - "for multiples of both return 'FizzBuzz', otherwise the number as a string.", - "test_code": ( - "from solution import fizzbuzz\n" - "assert fizzbuzz(15) == ['1','2','Fizz','4','Buzz','Fizz','7','8','Fizz','Buzz','11','Fizz','13','14','FizzBuzz']\n" - "assert fizzbuzz(1) == ['1']\n" - "assert fizzbuzz(0) == []\n" - "print('All tests passed!')\n" - ), - "difficulty": "easy", - }, - { - "task": "Write a Python function `is_palindrome(s)` that checks if a string is a palindrome, " - "ignoring case and non-alphanumeric characters. Return True or False.", - "test_code": ( - "from solution import is_palindrome\n" - "assert is_palindrome('A man, a plan, a canal: Panama') == True\n" - "assert is_palindrome('race a car') == False\n" - "assert is_palindrome('') == True\n" - "assert is_palindrome('Was it a car or a cat I saw?') == True\n" - "print('All tests passed!')\n" - ), - "difficulty": "easy", - }, - { - "task": "Write a Python function `two_sum(nums, target)` that returns the indices of the two " - "numbers in `nums` that add up to `target`. Assume exactly one solution exists. " - "Return a list of two indices [i, j] where i < j.", - "test_code": ( - "from solution import two_sum\n" - "assert two_sum([2, 7, 11, 15], 9) == [0, 1]\n" - "assert two_sum([3, 2, 4], 6) == [1, 2]\n" - "assert two_sum([3, 3], 6) == [0, 1]\n" - "print('All tests passed!')\n" - ), - "difficulty": "easy", - }, - { - "task": "Write a Python function `flatten(lst)` that takes an arbitrarily nested list and " - "returns a flat list of all elements. For example, flatten([1, [2, [3, 4], 5]]) " - "should return [1, 2, 3, 4, 5].", - "test_code": ( - "from solution import flatten\n" - "assert flatten([1, [2, [3, 4], 5]]) == [1, 2, 3, 4, 5]\n" - "assert flatten([]) == []\n" - "assert flatten([1, 2, 3]) == [1, 2, 3]\n" - "assert flatten([[[[1]]]]) == [1]\n" - "assert flatten([1, [2], [[3]], [[[4]]]]) == [1, 2, 3, 4]\n" - "print('All tests passed!')\n" - ), - "difficulty": "medium", - }, - { - "task": "Write a Python function `longest_common_prefix(strs)` that finds the longest " - "common prefix string amongst a list of strings. If there is no common prefix, " - "return an empty string.", - "test_code": ( - "from solution import longest_common_prefix\n" - "assert longest_common_prefix(['flower', 'flow', 'flight']) == 'fl'\n" - "assert longest_common_prefix(['dog', 'racecar', 'car']) == ''\n" - "assert longest_common_prefix(['interspecies', 'interstellar', 'interstate']) == 'inters'\n" - "assert longest_common_prefix(['a']) == 'a'\n" - "assert longest_common_prefix([]) == ''\n" - "print('All tests passed!')\n" - ), - "difficulty": "easy", - }, - { - "task": "Write a Python function `group_anagrams(strs)` that groups anagrams together. " - "Return a list of lists, where each inner list contains strings that are anagrams of " - "each other. The order of groups and strings within groups does not matter.", - "test_code": ( - "from solution import group_anagrams\n" - "result = group_anagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat'])\n" - "result_sorted = sorted([sorted(g) for g in result])\n" - "assert result_sorted == [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]\n" - "assert group_anagrams([]) == []\n" - "assert group_anagrams(['a']) == [['a']]\n" - "print('All tests passed!')\n" - ), - "difficulty": "medium", - }, - { - "task": "Write a Python function `valid_parentheses(s)` that determines if a string " - "containing just '(', ')', '{', '}', '[' and ']' is valid. A string is valid if " - "open brackets are closed by the same type and in the correct order.", - "test_code": ( - "from solution import valid_parentheses\n" - "assert valid_parentheses('()') == True\n" - "assert valid_parentheses('()[]{}') == True\n" - "assert valid_parentheses('(]') == False\n" - "assert valid_parentheses('([)]') == False\n" - "assert valid_parentheses('{[]}') == True\n" - "assert valid_parentheses('') == True\n" - "print('All tests passed!')\n" - ), - "difficulty": "easy", - }, - { - "task": "Write a Python function `merge_intervals(intervals)` that merges overlapping " - "intervals. Each interval is a list [start, end]. Return the merged intervals sorted " - "by start time.", - "test_code": ( - "from solution import merge_intervals\n" - "assert merge_intervals([[1,3],[2,6],[8,10],[15,18]]) == [[1,6],[8,10],[15,18]]\n" - "assert merge_intervals([[1,4],[4,5]]) == [[1,5]]\n" - "assert merge_intervals([[1,4],[0,4]]) == [[0,4]]\n" - "assert merge_intervals([]) == []\n" - "assert merge_intervals([[1,2]]) == [[1,2]]\n" - "print('All tests passed!')\n" - ), - "difficulty": "medium", - }, -] - - -# ═══════════════════════════════════════════════════════════════════════ -# Hint extraction prompts (adapted from OpenClaw-RL) -# ═══════════════════════════════════════════════════════════════════════ - -_HINT_JUDGE_SYSTEM = ( - "You are a process reward model used for hindsight hint extraction.\n" - "You are given:\n" - "1) The assistant response at turn t.\n" - "2) The next state at turn t+1, along with its **role**.\n\n" - "## Understanding the next state's role\n" - "- role='user': A reply from the user (follow-up, correction, new request, etc.).\n" - "- role='tool': The return value of a tool the assistant invoked. " - "This content was NOT available before the assistant's action — " - "it exists BECAUSE the assistant called the tool. " - "A successful, non-error tool output generally means the assistant's " - "action was appropriate; do NOT treat it as information the assistant " - "should have already known.\n\n" - "Your goal is to decide whether the next state reveals useful hindsight information\n" - "that could have helped improve the assistant response at turn t.\n\n" - "Output format rules (strict):\n" - "- You MUST include exactly one final decision token: \\boxed{1} or \\boxed{-1}.\n" - "- If and only if decision is \\boxed{1}, provide a concise, information-dense hint in 1-3 sentences,\n" - " wrapped between [HINT_START] and [HINT_END].\n" - "- If decision is \\boxed{-1}, do not provide a hint block.\n" - "- Hint must be concrete and actionable for improving the previous response." -) - -_BOXED_RE = re.compile(r"\\boxed\{(-?\d+)\}") -_HINT_RE = re.compile(r"\[HINT_START\](.*?)\[HINT_END\]", re.DOTALL) - - -def _build_hint_judge_messages( - response_text: str, next_state_text: str, next_state_role: str = "tool" -) -> list[dict]: - """Build messages for the hint extraction judge.""" - user = ( - f"## Assistant response (turn t)\n{response_text}\n\n" - f"## Next state (turn t+1) [role: {next_state_role}]\n{next_state_text}\n\n" - "Now output your decision and (if positive) the hint in the required format." - ) - return [ - {"role": "system", "content": _HINT_JUDGE_SYSTEM}, - {"role": "user", "content": user}, - ] - - -def _parse_hint_result(text: str) -> tuple[int | None, str]: - """Parse the judge's boxed decision and hint text.""" - boxed = _BOXED_RE.findall(text) - score = int(boxed[-1]) if boxed else None - if score not in {1, -1}: - score = None - hint_matches = _HINT_RE.findall(text) - hint = hint_matches[-1].strip() if hint_matches else "" - return score, hint - - -def _select_best_hint(votes: list[dict]) -> dict | None: - """Select the best hint from majority-voted judge results.""" - good = [ - v - for v in votes - if v.get("score") == 1 - and isinstance(v.get("hint"), str) - and len(v["hint"].strip()) > 10 - ] - if not good: - return None - return max(good, key=lambda v: len(v["hint"].strip())) - - -def _append_hint_to_messages(messages: list[dict], hint: str) -> list[dict]: - """Clone messages and append hint to the last user message.""" - cloned = copy.deepcopy(messages) - if not cloned: - return [{"role": "user", "content": f"[user's hint / instruction]\n{hint}"}] - - # Find last user message - target_idx = None - for i in range(len(cloned) - 1, -1, -1): - if cloned[i].get("role") == "user": - target_idx = i - break - if target_idx is None: - target_idx = len(cloned) - 1 - - content = cloned[target_idx].get("content", "") - if isinstance(content, list): - content = " ".join( - c.get("text", "") if isinstance(c, dict) else str(c) for c in content - ) - suffix = f"\n\n[user's hint / instruction]\n{hint.strip()}" - cloned[target_idx]["content"] = (content + suffix).strip() - return cloned - - -# ═══════════════════════════════════════════════════════════════════════ -# Configuration -# ═══════════════════════════════════════════════════════════════════════ - - -class AgenticOPDConfig(HermesAgentEnvConfig): - """Configuration for the agentic OPD environment.""" - - # --- OPD settings --- - opd_enabled: bool = Field( - default=True, - description="Enable on-policy distillation pipeline. When disabled, " - "the environment behaves like a standard agentic env (no distill fields).", - ) - distill_topk: int = Field( - default=50, - description="Number of top-K teacher logprobs per position for distillation.", - ) - prm_votes: int = Field( - default=3, - description="Number of independent judge queries for majority-voted hint extraction.", - ) - hint_max_next_state_chars: int = Field( - default=4000, - description="Maximum characters of next-state text to include in the hint judge prompt. " - "Tool results can be very long — truncating prevents judge context overflow.", - ) - - # --- Reward settings --- - correctness_weight: float = Field( - default=0.7, - description="Weight for test pass/fail in reward.", - ) - efficiency_weight: float = Field( - default=0.15, - description="Weight for efficiency (fewer turns = better).", - ) - tool_usage_weight: float = Field( - default=0.15, - description="Weight for appropriate tool usage signal.", - ) - - # --- Dataset --- - dataset_name: Optional[str] = Field( - default=None, - description="HuggingFace dataset with coding tasks. " - "Expected fields: 'task' (problem description) and 'test_code' (pytest/assert tests). " - "Falls back to built-in tasks if not set or unavailable.", - ) - - # --- Eval --- - eval_size: int = Field( - default=10, - description="Number of held-out items for evaluation.", - ) - eval_split_ratio: float = Field( - default=0.15, - description="Fraction of dataset to hold out for evaluation.", - ) - - -# ═══════════════════════════════════════════════════════════════════════ -# Environment -# ═══════════════════════════════════════════════════════════════════════ - - -class AgenticOPDEnv(HermesAgentBaseEnv): - """ - RL environment with on-policy distillation from next-state signals. - - Runs coding tasks where the agent writes code and runs tests. - Tool results (test pass/fail, error traces) serve as next-state signals - for hint extraction and teacher logprob scoring. - - This is the first Atropos environment to populate distill_token_ids - and distill_logprobs on ScoredDataGroup for OPD training. - """ - - name = "agentic-opd" - env_config_cls = AgenticOPDConfig - - # Default toolsets: terminal for running code, file for writing it - default_toolsets = ["terminal", "file"] - - @classmethod - def config_init(cls) -> Tuple[AgenticOPDConfig, List[APIServerConfig]]: - """Default configuration.""" - env_config = AgenticOPDConfig( - # Toolsets - enabled_toolsets=["terminal", "file"], - # Agent loop - max_agent_turns=15, - agent_temperature=1.0, - system_prompt=( - "You are a skilled Python programmer. When given a coding task:\n" - "1. Write the solution to a file called 'solution.py'\n" - "2. Write the test code to a file called 'test_solution.py'\n" - "3. Run the tests with: python test_solution.py\n" - "4. If tests fail, read the error output carefully, fix your code, and re-run\n" - "5. Once all tests pass, report success\n\n" - "Be efficient — write clean code and fix errors methodically." - ), - # OPD - opd_enabled=True, - distill_topk=50, - prm_votes=3, - # Training - group_size=4, - total_steps=500, - steps_per_eval=50, - use_wandb=True, - wandb_name="agentic-opd", - ) - - server_configs = [ - APIServerConfig( - base_url="http://localhost:8000/v1", - model_name="Qwen/Qwen3-4B", - server_type="vllm", - ) - ] - - return env_config, server_configs - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._items: list[dict] = [] - self._eval_items: list[dict] = [] - self._index: int = 0 - - # Metric buffers - self._reward_buffer: list[float] = [] - self._correctness_buffer: list[float] = [] - self._efficiency_buffer: list[float] = [] - self._tool_usage_buffer: list[float] = [] - self._hints_extracted_buffer: list[int] = [] - self._opd_turns_scored_buffer: list[int] = [] - - # ═══════════════════════════════════════════════════════════════════ - # 1. setup — load dataset - # ═══════════════════════════════════════════════════════════════════ - - async def setup(self) -> None: - """Load coding tasks from HuggingFace or use built-in set.""" - if self.config.dataset_name: - try: - from datasets import load_dataset - - logger.info( - "Loading dataset '%s'...", self.config.dataset_name - ) - ds = load_dataset( - self.config.dataset_name, split=self.config.dataset_split - ) - task_field = self.config.prompt_field - self._items = [ - { - "task": row.get(task_field, row.get("task", "")), - "test_code": row.get("test_code", row.get("tests", "")), - "difficulty": row.get("difficulty", "unknown"), - } - for row in ds - if row.get(task_field, row.get("task", "")) - ] - if self._items: - random.shuffle(self._items) - eval_size = max( - self.config.eval_size, - int(len(self._items) * self.config.eval_split_ratio), - ) - self._eval_items = self._items[:eval_size] - self._items = self._items[eval_size:] - logger.info( - "Loaded %d train / %d eval items from '%s'", - len(self._items), - len(self._eval_items), - self.config.dataset_name, - ) - return - except Exception as e: - logger.warning( - "Could not load dataset '%s': %s. Using built-in tasks.", - self.config.dataset_name, - e, - ) - - # Fallback to built-in tasks - items = copy.deepcopy(BUILTIN_CODING_TASKS) - random.shuffle(items) - split = max(1, len(items) * 85 // 100) - self._items = items[:split] - self._eval_items = items[split:] - logger.info( - "Using built-in coding tasks: %d train / %d eval items", - len(self._items), - len(self._eval_items), - ) - - # ═══════════════════════════════════════════════════════════════════ - # 2. get_next_item - # ═══════════════════════════════════════════════════════════════════ - - async def get_next_item(self) -> dict: - """Return the next coding task, cycling through the dataset.""" - if not self._items: - raise RuntimeError("Dataset is empty. Did you call setup()?") - item = self._items[self._index % len(self._items)] - self._index += 1 - return item - - # ═══════════════════════════════════════════════════════════════════ - # 3. format_prompt - # ═══════════════════════════════════════════════════════════════════ - - def format_prompt(self, item: dict) -> str: - """Format the coding task as a user prompt.""" - prompt = ( - f"Solve the following coding task.\n\n" - f"## Task\n{item['task']}\n\n" - ) - if item.get("test_code"): - prompt += ( - f"## Tests\nThe following test code will be used to verify your solution:\n" - f"```python\n{item['test_code']}```\n\n" - ) - prompt += ( - "## Instructions\n" - "1. Write your solution to `solution.py`\n" - "2. Write the test code to `test_solution.py`\n" - "3. Run `python test_solution.py` to verify\n" - "4. Fix any failures and re-run until all tests pass\n" - ) - return prompt - - # ═══════════════════════════════════════════════════════════════════ - # 4. compute_reward - # ═══════════════════════════════════════════════════════════════════ - - async def compute_reward( - self, - item: dict, - result: AgentResult, - ctx: ToolContext, - ) -> float: - """ - Multi-signal reward: - - correctness (0.7): Did the tests pass? - - efficiency (0.15): Fewer turns = better - - tool_usage (0.15): Did the agent actually write + run code? - """ - cfg = self.config - - # ---- Signal 1: Test correctness ---- - # Check if test_solution.py exists and passes in the agent's sandbox - correctness = 0.0 - try: - test_result = ctx.terminal("python test_solution.py 2>&1", timeout=30) - output = test_result.get("output", "") - exit_code = test_result.get("exit_code", 1) - if exit_code == 0 and "passed" in output.lower(): - correctness = 1.0 - elif exit_code == 0: - correctness = 0.8 # Ran without error but no explicit "passed" - elif "assert" in output.lower() and "error" in output.lower(): - correctness = 0.2 # Partial — code runs but assertions fail - else: - correctness = 0.1 # Code errors out entirely - except Exception as e: - logger.debug("Test execution failed in reward: %s", e) - correctness = 0.0 - - # ---- Signal 2: Efficiency ---- - max_turns = cfg.max_agent_turns - turns_used = result.turns_used - if turns_used <= 3: - efficiency = 1.0 - elif turns_used <= max_turns // 2: - efficiency = 0.8 - elif turns_used <= max_turns * 3 // 4: - efficiency = 0.5 - else: - efficiency = 0.2 - - # ---- Signal 3: Tool usage ---- - tools_used = set() - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - name = fn.get("name", "") - if name: - tools_used.add(name) - - # Good: used both terminal and file tools - if "terminal" in tools_used and ("write_file" in tools_used or "patch" in tools_used): - tool_usage = 1.0 - elif "terminal" in tools_used: - tool_usage = 0.6 - elif tools_used: - tool_usage = 0.3 - else: - tool_usage = 0.0 - - # ---- Combine ---- - reward = ( - cfg.correctness_weight * correctness - + cfg.efficiency_weight * efficiency - + cfg.tool_usage_weight * tool_usage - ) - reward = min(1.0, max(0.0, reward)) - - # Track metrics - self._reward_buffer.append(reward) - self._correctness_buffer.append(correctness) - self._efficiency_buffer.append(efficiency) - self._tool_usage_buffer.append(tool_usage) - - logger.debug( - "Reward: correctness=%.2f, efficiency=%.2f, tool_usage=%.2f → %.3f", - correctness, - efficiency, - tool_usage, - reward, - ) - return reward - - # ═══════════════════════════════════════════════════════════════════ - # 5. collect_trajectories — OPD pipeline - # ═══════════════════════════════════════════════════════════════════ - - async def collect_trajectories( - self, item: Item - ) -> Tuple[ - Union[Optional[ScoredDataGroup], List[Optional[ScoredDataGroup]]], - List[Item], - ]: - """ - Override collect_trajectories to add the OPD pipeline. - - 1. Run standard rollouts via super() → ScoredDataGroup with tokens/masks/scores - 2. For each rollout, extract hints from next-state signals - 3. Score student tokens under enhanced (hint-augmented) distribution - 4. Add distill_token_ids / distill_logprobs to the ScoredDataGroup - """ - # Step 1: Run standard rollouts - scored_group, backlog = await super().collect_trajectories(item) - - # Step 2: OPD pipeline (only if enabled and we have VLLM server) - if ( - self.config.opd_enabled - and scored_group is not None - and isinstance(scored_group, dict) - and self._use_managed_server() - ): - await self._apply_opd_pipeline(scored_group) - - return scored_group, backlog - - async def _apply_opd_pipeline(self, group: ScoredDataGroup) -> None: - """ - Apply on-policy distillation to each rollout in the group. - - For each rollout's messages: - 1. Find (assistant, next_state) turn pairs - 2. Extract hints via LLM judge with majority voting - 3. Build enhanced prompt (original + hint) - 4. Score student tokens under enhanced distribution via get_logprobs - 5. Add distill_token_ids / distill_logprobs to the group - """ - messages_list = group.get("messages", []) - tokens_list = group.get("tokens", []) - - if not messages_list or not tokens_list: - logger.debug("OPD: No messages or tokens to process") - return - - all_distill_token_ids: List[Optional[List[List[int]]]] = [] - all_distill_logprobs: List[Optional[List[List[float]]]] = [] - - for seq_idx, (messages, student_tokens) in enumerate( - zip(messages_list, tokens_list) - ): - try: - distill_ids, distill_lps = await self._opd_for_sequence( - messages, student_tokens - ) - all_distill_token_ids.append(distill_ids) - all_distill_logprobs.append(distill_lps) - except Exception as e: - logger.warning( - "OPD failed for sequence %d: %s", seq_idx, e - ) - all_distill_token_ids.append(None) - all_distill_logprobs.append(None) - - # Only set distill fields if at least one sequence succeeded - any_succeeded = any(d is not None for d in all_distill_token_ids) - if any_succeeded: - # Replace None entries with zero-padded arrays matching token length - for i in range(len(all_distill_token_ids)): - if all_distill_token_ids[i] is None and i < len(tokens_list): - seq_len = len(tokens_list[i]) - k = self.config.distill_topk - all_distill_token_ids[i] = [[0] * k] * seq_len - all_distill_logprobs[i] = [[0.0] * k] * seq_len - - group["distill_token_ids"] = all_distill_token_ids - group["distill_logprobs"] = all_distill_logprobs - logger.info( - "OPD: Set distill fields on %d/%d sequences", - sum(1 for d in all_distill_token_ids if d is not None), - len(all_distill_token_ids), - ) - - async def _opd_for_sequence( - self, messages: List[Dict], student_tokens: List[int] - ) -> Tuple[List[List[int]], List[List[float]]]: - """ - Run OPD for a single rollout sequence. - - 1. Walk conversation to find (assistant, next_state) pairs - 2. Extract hints from next-state signals - 3. For each hint-augmented turn, score student tokens via get_logprobs - 4. Merge per-turn teacher logprobs into a full-sequence distill array - - Returns: - (distill_token_ids, distill_logprobs) each of shape [seq_len][top_k] - """ - k = self.config.distill_topk - seq_len = len(student_tokens) - - # Initialize with zeros (no distill info = neutral) - distill_token_ids: List[List[int]] = [[0] * k for _ in range(seq_len)] - distill_logprobs: List[List[float]] = [[0.0] * k for _ in range(seq_len)] - - # Find (assistant, next_state) turn pairs - turn_pairs = self._extract_turn_pairs(messages) - if not turn_pairs: - return distill_token_ids, distill_logprobs - - hints_extracted = 0 - turns_scored = 0 - - for pair in turn_pairs: - try: - hint = await self._extract_hint( - pair["assistant_text"], - pair["next_state_text"], - pair["next_state_role"], - ) - if not hint: - continue - - hints_extracted += 1 - - # Build enhanced prompt with hint - enhanced_messages = _append_hint_to_messages( - pair["context_messages"], hint - ) - - # Tokenize the enhanced prompt - if not self.tokenizer: - logger.warning("OPD: No tokenizer available, skipping scoring") - continue - - enhanced_prompt = self.tokenizer.apply_chat_template( - enhanced_messages, - tokenize=False, - add_generation_prompt=True, - ) - - # Tokenize the assistant response to score - response_text = pair["assistant_text"] - enhanced_full_text = enhanced_prompt + response_text - enhanced_ids = self.tokenizer( - enhanced_full_text, add_special_tokens=False - )["input_ids"] - - response_ids = self.tokenizer( - response_text, add_special_tokens=False - )["input_ids"] - response_len = len(response_ids) - - if response_len == 0: - continue - - # Score via get_logprobs — teacher scoring the student's tokens - # under the enhanced (hint-augmented) distribution - try: - logprob_result = await self.server.get_logprobs( - input_ids=enhanced_ids, - top_k=k, - split="eval", # Use eval semaphore to not block training - ) - except Exception as e: - logger.debug("get_logprobs failed: %s", e) - continue - - teacher_topk_ids = logprob_result.get("prompt_topk_token_ids", []) - teacher_topk_lps = logprob_result.get("prompt_topk_logprobs", []) - - if not teacher_topk_ids: - continue - - # Extract only the response positions (last response_len entries) - if len(teacher_topk_ids) >= response_len: - resp_topk_ids = teacher_topk_ids[-response_len:] - resp_topk_lps = teacher_topk_lps[-response_len:] - else: - # Pad from the left if the response was shorter than expected - pad_len = response_len - len(teacher_topk_ids) - resp_topk_ids = [[0] * k] * pad_len + teacher_topk_ids - resp_topk_lps = [[0.0] * k] * pad_len + teacher_topk_lps - - # Map these back to the student's full sequence positions - # Find where this assistant turn's tokens appear in the full sequence - turn_start = self._find_token_span( - student_tokens, response_ids - ) - if turn_start is not None: - for j in range(min(response_len, seq_len - turn_start)): - pos = turn_start + j - if pos < seq_len and j < len(resp_topk_ids): - # Pad/truncate to exactly k entries - ids = resp_topk_ids[j][:k] - lps = resp_topk_lps[j][:k] - while len(ids) < k: - ids.append(0) - lps.append(0.0) - distill_token_ids[pos] = ids - distill_logprobs[pos] = lps - turns_scored += 1 - - except Exception as e: - logger.debug("OPD turn processing failed: %s", e) - continue - - # Track OPD metrics - self._hints_extracted_buffer.append(hints_extracted) - self._opd_turns_scored_buffer.append(turns_scored) - - logger.debug( - "OPD sequence: %d turn pairs, %d hints extracted, %d turns scored", - len(turn_pairs), - hints_extracted, - turns_scored, - ) - return distill_token_ids, distill_logprobs - - def _extract_turn_pairs( - self, messages: List[Dict] - ) -> List[Dict[str, Any]]: - """ - Walk conversation messages to find (assistant, next_state) pairs. - - A "turn pair" is an assistant message with content (the response) - followed by one or more tool results or a user reply (the next state). - - Returns list of dicts: - { - "context_messages": messages up to (not including) the assistant turn, - "assistant_text": the assistant's response text, - "next_state_text": the next state content (tool result or user reply), - "next_state_role": "tool" or "user", - } - """ - pairs = [] - i = 0 - while i < len(messages): - msg = messages[i] - if msg.get("role") == "assistant" and msg.get("content"): - # Found an assistant message with content - assistant_text = msg["content"] - context = messages[:i] # Everything before this turn - - # Look ahead for next state - j = i + 1 - # Skip tool_calls-only assistant messages and collect tool results - next_states = [] - while j < len(messages): - next_msg = messages[j] - if next_msg.get("role") == "tool": - next_states.append(next_msg) - j += 1 - elif next_msg.get("role") == "user": - next_states.append(next_msg) - break - else: - break - - if next_states: - # Combine all next-state content - next_text_parts = [] - next_role = next_states[0].get("role", "tool") - for ns in next_states: - content = ns.get("content", "") - if content: - # Truncate very long tool outputs - max_chars = self.config.hint_max_next_state_chars - if len(content) > max_chars: - content = content[:max_chars] + "\n...[truncated]" - next_text_parts.append(content) - - next_text = "\n---\n".join(next_text_parts) - if next_text.strip(): - pairs.append( - { - "context_messages": context, - "assistant_text": assistant_text, - "next_state_text": next_text, - "next_state_role": next_role, - } - ) - i += 1 - return pairs - - async def _extract_hint( - self, - assistant_text: str, - next_state_text: str, - next_state_role: str, - ) -> Optional[str]: - """ - Extract a hindsight hint from a next-state signal using majority-voted LLM judge. - - Returns the hint string if the judge votes positively, None otherwise. - """ - judge_messages = _build_hint_judge_messages( - response_text=assistant_text, - next_state_text=next_state_text, - next_state_role=next_state_role, - ) - - # Majority voting across multiple judge queries - votes = [] - tasks = [] - for _ in range(self.config.prm_votes): - tasks.append( - self.server.chat_completion( - messages=judge_messages, - n=1, - max_tokens=500, - temperature=0.7, - split="eval", - ) - ) - - results = await asyncio.gather(*tasks, return_exceptions=True) - - for result in results: - if isinstance(result, Exception): - logger.debug("Hint judge call failed: %s", result) - votes.append({"score": None, "hint": ""}) - continue - try: - text = result.choices[0].message.content or "" - score, hint = _parse_hint_result(text) - votes.append({"score": score, "hint": hint}) - except Exception as e: - logger.debug("Hint parse failed: %s", e) - votes.append({"score": None, "hint": ""}) - - selected = _select_best_hint(votes) - if selected is None: - return None - return selected["hint"] - - @staticmethod - def _find_token_span( - full_tokens: List[int], sub_tokens: List[int] - ) -> Optional[int]: - """ - Find where sub_tokens appears in full_tokens. - Returns the start index, or None if not found. - - Uses a sliding window search. For long sequences, searches - from the end since assistant responses are typically at the end. - """ - if not sub_tokens or not full_tokens: - return None - sub_len = len(sub_tokens) - full_len = len(full_tokens) - if sub_len > full_len: - return None - - # Search backwards (assistant responses are usually near the end) - for i in range(full_len - sub_len, -1, -1): - if full_tokens[i : i + sub_len] == sub_tokens: - return i - return None - - # ═══════════════════════════════════════════════════════════════════ - # 6. evaluate - # ═══════════════════════════════════════════════════════════════════ - - async def evaluate(self, *args, **kwargs) -> None: - """ - Evaluate on held-out coding tasks using the full agent loop. - No OPD during eval — just standard agentic evaluation. - """ - if not self._eval_items: - logger.warning("No eval items available.") - return - - eval_size = min(self.config.eval_size, len(self._eval_items)) - eval_items = self._eval_items[:eval_size] - - logger.info("Running eval on %d coding tasks...", len(eval_items)) - start_time = time.time() - samples = [] - - tools, valid_names = self._resolve_tools_for_group() - - for i, item in enumerate(eval_items): - task_id = str(uuid.uuid4()) - logger.info( - "Eval [%d/%d]: %s...", i + 1, len(eval_items), item["task"][:60] - ) - - try: - messages: List[Dict[str, Any]] = [] - if self.config.system_prompt: - messages.append( - {"role": "system", "content": self.config.system_prompt} - ) - messages.append( - {"role": "user", "content": self.format_prompt(item)} - ) - - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=0.0, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - - # Compute reward (track buffer lengths to rollback eval pollution) - buf_len = len(self._correctness_buffer) - ctx = ToolContext(task_id) - try: - reward = await self.compute_reward(item, result, ctx) - finally: - ctx.cleanup() - - # Extract correctness and rollback training buffers - correctness = ( - self._correctness_buffer[buf_len] - if len(self._correctness_buffer) > buf_len - else 0.0 - ) - for buf in ( - self._reward_buffer, - self._correctness_buffer, - self._efficiency_buffer, - self._tool_usage_buffer, - ): - if len(buf) > buf_len: - buf.pop() - - # Also rollback OPD buffers if they were touched - for buf in ( - self._hints_extracted_buffer, - self._opd_turns_scored_buffer, - ): - if len(buf) > buf_len: - buf.pop() - - # Extract final response - final_response = "" - for msg in reversed(result.messages): - if ( - msg.get("role") == "assistant" - and msg.get("content") - and not final_response - ): - final_response = msg["content"] - break - - samples.append( - { - "prompt": item["task"][:200], - "response": final_response[:500], - "correctness": correctness, - "reward": reward, - "turns": result.turns_used, - } - ) - - logger.info( - " → correctness=%.2f, reward=%.3f, turns=%d", - correctness, - reward, - result.turns_used, - ) - - except Exception as e: - logger.error("Eval error: %s", e) - samples.append( - { - "prompt": item["task"][:200], - "response": f"ERROR: {e}", - "correctness": 0.0, - "reward": 0.0, - "turns": 0, - } - ) - - end_time = time.time() - - correctness_scores = [s["correctness"] for s in samples] - rewards = [s["reward"] for s in samples] - n = len(samples) - - eval_metrics = { - "eval/mean_correctness": sum(correctness_scores) / n if n else 0.0, - "eval/mean_reward": sum(rewards) / n if n else 0.0, - "eval/pass_rate": ( - sum(1 for c in correctness_scores if c >= 0.8) / n if n else 0.0 - ), - "eval/n_items": n, - } - - logger.info( - "Eval complete — correctness=%.3f, reward=%.3f, pass_rate=%.0f%%", - eval_metrics["eval/mean_correctness"], - eval_metrics["eval/mean_reward"], - eval_metrics["eval/pass_rate"] * 100, - ) - - await self.evaluate_log( - metrics=eval_metrics, - samples=samples, - start_time=start_time, - end_time=end_time, - ) - - # ═══════════════════════════════════════════════════════════════════ - # 7. wandb_log — custom OPD metrics - # ═══════════════════════════════════════════════════════════════════ - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None) -> None: - """Log reward breakdown and OPD-specific metrics to wandb.""" - if wandb_metrics is None: - wandb_metrics = {} - - if self._reward_buffer: - n = len(self._reward_buffer) - wandb_metrics["train/mean_reward"] = sum(self._reward_buffer) / n - wandb_metrics["train/mean_correctness"] = ( - sum(self._correctness_buffer) / n - ) - wandb_metrics["train/mean_efficiency"] = ( - sum(self._efficiency_buffer) / n - ) - wandb_metrics["train/mean_tool_usage"] = ( - sum(self._tool_usage_buffer) / n - ) - wandb_metrics["train/pass_rate"] = ( - sum(1 for c in self._correctness_buffer if c >= 0.8) / n - ) - wandb_metrics["train/total_rollouts"] = n - - self._reward_buffer.clear() - self._correctness_buffer.clear() - self._efficiency_buffer.clear() - self._tool_usage_buffer.clear() - - # OPD-specific metrics - if self._hints_extracted_buffer: - n = len(self._hints_extracted_buffer) - wandb_metrics["opd/mean_hints_per_rollout"] = ( - sum(self._hints_extracted_buffer) / n - ) - wandb_metrics["opd/mean_turns_scored"] = ( - sum(self._opd_turns_scored_buffer) / n - ) - wandb_metrics["opd/hint_rate"] = ( - sum(1 for h in self._hints_extracted_buffer if h > 0) / n - ) - wandb_metrics["opd/total_hints"] = sum(self._hints_extracted_buffer) - wandb_metrics["opd/total_scored_turns"] = sum( - self._opd_turns_scored_buffer - ) - - self._hints_extracted_buffer.clear() - self._opd_turns_scored_buffer.clear() - - await super().wandb_log(wandb_metrics) - - -# ═══════════════════════════════════════════════════════════════════════ -# Entry point -# ═══════════════════════════════════════════════════════════════════════ - -if __name__ == "__main__": - AgenticOPDEnv.cli() diff --git a/environments/benchmarks/__init__.py b/environments/benchmarks/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/benchmarks/tblite/README.md b/environments/benchmarks/tblite/README.md deleted file mode 100644 index 54b3745c383..00000000000 --- a/environments/benchmarks/tblite/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# OpenThoughts-TBLite Evaluation Environment - -This environment evaluates terminal agents on the [OpenThoughts-TBLite](https://huggingface.co/datasets/open-thoughts/OpenThoughts-TBLite) benchmark, a difficulty-calibrated subset of [Terminal-Bench 2.0](https://www.tbench.ai/leaderboard/terminal-bench/2.0). - -## Source - -OpenThoughts-TBLite was created by the [OpenThoughts](https://www.openthoughts.ai/) Agent team in collaboration with [Snorkel AI](https://snorkel.ai/) and [Bespoke Labs](https://bespokelabs.ai/). The original dataset and documentation live at: - -- **Dataset (source):** [open-thoughts/OpenThoughts-TBLite](https://huggingface.co/datasets/open-thoughts/OpenThoughts-TBLite) -- **GitHub:** [open-thoughts/OpenThoughts-TBLite](https://github.com/open-thoughts/OpenThoughts-TBLite) -- **Blog post:** [openthoughts.ai/blog/openthoughts-tblite](https://www.openthoughts.ai/blog/openthoughts-tblite) - -## Our Dataset - -We converted the source into the same schema used by our Terminal-Bench 2.0 environment (pre-built Docker Hub images, base64-encoded test tarballs, etc.) and published it as: - -- **Dataset (ours):** [NousResearch/openthoughts-tblite](https://huggingface.co/datasets/NousResearch/openthoughts-tblite) -- **Docker images:** `nousresearch/tblite-:latest` on Docker Hub (100 images) - -The conversion script is at `scripts/prepare_tblite_dataset.py`. - -## Why TBLite? - -Terminal-Bench 2.0 is one of the strongest frontier evaluations for terminal agents, but when a model scores near the floor (e.g., Qwen 3 8B at <1%), many changes look identical in aggregate score. TBLite addresses this by calibrating task difficulty using Claude Haiku 4.5 as a reference: - -| Difficulty | Pass Rate Range | Tasks | -|------------|----------------|-------| -| Easy | >= 70% | 40 | -| Medium | 40-69% | 26 | -| Hard | 10-39% | 26 | -| Extreme | < 10% | 8 | - -This gives enough solvable tasks to detect small improvements quickly, while preserving enough hard tasks to avoid saturation. The correlation between TBLite and TB2 scores is **r = 0.911**. - -TBLite also runs 2.6-8x faster than the full TB2, making it practical for iteration loops. - -## Usage - -```bash -# Run the full benchmark -python environments/benchmarks/tblite/tblite_env.py evaluate - -# Filter to specific tasks -python environments/benchmarks/tblite/tblite_env.py evaluate \ - --env.task_filter "broken-python,pandas-etl" - -# Use a different model -python environments/benchmarks/tblite/tblite_env.py evaluate \ - --server.model_name "qwen/qwen3-30b" -``` - -## Architecture - -`TBLiteEvalEnv` is a thin subclass of `TerminalBench2EvalEnv`. All evaluation logic (agent loop, Docker sandbox management, test verification, metrics) is inherited. Only the defaults differ: - -| Setting | TB2 | TBLite | -|----------------|----------------------------------|-----------------------------------------| -| Dataset | `NousResearch/terminal-bench-2` | `NousResearch/openthoughts-tblite` | -| Tasks | 89 | 100 | -| Task timeout | 1800s (30 min) | 1200s (20 min) | -| Wandb name | `terminal-bench-2` | `openthoughts-tblite` | - -## Citation - -```bibtex -@software{OpenThoughts-TBLite, - author = {OpenThoughts-Agent team, Snorkel AI, Bespoke Labs}, - month = Feb, - title = {{OpenThoughts-TBLite: A High-Signal Benchmark for Iterating on Terminal Agents}}, - howpublished = {https://www.openthoughts.ai/blog/openthoughts-tblite}, - year = {2026} -} -``` diff --git a/environments/benchmarks/tblite/__init__.py b/environments/benchmarks/tblite/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/benchmarks/tblite/default.yaml b/environments/benchmarks/tblite/default.yaml deleted file mode 100644 index cb521828061..00000000000 --- a/environments/benchmarks/tblite/default.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# OpenThoughts-TBLite Evaluation -- Default Configuration -# -# Eval-only environment for the TBLite benchmark (100 difficulty-calibrated -# terminal tasks, a faster proxy for Terminal-Bench 2.0). -# Uses Modal terminal backend for per-task cloud-isolated sandboxes -# and OpenRouter for inference. -# -# Usage: -# python environments/benchmarks/tblite/tblite_env.py evaluate \ -# --config environments/benchmarks/tblite/default.yaml -# -# # Override model: -# python environments/benchmarks/tblite/tblite_env.py evaluate \ -# --config environments/benchmarks/tblite/default.yaml \ -# --openai.model_name anthropic/claude-sonnet-4 - -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 60 - max_token_length: 32000 - agent_temperature: 0.8 - terminal_backend: "modal" - terminal_timeout: 300 # 5 min per command (builds, pip install) - tool_pool_size: 128 # thread pool for 100 parallel tasks - dataset_name: "NousResearch/openthoughts-tblite" - test_timeout: 600 - task_timeout: 1200 # 20 min wall-clock per task (TBLite tasks are faster) - tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B" - use_wandb: true - wandb_name: "openthoughts-tblite" - ensure_scores_are_not_same: false - data_dir_to_save_evals: "environments/benchmarks/evals/openthoughts-tblite" - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-opus-4.6" - server_type: "openai" - health_check: false - # api_key loaded from OPENROUTER_API_KEY in .env diff --git a/environments/benchmarks/tblite/local.yaml b/environments/benchmarks/tblite/local.yaml deleted file mode 100644 index 35d4b896869..00000000000 --- a/environments/benchmarks/tblite/local.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# OpenThoughts-TBLite Evaluation -- Docker Backend (Local Compute) -# -# Runs tasks in Docker containers on the local machine. -# Sandboxed like Modal but no cloud costs. Good for dev/testing. -# -# Usage: -# python environments/benchmarks/tblite/tblite_env.py evaluate \ -# --config environments/benchmarks/tblite/local.yaml -# -# # Override concurrency: -# python environments/benchmarks/tblite/tblite_env.py evaluate \ -# --config environments/benchmarks/tblite/local.yaml \ -# --env.eval_concurrency 4 - -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 60 - max_token_length: 32000 - agent_temperature: 0.8 - terminal_backend: "docker" - terminal_timeout: 300 - tool_pool_size: 16 - dataset_name: "NousResearch/openthoughts-tblite" - test_timeout: 600 - task_timeout: 1200 - eval_concurrency: 8 # max 8 tasks at once - tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B" - use_wandb: false - wandb_name: "openthoughts-tblite-local" - ensure_scores_are_not_same: false - data_dir_to_save_evals: "environments/benchmarks/evals/openthoughts-tblite-local" - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-sonnet-4" - server_type: "openai" - health_check: false - # api_key loaded from OPENROUTER_API_KEY in .env diff --git a/environments/benchmarks/tblite/local_vllm.yaml b/environments/benchmarks/tblite/local_vllm.yaml deleted file mode 100644 index 17689ba1d35..00000000000 --- a/environments/benchmarks/tblite/local_vllm.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# OpenThoughts-TBLite Evaluation -- Local vLLM Backend -# -# Runs against a local vLLM server with Docker sandboxes. -# -# Start the vLLM server from the atropos directory: -# python -m example_trainer.vllm_api_server \ -# --model Qwen/Qwen3-4B-Instruct-2507 \ -# --port 9001 \ -# --gpu-memory-utilization 0.8 \ -# --max-model-len=32000 -# -# Then run: -# python environments/benchmarks/tblite/tblite_env.py evaluate \ -# --config environments/benchmarks/tblite/local_vllm.yaml - -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 60 - max_token_length: 16000 - agent_temperature: 0.6 - terminal_backend: "docker" - terminal_timeout: 300 - tool_pool_size: 16 - dataset_name: "NousResearch/openthoughts-tblite" - test_timeout: 600 - task_timeout: 1200 - eval_concurrency: 8 - tool_call_parser: "hermes" - system_prompt: "You are an expert terminal agent. You MUST use the provided tools to complete tasks. Use the terminal tool to run shell commands, read_file to read files, write_file to write files, search_files to search, and patch to edit files. Do NOT write out solutions as text - execute them using the tools. Always start by exploring the environment with terminal commands." - tokenizer_name: "Qwen/Qwen3-4B-Instruct-2507" - use_wandb: false - wandb_name: "tblite-qwen3-4b-instruct" - ensure_scores_are_not_same: false - data_dir_to_save_evals: "environments/benchmarks/evals/tblite-qwen3-4b-local" - -openai: - base_url: "http://localhost:9001" - model_name: "Qwen/Qwen3-4B-Instruct-2507" - server_type: "vllm" - health_check: false diff --git a/environments/benchmarks/tblite/run_eval.sh b/environments/benchmarks/tblite/run_eval.sh deleted file mode 100755 index 9d860bf5ef7..00000000000 --- a/environments/benchmarks/tblite/run_eval.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# OpenThoughts-TBLite Evaluation -# -# Run from repo root: -# bash environments/benchmarks/tblite/run_eval.sh -# -# Override model: -# bash environments/benchmarks/tblite/run_eval.sh \ -# --openai.model_name anthropic/claude-sonnet-4 -# -# Run a subset: -# bash environments/benchmarks/tblite/run_eval.sh \ -# --env.task_filter broken-python,pandas-etl -# -# All terminal settings (backend, timeout, lifetime, pool size) are -# configured via env config fields -- no env vars needed. - -set -euo pipefail - -mkdir -p logs evals/openthoughts-tblite -LOG_FILE="logs/tblite_$(date +%Y%m%d_%H%M%S).log" - -echo "OpenThoughts-TBLite Evaluation" -echo "Log file: $LOG_FILE" -echo "" - -# Unbuffered python output so logs are written in real-time -export PYTHONUNBUFFERED=1 - -# Show INFO-level agent loop timing (api/tool durations per turn) -# These go to the log file; tqdm + [START]/[PASS]/[FAIL] go to terminal -export LOGLEVEL=INFO - -python tblite_env.py evaluate \ - --config default.yaml \ - "$@" \ - 2>&1 | tee "$LOG_FILE" - -echo "" -echo "Log saved to: $LOG_FILE" -echo "Eval results: evals/openthoughts-tblite/" diff --git a/environments/benchmarks/tblite/tblite_env.py b/environments/benchmarks/tblite/tblite_env.py deleted file mode 100644 index 4b23f9cc558..00000000000 --- a/environments/benchmarks/tblite/tblite_env.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -OpenThoughts-TBLite Evaluation Environment - -A lighter, faster alternative to Terminal-Bench 2.0 for iterating on terminal -agents. Uses the same evaluation logic as TerminalBench2EvalEnv but defaults -to the NousResearch/openthoughts-tblite dataset (100 difficulty-calibrated -tasks vs TB2's 89 harder tasks). - -TBLite tasks are a curated subset of TB2 with a difficulty distribution -designed to give meaningful signal even for smaller models: - - Easy (40 tasks): >= 70% pass rate with Claude Haiku 4.5 - - Medium (26 tasks): 40-69% pass rate - - Hard (26 tasks): 10-39% pass rate - - Extreme (8 tasks): < 10% pass rate - -Usage: - python environments/benchmarks/tblite/tblite_env.py evaluate - - # Filter to specific tasks: - python environments/benchmarks/tblite/tblite_env.py evaluate \\ - --env.task_filter "broken-python,pandas-etl" -""" - -import os -import sys -from pathlib import Path -from typing import List, Tuple - -_repo_root = Path(__file__).resolve().parent.parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from pydantic import Field - -from atroposlib.envs.base import EvalHandlingEnum -from atroposlib.envs.server_handling.server_manager import APIServerConfig - -from environments.benchmarks.terminalbench_2.terminalbench2_env import ( - TerminalBench2EvalConfig, - TerminalBench2EvalEnv, -) - - -class TBLiteEvalConfig(TerminalBench2EvalConfig): - """Configuration for the OpenThoughts-TBLite evaluation environment. - - Inherits all TB2 config fields. Only the dataset default and task timeout - differ -- TBLite tasks are calibrated to be faster. - """ - - dataset_name: str = Field( - default="NousResearch/openthoughts-tblite", - description="HuggingFace dataset containing TBLite tasks.", - ) - - task_timeout: int = Field( - default=1200, - description="Maximum wall-clock seconds per task. TBLite tasks are " - "generally faster than TB2, so 20 minutes is usually sufficient.", - ) - - -class TBLiteEvalEnv(TerminalBench2EvalEnv): - """OpenThoughts-TBLite evaluation environment. - - Inherits all evaluation logic from TerminalBench2EvalEnv (agent loop, - test verification, Docker image resolution, metrics, wandb logging). - Only the default configuration differs. - """ - - name = "openthoughts-tblite" - env_config_cls = TBLiteEvalConfig - - @classmethod - def config_init(cls) -> Tuple[TBLiteEvalConfig, List[APIServerConfig]]: - env_config = TBLiteEvalConfig( - enabled_toolsets=["terminal", "file"], - disabled_toolsets=None, - distribution=None, - - max_agent_turns=60, - max_token_length=16000, - agent_temperature=0.6, - system_prompt=None, - - terminal_backend="modal", - terminal_timeout=300, - - test_timeout=180, - - # 100 tasks in parallel - tool_pool_size=128, - - eval_handling=EvalHandlingEnum.STOP_TRAIN, - group_size=1, - steps_per_eval=1, - total_steps=1, - - tokenizer_name="NousResearch/Hermes-3-Llama-3.1-8B", - use_wandb=True, - wandb_name="openthoughts-tblite", - ensure_scores_are_not_same=False, - ) - - server_configs = [ - APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-sonnet-4", - server_type="openai", - api_key=os.getenv("OPENROUTER_API_KEY", ""), - health_check=False, - ) - ] - - return env_config, server_configs - - -if __name__ == "__main__": - TBLiteEvalEnv.cli() diff --git a/environments/benchmarks/terminalbench_2/__init__.py b/environments/benchmarks/terminalbench_2/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/benchmarks/terminalbench_2/default.yaml b/environments/benchmarks/terminalbench_2/default.yaml deleted file mode 100644 index eb675b12e70..00000000000 --- a/environments/benchmarks/terminalbench_2/default.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Terminal-Bench 2.0 Evaluation -- Default Configuration -# -# Eval-only environment for the TB2 benchmark (89 terminal tasks). -# Uses Modal terminal backend for per-task cloud-isolated sandboxes -# and OpenRouter for inference. -# -# Usage: -# python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ -# --config environments/benchmarks/terminalbench_2/default.yaml -# -# # Override model: -# python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ -# --config environments/benchmarks/terminalbench_2/default.yaml \ -# --openai.model_name anthropic/claude-sonnet-4 - -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 60 - max_token_length: 32000 - agent_temperature: 0.8 - terminal_backend: "modal" - terminal_timeout: 300 # 5 min per command (builds, pip install) - tool_pool_size: 128 # thread pool for 89 parallel tasks - dataset_name: "NousResearch/terminal-bench-2" - test_timeout: 600 - task_timeout: 1800 # 30 min wall-clock per task, auto-FAIL if exceeded - tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B" - use_wandb: true - wandb_name: "terminal-bench-2" - ensure_scores_are_not_same: false - data_dir_to_save_evals: "environments/benchmarks/evals/terminal-bench-2" - # CRITICAL: Limit concurrent Modal sandbox creations to avoid deadlocks. - # Modal's blocking calls (App.lookup, etc.) deadlock when too many sandboxes - # are created simultaneously inside thread pool workers via asyncio.run(). - max_concurrent_tasks: 8 - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-opus-4.6" - server_type: "openai" - health_check: false - # api_key loaded from OPENROUTER_API_KEY in .env diff --git a/environments/benchmarks/terminalbench_2/run_eval.sh b/environments/benchmarks/terminalbench_2/run_eval.sh deleted file mode 100755 index ffbe4848065..00000000000 --- a/environments/benchmarks/terminalbench_2/run_eval.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# Terminal-Bench 2.0 Evaluation -# -# Run from repo root: -# bash environments/benchmarks/terminalbench_2/run_eval.sh -# -# Override model: -# bash environments/benchmarks/terminalbench_2/run_eval.sh \ -# --openai.model_name anthropic/claude-sonnet-4 -# -# Run a subset: -# bash environments/benchmarks/terminalbench_2/run_eval.sh \ -# --env.task_filter fix-git,git-multibranch -# -# All terminal settings (backend, timeout, lifetime, pool size) are -# configured via env config fields -- no env vars needed. - -set -euo pipefail - -mkdir -p logs evals/terminal-bench-2 -LOG_FILE="logs/terminalbench2_$(date +%Y%m%d_%H%M%S).log" - -echo "Terminal-Bench 2.0 Evaluation" -echo "Log file: $LOG_FILE" -echo "" - -# Unbuffered python output so logs are written in real-time -export PYTHONUNBUFFERED=1 - -# Show INFO-level agent loop timing (api/tool durations per turn) -# These go to the log file; tqdm + [START]/[PASS]/[FAIL] go to terminal -export LOGLEVEL=INFO - -python terminalbench2_env.py evaluate \ - --config default.yaml \ - "$@" \ - 2>&1 | tee "$LOG_FILE" - -echo "" -echo "Log saved to: $LOG_FILE" -echo "Eval results: evals/terminal-bench-2/" diff --git a/environments/benchmarks/terminalbench_2/terminalbench2_env.py b/environments/benchmarks/terminalbench_2/terminalbench2_env.py deleted file mode 100644 index 1a76b8da61e..00000000000 --- a/environments/benchmarks/terminalbench_2/terminalbench2_env.py +++ /dev/null @@ -1,1016 +0,0 @@ -""" -TerminalBench2Env -- Terminal-Bench 2.0 Evaluation Environment - -Evaluates agentic LLMs on challenging terminal tasks from Terminal-Bench 2.0. -Each task provides a unique Docker environment (pre-built on Docker Hub), a natural -language instruction, and a test suite for verification. The agent uses terminal + -file tools to complete the task, then the test suite runs inside the same sandbox. - -This is an eval-only environment (not a training environment). It is designed to -be run via the `evaluate` subcommand: - - python environments/terminalbench2_env.py evaluate \\ - --env.dataset_name NousResearch/terminal-bench-2 - -The evaluate flow: - 1. setup() -- Loads the TB2 dataset from HuggingFace - 2. evaluate() -- Iterates over all tasks, running each through: - a. rollout_and_score_eval() -- Per-task agent loop + test verification - - Resolves Docker image (pre-built Hub image or Dockerfile fallback) - - Registers per-task Modal sandbox via register_task_env_overrides() - - Runs the HermesAgentLoop (terminal + file tools) - - Uploads test suite and runs test.sh in the same sandbox - - Returns binary pass/fail result - b. Aggregates per-task, per-category, and overall pass rates - c. Logs results via evaluate_log() and wandb - -Key features: - - Per-task Modal sandboxes using pre-built Docker Hub images - - Binary reward: 1.0 if all tests pass, 0.0 otherwise - - Concurrency-controlled parallel evaluation via asyncio.Semaphore - - Per-task, per-category, and aggregate pass rate tracking -""" - -import asyncio -import base64 -import io -import json -import logging -import os -import shutil -import sys -import tarfile -import tempfile -import time -import uuid -from collections import defaultdict -from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Any, Dict, List, Optional, Tuple, Union - -# Ensure repo root is on sys.path for imports -_repo_root = Path(__file__).resolve().parent.parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from pydantic import Field - -from atroposlib.envs.base import EvalHandlingEnum -from atroposlib.envs.server_handling.server_manager import APIServerConfig - -from environments.agent_loop import AgentResult, HermesAgentLoop -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from environments.tool_context import ToolContext -from tools.terminal_tool import ( - register_task_env_overrides, - clear_task_env_overrides, - cleanup_vm, -) - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Configuration -# ============================================================================= - -class TerminalBench2EvalConfig(HermesAgentEnvConfig): - """ - Configuration for the Terminal-Bench 2.0 evaluation environment. - - Extends HermesAgentEnvConfig with TB2-specific settings for dataset loading, - test execution, task filtering, and eval concurrency. - """ - - # --- Dataset --- - dataset_name: str = Field( - default="NousResearch/terminal-bench-2", - description="HuggingFace dataset containing TB2 tasks.", - ) - - # --- Test execution --- - test_timeout: int = Field( - default=180, - description="Timeout in seconds for running the test suite after agent completes.", - ) - - # --- Image strategy --- - force_build: bool = Field( - default=False, - description="If True, always build from Dockerfile (ignore docker_image). " - "Useful for testing custom Dockerfiles.", - ) - - # --- Task filtering (comma-separated from CLI) --- - task_filter: Optional[str] = Field( - default=None, - description="Comma-separated task names to run (e.g., 'fix-git,git-multibranch'). " - "If not set, all tasks are run.", - ) - skip_tasks: Optional[str] = Field( - default=None, - description="Comma-separated task names to skip on top of the default skip list.", - ) - - # --- Per-task wall-clock timeout --- - task_timeout: int = Field( - default=1800, - description="Maximum wall-clock seconds per task (agent loop + verification). " - "Tasks exceeding this are scored as FAIL. Default 30 minutes.", - ) - - # --- Concurrency control --- - max_concurrent_tasks: int = Field( - default=8, - description="Maximum number of tasks to run concurrently. " - "Limits concurrent Modal sandbox creations to avoid async/threading deadlocks. " - "Modal has internal limits and creating too many sandboxes simultaneously " - "causes blocking calls to deadlock inside the thread pool.", - ) - - # --- Eval concurrency --- - eval_concurrency: int = Field( - default=0, - description="Maximum number of tasks to evaluate in parallel. " - "0 means unlimited (all tasks run concurrently). " - "Set to 8 for local backends to avoid overwhelming the machine.", - ) - - -# Tasks that cannot run properly on Modal and are excluded from scoring. -MODAL_INCOMPATIBLE_TASKS = { - "qemu-startup", # Needs KVM/hardware virtualization - "qemu-alpine-ssh", # Needs KVM/hardware virtualization - "crack-7z-hash", # Password brute-force -- too slow for cloud sandbox timeouts -} - - -# ============================================================================= -# Tar extraction helper -# ============================================================================= - -def _normalize_tar_member_parts(member_name: str) -> list: - """Return safe path components for a tar member or raise ValueError.""" - normalized_name = member_name.replace("\\", "/") - posix_path = PurePosixPath(normalized_name) - windows_path = PureWindowsPath(member_name) - - if ( - not normalized_name - or posix_path.is_absolute() - or windows_path.is_absolute() - or windows_path.drive - ): - raise ValueError(f"Unsafe archive member path: {member_name}") - - parts = [part for part in posix_path.parts if part not in {"", "."}] - if not parts or any(part == ".." for part in parts): - raise ValueError(f"Unsafe archive member path: {member_name}") - return parts - - -def _safe_extract_tar(tar: tarfile.TarFile, target_dir: Path) -> None: - """Extract a tar archive without allowing traversal or link entries.""" - target_dir.mkdir(parents=True, exist_ok=True) - target_root = target_dir.resolve() - - for member in tar.getmembers(): - parts = _normalize_tar_member_parts(member.name) - target = target_dir.joinpath(*parts) - target_real = target.resolve(strict=False) - - try: - target_real.relative_to(target_root) - except ValueError as exc: - raise ValueError(f"Unsafe archive member path: {member.name}") from exc - - if member.isdir(): - target_real.mkdir(parents=True, exist_ok=True) - continue - - if not member.isfile(): - raise ValueError(f"Unsupported archive member type: {member.name}") - - target_real.parent.mkdir(parents=True, exist_ok=True) - extracted = tar.extractfile(member) - if extracted is None: - raise ValueError(f"Cannot read archive member: {member.name}") - - with extracted, open(target_real, "wb") as dst: - shutil.copyfileobj(extracted, dst) - - try: - os.chmod(target_real, member.mode & 0o777) - except OSError: - pass - - -def _extract_base64_tar(b64_data: str, target_dir: Path): - """Extract a base64-encoded tar.gz archive into target_dir.""" - if not b64_data: - return - raw = base64.b64decode(b64_data) - buf = io.BytesIO(raw) - with tarfile.open(fileobj=buf, mode="r:gz") as tar: - _safe_extract_tar(tar, target_dir) - - -# ============================================================================= -# Main Environment -# ============================================================================= - -class TerminalBench2EvalEnv(HermesAgentBaseEnv): - """ - Terminal-Bench 2.0 evaluation environment (eval-only, no training). - - Inherits from HermesAgentBaseEnv for: - - Terminal backend setup (os.environ["TERMINAL_ENV"]) - - Tool resolution via _resolve_tools_for_group() - - Monkey patches for async-safe tool operation - - Wandb trajectory formatting - - The evaluate flow (triggered by `environment.py evaluate`): - 1. setup() -- Load dataset from HuggingFace - 2. evaluate() -- Run all tasks through rollout_and_score_eval() - - Each task in rollout_and_score_eval(): - 1. Resolve Docker image (pre-built Hub image or Dockerfile fallback) - 2. Register per-task Modal sandbox override - 3. Run HermesAgentLoop with terminal + file tools - 4. Upload test suite and execute test.sh in the same sandbox - 5. Check /logs/verifier/reward.txt for pass/fail - 6. Clean up sandbox, overrides, and temp files - """ - - name = "terminal-bench-2" - env_config_cls = TerminalBench2EvalConfig - - @classmethod - def config_init(cls) -> Tuple[TerminalBench2EvalConfig, List[APIServerConfig]]: - """ - Default configuration for Terminal-Bench 2.0 evaluation. - - Uses eval-only settings: - - eval_handling=STOP_TRAIN so the eval flow runs cleanly - - steps_per_eval=1, total_steps=1 so eval triggers immediately - - group_size=1 (one rollout per group, each task is expensive) - - Uses Modal terminal backend (cloud-isolated sandbox per task) and - OpenRouter with Claude for inference. - """ - env_config = TerminalBench2EvalConfig( - # Terminal + file tools only (the agent interacts via shell commands) - enabled_toolsets=["terminal", "file"], - disabled_toolsets=None, - distribution=None, - - # Agent settings -- TB2 tasks are complex, need many turns - max_agent_turns=60, - max_token_length=16000, - agent_temperature=0.6, - system_prompt=None, - - # Modal backend for per-task cloud-isolated sandboxes - terminal_backend="modal", - terminal_timeout=300, # 5 min per command (builds, pip install, etc.) - - # Test execution timeout (TB2 test scripts can install deps like pytest) - test_timeout=180, - - # 89 tasks run in parallel, each needs a thread for tool calls - tool_pool_size=128, - - # --- Eval-only Atropos settings --- - # These settings make the env work as an eval-only environment: - # - STOP_TRAIN: pauses training during eval (standard for eval envs) - # - steps_per_eval=1, total_steps=1: eval triggers immediately - # - group_size=1: one rollout per group (each task is expensive) - eval_handling=EvalHandlingEnum.STOP_TRAIN, - group_size=1, - steps_per_eval=1, - total_steps=1, - - tokenizer_name="NousResearch/Hermes-3-Llama-3.1-8B", - use_wandb=True, - wandb_name="terminal-bench-2", - ensure_scores_are_not_same=False, # Binary rewards may all be 0 or 1 - ) - - # OpenRouter with Claude -- API key loaded from .env - server_configs = [ - APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-sonnet-4", - server_type="openai", - api_key=os.getenv("OPENROUTER_API_KEY", ""), - health_check=False, - ) - ] - - return env_config, server_configs - - # ========================================================================= - # Setup -- load dataset - # ========================================================================= - - async def setup(self): - """Load the Terminal-Bench 2.0 dataset from HuggingFace.""" - from datasets import load_dataset - - # Auto-set terminal_lifetime to task_timeout + 120s so sandboxes - # never get killed during an active task, but still get cleaned up - # promptly after the task times out. - lifetime = self.config.task_timeout + 120 - self.config.terminal_lifetime = lifetime - os.environ["TERMINAL_LIFETIME_SECONDS"] = str(lifetime) - print(f" Terminal lifetime auto-set to {lifetime}s (task_timeout + 120s)") - - print(f"Loading TB2 dataset from: {self.config.dataset_name}") - ds = load_dataset(self.config.dataset_name, split="train") - - # Apply task filters (comma-separated strings from CLI) - tasks = list(ds) - if self.config.task_filter: - allowed = {name.strip() for name in self.config.task_filter.split(",")} - tasks = [t for t in tasks if t["task_name"] in allowed] - print(f" Filtered to {len(tasks)} tasks: {sorted(allowed)}") - - # Skip tasks incompatible with the current backend (e.g., QEMU on Modal) - # plus any user-specified skip_tasks - skip = set(MODAL_INCOMPATIBLE_TASKS) if self.config.terminal_backend == "modal" else set() - if self.config.skip_tasks: - skip |= {name.strip() for name in self.config.skip_tasks.split(",")} - if skip: - before = len(tasks) - tasks = [t for t in tasks if t["task_name"] not in skip] - skipped = before - len(tasks) - if skipped > 0: - print(f" Skipped {skipped} incompatible tasks: {sorted(skip & {t['task_name'] for t in ds})}") - - self.all_eval_items = tasks - self.iter = 0 - - # Build category index for per-category metrics - self.category_index: Dict[str, List[int]] = defaultdict(list) - for i, task in enumerate(self.all_eval_items): - self.category_index[task.get("category", "unknown")].append(i) - - # Reward tracking for wandb logging - self.eval_metrics: List[Tuple[str, float]] = [] - - # Streaming JSONL writer -- saves each task's full conversation - # immediately on completion so data is preserved even on Ctrl+C. - # Timestamped filename so each run produces a unique file. - import datetime - log_dir = os.path.join(os.path.dirname(__file__), "logs") - os.makedirs(log_dir, exist_ok=True) - run_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - self._streaming_path = os.path.join(log_dir, f"samples_{run_ts}.jsonl") - self._streaming_file = open(self._streaming_path, "w", encoding="utf-8") - self._streaming_lock = __import__("threading").Lock() - print(f" Streaming results to: {self._streaming_path}") - - print(f"TB2 ready: {len(self.all_eval_items)} tasks across {len(self.category_index)} categories") - for cat, indices in sorted(self.category_index.items()): - print(f" {cat}: {len(indices)} tasks") - - def _save_result(self, result: Dict[str, Any]): - """Write a single task result to the streaming JSONL file immediately.""" - if not hasattr(self, "_streaming_file") or self._streaming_file.closed: - return - with self._streaming_lock: - self._streaming_file.write(json.dumps(result, ensure_ascii=False, default=str) + "\n") - self._streaming_file.flush() - - # ========================================================================= - # Training pipeline stubs -- NOT used in eval-only mode - # ========================================================================= - # These satisfy the abstract method requirements from HermesAgentBaseEnv. - # The evaluate subcommand calls setup() -> evaluate() directly, bypassing - # the training pipeline entirely. - - async def get_next_item(self): - """Return next item (stub -- not used in eval-only mode).""" - item = self.all_eval_items[self.iter % len(self.all_eval_items)] - self.iter += 1 - return item - - def format_prompt(self, item: Dict[str, Any]) -> str: - """Return the task's instruction as the user prompt.""" - return item["instruction"] - - async def compute_reward(self, item, result, ctx) -> float: - """Compute reward (stub -- actual verification is in rollout_and_score_eval).""" - return 0.0 - - async def collect_trajectories(self, item): - """Collect trajectories (stub -- not used in eval-only mode).""" - return None, [] - - async def score(self, rollout_group_data): - """Score rollouts (stub -- not used in eval-only mode).""" - return None - - # ========================================================================= - # Docker image resolution - # ========================================================================= - - def _resolve_task_image( - self, item: Dict[str, Any], task_name: str - ) -> Tuple[str, Optional[Path]]: - """ - Resolve the Docker image for a task, with fallback to Dockerfile. - - Strategy (mirrors Harbor's approach): - 1. If force_build=True, always build from Dockerfile in environment_tar - 2. If docker_image is available, use the pre-built Docker Hub image (fast) - 3. Otherwise, extract Dockerfile from environment_tar and build (slow) - - Returns: - (modal_image, temp_dir) -- modal_image is a Docker Hub name or a - Dockerfile path. temp_dir is set if we extracted files that need - cleanup later. - """ - docker_image = item.get("docker_image", "") - environment_tar = item.get("environment_tar", "") - - # Fast path: use pre-built Docker Hub image - if docker_image and not self.config.force_build: - logger.info("Task %s: using pre-built image %s", task_name, docker_image) - return docker_image, None - - # Slow path: extract Dockerfile from environment_tar and build - if environment_tar: - task_dir = Path(tempfile.mkdtemp(prefix=f"tb2-{task_name}-")) - _extract_base64_tar(environment_tar, task_dir) - dockerfile_path = task_dir / "Dockerfile" - if dockerfile_path.exists(): - logger.info( - "Task %s: building from Dockerfile (force_build=%s, docker_image=%s)", - task_name, self.config.force_build, bool(docker_image), - ) - return str(dockerfile_path), task_dir - - # Neither available -- fall back to Hub image if force_build was True - if docker_image: - logger.warning( - "Task %s: force_build=True but no environment_tar, " - "falling back to docker_image %s", task_name, docker_image, - ) - return docker_image, None - - return "", None - - # ========================================================================= - # Per-task evaluation -- agent loop + test verification - # ========================================================================= - - async def rollout_and_score_eval(self, eval_item: Dict[str, Any]) -> Dict: - """ - Evaluate a single TB2 task: run the agent loop, then verify with tests. - - This is the core evaluation method. For each task it: - 1. Resolves the Docker image and registers the Modal sandbox override - 2. Runs HermesAgentLoop with terminal + file tools - 3. Uploads the test suite into the sandbox - 4. Executes test.sh and checks the result - 5. Cleans up the sandbox and temp files - - Args: - eval_item: A single TB2 task dict from the dataset - - Returns: - Dict with 'passed' (bool), 'reward' (float), 'task_name' (str), - 'category' (str), and optional debug info - """ - task_name = eval_item.get("task_name", "unknown") - category = eval_item.get("category", "unknown") - task_id = str(uuid.uuid4()) - task_dir = None # Set if we extract a Dockerfile (needs cleanup) - - from tqdm import tqdm - tqdm.write(f" [START] {task_name} (task_id={task_id[:8]})") - task_start = time.time() - - try: - # --- 1. Resolve Docker image --- - modal_image, task_dir = self._resolve_task_image(eval_item, task_name) - if not modal_image: - logger.error("Task %s: no docker_image or environment_tar, skipping", task_name) - return { - "passed": False, "reward": 0.0, - "task_name": task_name, "category": category, - "error": "no_image", - } - - # --- 2. Register per-task image override --- - # Set both modal_image and docker_image so the task image is used - # regardless of which backend is configured. - register_task_env_overrides(task_id, { - "modal_image": modal_image, - "docker_image": modal_image, - "cwd": "/app", - }) - logger.info( - "Task %s: registered image override for task_id %s", - task_name, task_id[:8], - ) - - # --- 3. Resolve tools and build messages --- - tools, valid_names = self._resolve_tools_for_group() - - messages: List[Dict[str, Any]] = [] - if self.config.system_prompt: - messages.append({"role": "system", "content": self.config.system_prompt}) - messages.append({"role": "user", "content": self.format_prompt(eval_item)}) - - # --- 4. Run agent loop --- - # Use ManagedServer (Phase 2) for vLLM/SGLang backends to get - # token-level tracking via /generate. Falls back to direct - # ServerManager (Phase 1) for OpenAI endpoints. - if self._use_managed_server(): - async with self.server.managed_server( - tokenizer=self.tokenizer, - preserve_think_blocks=bool(self.config.thinking_mode), - ) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - else: - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - - # --- 5. Verify -- run test suite in the agent's sandbox --- - # Skip verification if the agent produced no meaningful output - only_system_and_user = all( - msg.get("role") in {"system", "user"} for msg in result.messages - ) - if result.turns_used == 0 or only_system_and_user: - logger.warning( - "Task %s: agent produced no output (turns=%d). Reward=0.", - task_name, result.turns_used, - ) - reward = 0.0 - else: - # Run tests in a thread so the blocking ctx.terminal() calls - # don't freeze the entire event loop (which would stall all - # other tasks, tqdm updates, and timeout timers). - ctx = ToolContext(task_id) - try: - loop = asyncio.get_running_loop() - reward = await loop.run_in_executor( - None, # default thread pool - self._run_tests, eval_item, ctx, task_name, - ) - except Exception as e: - logger.error("Task %s: test verification failed: %s", task_name, e) - reward = 0.0 - finally: - ctx.cleanup() - - passed = reward == 1.0 - status = "PASS" if passed else "FAIL" - elapsed = time.time() - task_start - tqdm.write(f" [{status}] {task_name} (turns={result.turns_used}, {elapsed:.0f}s)") - logger.info( - "Task %s: reward=%.1f, turns=%d, finished=%s", - task_name, reward, result.turns_used, result.finished_naturally, - ) - - out = { - "passed": passed, - "reward": reward, - "task_name": task_name, - "category": category, - "turns_used": result.turns_used, - "finished_naturally": result.finished_naturally, - "messages": result.messages, - } - self._save_result(out) - return out - - except Exception as e: - elapsed = time.time() - task_start - logger.error("Task %s: rollout failed: %s", task_name, e, exc_info=True) - tqdm.write(f" [ERROR] {task_name}: {e} ({elapsed:.0f}s)") - out = { - "passed": False, "reward": 0.0, - "task_name": task_name, "category": category, - "error": str(e), - } - self._save_result(out) - return out - - finally: - # --- Cleanup: clear overrides, sandbox, and temp files --- - clear_task_env_overrides(task_id) - try: - cleanup_vm(task_id) - except Exception as e: - logger.debug("VM cleanup for %s: %s", task_id[:8], e) - if task_dir and task_dir.exists(): - shutil.rmtree(task_dir, ignore_errors=True) - - def _run_tests( - self, item: Dict[str, Any], ctx: ToolContext, task_name: str - ) -> float: - """ - Upload and execute the test suite in the agent's sandbox, then - download the verifier output locally to read the reward. - - Follows Harbor's verification pattern: - 1. Upload tests/ directory into the sandbox - 2. Execute test.sh inside the sandbox - 3. Download /logs/verifier/ directory to a local temp dir - 4. Read reward.txt locally with native Python I/O - - Downloading locally avoids issues with the file_read tool on - the Modal VM and matches how Harbor handles verification. - - TB2 test scripts (test.sh) typically: - 1. Install pytest via uv/pip - 2. Run pytest against the test files in /tests/ - 3. Write results to /logs/verifier/reward.txt - - Args: - item: The TB2 task dict (contains tests_tar, test_sh) - ctx: ToolContext scoped to this task's sandbox - task_name: For logging - - Returns: - 1.0 if tests pass, 0.0 otherwise - """ - tests_tar = item.get("tests_tar", "") - test_sh = item.get("test_sh", "") - - if not test_sh: - logger.warning("Task %s: no test_sh content, reward=0", task_name) - return 0.0 - - # Create required directories in the sandbox - ctx.terminal("mkdir -p /tests /logs/verifier") - - # Upload test files into the sandbox (binary-safe via base64) - if tests_tar: - tests_temp = Path(tempfile.mkdtemp(prefix=f"tb2-tests-{task_name}-")) - try: - _extract_base64_tar(tests_tar, tests_temp) - ctx.upload_dir(str(tests_temp), "/tests") - except Exception as e: - logger.warning("Task %s: failed to upload test files: %s", task_name, e) - finally: - shutil.rmtree(tests_temp, ignore_errors=True) - - # Write the test runner script (test.sh) - ctx.write_file("/tests/test.sh", test_sh) - ctx.terminal("chmod +x /tests/test.sh") - - # Execute the test suite - logger.info( - "Task %s: running test suite (timeout=%ds)", - task_name, self.config.test_timeout, - ) - test_result = ctx.terminal( - "bash /tests/test.sh", - timeout=self.config.test_timeout, - ) - - exit_code = test_result.get("exit_code", -1) - output = test_result.get("output", "") - - # Download the verifier output directory locally, then read reward.txt - # with native Python I/O. This avoids issues with file_read on the - # Modal VM and matches Harbor's verification pattern. - reward = 0.0 - local_verifier_dir = Path(tempfile.mkdtemp(prefix=f"tb2-verifier-{task_name}-")) - try: - ctx.download_dir("/logs/verifier", str(local_verifier_dir)) - - reward_file = local_verifier_dir / "reward.txt" - if reward_file.exists() and reward_file.stat().st_size > 0: - content = reward_file.read_text().strip() - if content == "1": - reward = 1.0 - elif content == "0": - reward = 0.0 - else: - # Unexpected content -- try parsing as float - try: - reward = float(content) - except (ValueError, TypeError): - logger.warning( - "Task %s: reward.txt content unexpected (%r), " - "falling back to exit_code=%d", - task_name, content, exit_code, - ) - reward = 1.0 if exit_code == 0 else 0.0 - else: - # reward.txt not written -- fall back to exit code - logger.warning( - "Task %s: reward.txt not found after download, " - "falling back to exit_code=%d", - task_name, exit_code, - ) - reward = 1.0 if exit_code == 0 else 0.0 - except Exception as e: - logger.warning( - "Task %s: failed to download verifier dir: %s, " - "falling back to exit_code=%d", - task_name, e, exit_code, - ) - reward = 1.0 if exit_code == 0 else 0.0 - finally: - shutil.rmtree(local_verifier_dir, ignore_errors=True) - - # Log test output for debugging failures - if reward == 0.0: - output_preview = output[-500:] if output else "(no output)" - logger.info( - "Task %s: FAIL (exit_code=%d)\n%s", - task_name, exit_code, output_preview, - ) - - return reward - - # ========================================================================= - # Evaluate -- main entry point for the eval subcommand - # ========================================================================= - - async def _eval_with_timeout(self, item: Dict[str, Any]) -> Dict: - """ - Wrap rollout_and_score_eval with a per-task wall-clock timeout. - - If the task exceeds task_timeout seconds, it's automatically scored - as FAIL. This prevents any single task from hanging indefinitely. - """ - task_name = item.get("task_name", "unknown") - category = item.get("category", "unknown") - try: - return await asyncio.wait_for( - self.rollout_and_score_eval(item), - timeout=self.config.task_timeout, - ) - except asyncio.TimeoutError: - from tqdm import tqdm - elapsed = self.config.task_timeout - tqdm.write(f" [TIMEOUT] {task_name} (exceeded {elapsed}s wall-clock limit)") - logger.error("Task %s: wall-clock timeout after %ds", task_name, elapsed) - out = { - "passed": False, "reward": 0.0, - "task_name": task_name, "category": category, - "error": f"timeout ({elapsed}s)", - } - self._save_result(out) - return out - - async def evaluate(self, *args, **kwargs) -> None: - """ - Run Terminal-Bench 2.0 evaluation over all tasks. - - This is the main entry point when invoked via: - python environments/terminalbench2_env.py evaluate - - Runs all tasks through rollout_and_score_eval() via asyncio.gather() - (same pattern as GPQA and other Atropos eval envs). Each task is - wrapped with a wall-clock timeout so hung tasks auto-fail. - - Suppresses noisy Modal/terminal output (HERMES_QUIET) so the tqdm - bar stays visible. - """ - start_time = time.time() - - # Route all logging through tqdm.write() so the progress bar stays - # pinned at the bottom while log lines scroll above it. - from tqdm import tqdm - - class _TqdmHandler(logging.Handler): - def emit(self, record): - try: - tqdm.write(self.format(record)) - except Exception: - self.handleError(record) - - handler = _TqdmHandler() - handler.setFormatter(logging.Formatter( - "%(asctime)s [%(name)s] %(levelname)s: %(message)s", - datefmt="%H:%M:%S", - )) - root = logging.getLogger() - root.handlers = [handler] # Replace any existing handlers - root.setLevel(logging.INFO) - - # Silence noisy third-party loggers that flood the output - logging.getLogger("httpx").setLevel(logging.WARNING) # Every HTTP request - logging.getLogger("openai").setLevel(logging.WARNING) # OpenAI client retries - logging.getLogger("rex-deploy").setLevel(logging.WARNING) # Swerex deployment - logging.getLogger("rex_image_builder").setLevel(logging.WARNING) # Image builds - - print(f"\n{'='*60}") - print("Starting Terminal-Bench 2.0 Evaluation") - print(f"{'='*60}") - print(f" Dataset: {self.config.dataset_name}") - print(f" Total tasks: {len(self.all_eval_items)}") - print(f" Max agent turns: {self.config.max_agent_turns}") - print(f" Task timeout: {self.config.task_timeout}s") - print(f" Terminal backend: {self.config.terminal_backend}") - print(f" Tool thread pool: {self.config.tool_pool_size}") - print(f" Terminal timeout: {self.config.terminal_timeout}s/cmd") - print(f" Terminal lifetime: {self.config.terminal_lifetime}s (auto: task_timeout + 120)") - print(f" Max concurrent tasks: {self.config.max_concurrent_tasks}") - print(f"{'='*60}\n") - - # Semaphore to limit concurrent Modal sandbox creations. - # Without this, all 86 tasks fire simultaneously, each creating a Modal - # sandbox via asyncio.run() inside a thread pool worker. Modal's blocking - # calls (App.lookup, etc.) deadlock when too many are created at once. - semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks) - - async def _eval_with_semaphore(item): - async with semaphore: - return await self._eval_with_timeout(item) - - # Fire all tasks with wall-clock timeout, track live accuracy on the bar - total_tasks = len(self.all_eval_items) - eval_tasks = [ - asyncio.ensure_future(_eval_with_semaphore(item)) - for item in self.all_eval_items - ] - - results = [] - passed_count = 0 - pbar = tqdm(total=total_tasks, desc="Evaluating TB2", dynamic_ncols=True) - try: - for coro in asyncio.as_completed(eval_tasks): - result = await coro - results.append(result) - if result and result.get("passed"): - passed_count += 1 - done = len(results) - pct = (passed_count / done * 100) if done else 0 - pbar.set_postfix_str(f"pass={passed_count}/{done} ({pct:.1f}%)") - pbar.update(1) - except (KeyboardInterrupt, asyncio.CancelledError): - pbar.close() - print(f"\n\nInterrupted! Cleaning up {len(eval_tasks)} tasks...") - # Cancel all pending tasks - for task in eval_tasks: - task.cancel() - # Let cancellations propagate (finally blocks run cleanup_vm) - await asyncio.gather(*eval_tasks, return_exceptions=True) - # Belt-and-suspenders: clean up any remaining sandboxes - from tools.terminal_tool import cleanup_all_environments - cleanup_all_environments() - print("All sandboxes cleaned up.") - return - finally: - pbar.close() - - end_time = time.time() - - # Filter out None results (shouldn't happen, but be safe) - valid_results = [r for r in results if r is not None] - - if not valid_results: - print("Warning: No valid evaluation results obtained") - return - - # ---- Compute metrics ---- - total = len(valid_results) - passed = sum(1 for r in valid_results if r.get("passed")) - overall_pass_rate = passed / total if total > 0 else 0.0 - - # Per-category breakdown - cat_results: Dict[str, List[Dict]] = defaultdict(list) - for r in valid_results: - cat_results[r.get("category", "unknown")].append(r) - - # Build metrics dict - eval_metrics = { - "eval/pass_rate": overall_pass_rate, - "eval/total_tasks": total, - "eval/passed_tasks": passed, - "eval/evaluation_time_seconds": end_time - start_time, - } - - # Per-category metrics - for category, cat_items in sorted(cat_results.items()): - cat_passed = sum(1 for r in cat_items if r.get("passed")) - cat_total = len(cat_items) - cat_pass_rate = cat_passed / cat_total if cat_total > 0 else 0.0 - cat_key = category.replace(" ", "_").replace("-", "_").lower() - eval_metrics[f"eval/pass_rate_{cat_key}"] = cat_pass_rate - - # Store metrics for wandb_log - self.eval_metrics = list(eval_metrics.items()) - - # ---- Print summary ---- - print(f"\n{'='*60}") - print("Terminal-Bench 2.0 Evaluation Results") - print(f"{'='*60}") - print(f"Overall Pass Rate: {overall_pass_rate:.4f} ({passed}/{total})") - print(f"Evaluation Time: {end_time - start_time:.1f} seconds") - - print("\nCategory Breakdown:") - for category, cat_items in sorted(cat_results.items()): - cat_passed = sum(1 for r in cat_items if r.get("passed")) - cat_total = len(cat_items) - cat_rate = cat_passed / cat_total if cat_total > 0 else 0.0 - print(f" {category}: {cat_rate:.1%} ({cat_passed}/{cat_total})") - - # Print individual task results - print("\nTask Results:") - for r in sorted(valid_results, key=lambda x: x.get("task_name", "")): - status = "PASS" if r.get("passed") else "FAIL" - turns = r.get("turns_used", "?") - error = r.get("error", "") - extra = f" (error: {error})" if error else "" - print(f" [{status}] {r['task_name']} (turns={turns}){extra}") - - print(f"{'='*60}\n") - - # Build sample records for evaluate_log (includes full conversations) - samples = [ - { - "task_name": r.get("task_name"), - "category": r.get("category"), - "passed": r.get("passed"), - "reward": r.get("reward"), - "turns_used": r.get("turns_used"), - "error": r.get("error"), - "messages": r.get("messages"), - } - for r in valid_results - ] - - # Log evaluation results - try: - await self.evaluate_log( - metrics=eval_metrics, - samples=samples, - start_time=start_time, - end_time=end_time, - generation_parameters={ - "temperature": self.config.agent_temperature, - "max_tokens": self.config.max_token_length, - "max_agent_turns": self.config.max_agent_turns, - "terminal_backend": self.config.terminal_backend, - }, - ) - except Exception as e: - print(f"Error logging evaluation results: {e}") - - # Close streaming file - if hasattr(self, "_streaming_file") and not self._streaming_file.closed: - self._streaming_file.close() - print(f" Live results saved to: {self._streaming_path}") - - # Kill all remaining sandboxes. Timed-out tasks leave orphaned thread - # pool workers still executing commands -- cleanup_all stops them. - from tools.terminal_tool import cleanup_all_environments - print("\nCleaning up all sandboxes...") - cleanup_all_environments() - - # Shut down the tool thread pool so orphaned workers from timed-out - # tasks are killed immediately instead of retrying against dead - # sandboxes and spamming the console with TimeoutError warnings. - from environments.agent_loop import _tool_executor - _tool_executor.shutdown(wait=False, cancel_futures=True) - print("Done.") - - # ========================================================================= - # Wandb logging - # ========================================================================= - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None): - """Log TB2-specific metrics to wandb.""" - if wandb_metrics is None: - wandb_metrics = {} - - # Add stored eval metrics - for metric_name, metric_value in self.eval_metrics: - wandb_metrics[metric_name] = metric_value - self.eval_metrics = [] - - await super().wandb_log(wandb_metrics) - - -if __name__ == "__main__": - TerminalBench2EvalEnv.cli() diff --git a/environments/benchmarks/yc_bench/README.md b/environments/benchmarks/yc_bench/README.md deleted file mode 100644 index 7a8aba7874d..00000000000 --- a/environments/benchmarks/yc_bench/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# YC-Bench: Long-Horizon Agent Benchmark - -[YC-Bench](https://github.com/collinear-ai/yc-bench) by [Collinear AI](https://collinear.ai/) is a deterministic, long-horizon benchmark that tests LLM agents' ability to act as a tech startup CEO. The agent manages a simulated company over 1-3 years, making compounding decisions about resource allocation, cash flow, task management, and prestige specialisation across 4 skill domains. - -Unlike TerminalBench2 (which evaluates per-task coding ability with binary pass/fail), YC-Bench measures **long-term strategic coherence** — whether an agent can maintain consistent strategy, manage compounding consequences, and adapt plans over hundreds of turns. - -## Setup - -```bash -# Install yc-bench (optional dependency) -pip install "hermes-agent[yc-bench]" - -# Or install from source -git clone https://github.com/collinear-ai/yc-bench -cd yc-bench && pip install -e . - -# Verify -yc-bench --help -``` - -## Running - -```bash -# From the repo root: -bash environments/benchmarks/yc_bench/run_eval.sh - -# Or directly: -python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ - --config environments/benchmarks/yc_bench/default.yaml - -# Override model: -bash environments/benchmarks/yc_bench/run_eval.sh \ - --openai.model_name anthropic/claude-opus-4-20250514 - -# Quick single-preset test: -bash environments/benchmarks/yc_bench/run_eval.sh \ - --env.presets '["fast_test"]' --env.seeds '[1]' -``` - -## How It Works - -### Architecture - -``` -HermesAgentLoop (our agent) - -> terminal tool -> subprocess("yc-bench company status") -> JSON output - -> terminal tool -> subprocess("yc-bench task accept --task-id X") -> JSON - -> terminal tool -> subprocess("yc-bench sim resume") -> JSON (advance time) - -> ... (100-500 turns per run) -``` - -The environment initialises the simulation via `yc-bench sim init` (NOT `yc-bench run`, which would start yc-bench's own built-in agent loop). Our `HermesAgentLoop` then drives all interaction through CLI commands. - -### Simulation Mechanics - -- **4 skill domains**: research, inference, data_environment, training -- **Prestige system** (1.0-10.0): Gates access to higher-paying tasks -- **Employee management**: Junior/Mid/Senior with domain-specific skill rates -- **Throughput splitting**: `effective_rate = base_rate / N` active tasks per employee -- **Financial pressure**: Monthly payroll, bankruptcy = game over -- **Deterministic**: SHA256-based RNG — same seed + preset = same world - -### Difficulty Presets - -| Preset | Employees | Tasks | Focus | -|-----------|-----------|-------|-------| -| tutorial | 3 | 50 | Basic loop mechanics | -| easy | 5 | 100 | Throughput awareness | -| **medium**| 5 | 150 | Prestige climbing + domain specialisation | -| **hard** | 7 | 200 | Precise ETA reasoning | -| nightmare | 8 | 300 | Sustained perfection under payroll pressure | -| fast_test | (varies) | (varies) | Quick validation (~50 turns) | - -Default eval runs **fast_test + medium + hard** × 3 seeds = 9 runs. - -### Scoring - -``` -composite = 0.5 × survival + 0.5 × normalised_funds -``` - -- **Survival** (binary): Did the company avoid bankruptcy? -- **Normalised funds** (0.0-1.0): Log-scale relative to initial $250K capital - -## Configuration - -Key fields in `default.yaml`: - -| Field | Default | Description | -|-------|---------|-------------| -| `presets` | `["fast_test", "medium", "hard"]` | Which presets to evaluate | -| `seeds` | `[1, 2, 3]` | RNG seeds per preset | -| `max_agent_turns` | 200 | Max LLM calls per run | -| `run_timeout` | 3600 | Wall-clock timeout per run (seconds) | -| `survival_weight` | 0.5 | Weight of survival in composite score | -| `funds_weight` | 0.5 | Weight of normalised funds in composite | -| `horizon_years` | null | Override horizon (null = auto from preset) | - -## Cost & Time Estimates - -Each run is 100-500 LLM turns. Approximate costs per run at typical API rates: - -| Preset | Turns | Time | Est. Cost | -|--------|-------|------|-----------| -| fast_test | ~50 | 5-10 min | $1-5 | -| medium | ~200 | 20-40 min | $5-15 | -| hard | ~300 | 30-60 min | $10-25 | - -Full default eval (9 runs): ~3-6 hours, $50-200 depending on model. - -## References - -- [collinear-ai/yc-bench](https://github.com/collinear-ai/yc-bench) — Official repository -- [Collinear AI](https://collinear.ai/) — Company behind yc-bench -- [TerminalBench2](../terminalbench_2/) — Per-task coding benchmark (complementary) diff --git a/environments/benchmarks/yc_bench/__init__.py b/environments/benchmarks/yc_bench/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/benchmarks/yc_bench/default.yaml b/environments/benchmarks/yc_bench/default.yaml deleted file mode 100644 index 4396c00ab94..00000000000 --- a/environments/benchmarks/yc_bench/default.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# YC-Bench Evaluation -- Default Configuration -# -# Long-horizon agent benchmark: agent plays CEO of an AI startup over -# a simulated 1-3 year run, interacting via yc-bench CLI subcommands. -# -# Requires: pip install "hermes-agent[yc-bench]" -# -# Usage: -# python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ -# --config environments/benchmarks/yc_bench/default.yaml -# -# # Override model: -# python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ -# --config environments/benchmarks/yc_bench/default.yaml \ -# --openai.model_name anthropic/claude-opus-4-20250514 - -env: - enabled_toolsets: ["terminal"] - max_agent_turns: 200 - max_token_length: 32000 - agent_temperature: 0.0 - terminal_backend: "local" - terminal_timeout: 60 - presets: ["fast_test", "medium", "hard"] - seeds: [1, 2, 3] - run_timeout: 3600 # 60 min wall-clock per run, auto-FAIL if exceeded - survival_weight: 0.5 # weight of binary survival in composite score - funds_weight: 0.5 # weight of normalised final funds in composite score - db_dir: "/tmp/yc_bench_dbs" - company_name: "BenchCo" - start_date: "01/01/2025" # MM/DD/YYYY (yc-bench convention) - tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B" - use_wandb: true - wandb_name: "yc-bench" - ensure_scores_are_not_same: false - data_dir_to_save_evals: "environments/benchmarks/evals/yc-bench" - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-sonnet-4.6" - server_type: "openai" - health_check: false - # api_key loaded from OPENROUTER_API_KEY in .env diff --git a/environments/benchmarks/yc_bench/run_eval.sh b/environments/benchmarks/yc_bench/run_eval.sh deleted file mode 100755 index 0d793f53d54..00000000000 --- a/environments/benchmarks/yc_bench/run_eval.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -# YC-Bench Evaluation -# -# Requires: pip install "hermes-agent[yc-bench]" -# -# Run from repo root: -# bash environments/benchmarks/yc_bench/run_eval.sh -# -# Override model: -# bash environments/benchmarks/yc_bench/run_eval.sh \ -# --openai.model_name anthropic/claude-opus-4-20250514 -# -# Run a single preset: -# bash environments/benchmarks/yc_bench/run_eval.sh \ -# --env.presets '["fast_test"]' --env.seeds '[1]' - -set -euo pipefail - -mkdir -p logs evals/yc-bench -LOG_FILE="logs/yc_bench_$(date +%Y%m%d_%H%M%S).log" - -echo "YC-Bench Evaluation" -echo "Log: $LOG_FILE" -echo "" - -PYTHONUNBUFFERED=1 LOGLEVEL="${LOGLEVEL:-INFO}" \ - python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ - --config environments/benchmarks/yc_bench/default.yaml \ - "$@" \ - 2>&1 | tee "$LOG_FILE" - -echo "" -echo "Log saved to: $LOG_FILE" diff --git a/environments/benchmarks/yc_bench/yc_bench_env.py b/environments/benchmarks/yc_bench/yc_bench_env.py deleted file mode 100644 index 6e7be2c899b..00000000000 --- a/environments/benchmarks/yc_bench/yc_bench_env.py +++ /dev/null @@ -1,848 +0,0 @@ -""" -YCBenchEvalEnv -- YC-Bench Long-Horizon Agent Benchmark Environment - -Evaluates agentic LLMs on YC-Bench: a deterministic, long-horizon benchmark -where the agent acts as CEO of an AI startup over a simulated 1-3 year run. -The agent manages cash flow, employees, tasks, and prestige across 4 domains, -interacting exclusively via CLI subprocess calls against a SQLite-backed -discrete-event simulation. - -Unlike TerminalBench2 (per-task binary pass/fail), YC-Bench measures sustained -multi-turn strategic coherence -- whether an agent can manage compounding -decisions over hundreds of turns without going bankrupt. - -This is an eval-only environment. Run via: - - python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ - --config environments/benchmarks/yc_bench/default.yaml - -The evaluate flow: - 1. setup() -- Verifies yc-bench installed, builds eval matrix (preset x seed) - 2. evaluate() -- Iterates over all runs sequentially through: - a. rollout_and_score_eval() -- Per-run agent loop - - Initialises a fresh yc-bench simulation via `sim init` (NOT `run`) - - Runs HermesAgentLoop with terminal tool only - - Reads final SQLite DB to extract score - - Returns survival (0/1) + normalised funds score - b. Aggregates per-preset and overall metrics - c. Logs results via evaluate_log() and wandb - -Key features: - - CLI-only interface: agent calls yc-bench subcommands via terminal tool - - Deterministic: same seed + preset = same world (SHA256-based RNG) - - Multi-dimensional scoring: survival + normalised final funds - - Per-preset difficulty breakdown in results - - Isolated SQLite DB per run (no cross-run state leakage) - -Requires: pip install hermes-agent[yc-bench] -""" - -import asyncio -import datetime -import json -import logging -import math -import os -import sqlite3 -import subprocess -import sys -import threading -import time -import uuid -from collections import defaultdict -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -_repo_root = Path(__file__).resolve().parent.parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from pydantic import Field - -from atroposlib.envs.base import EvalHandlingEnum -from atroposlib.envs.server_handling.server_manager import APIServerConfig - -from environments.agent_loop import HermesAgentLoop -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig - -logger = logging.getLogger(__name__) - -# ============================================================================= -# System prompt -# ============================================================================= - -YC_BENCH_SYSTEM_PROMPT = """\ -You are the autonomous CEO of an early-stage AI startup in a deterministic -business simulation. You manage the company exclusively through the `yc-bench` -CLI tool. Your primary goal is to **survive** until the simulation horizon ends -without going bankrupt, while **maximising final funds**. - -## Simulation Mechanics - -- **Funds**: You start with $250,000 seed capital. Revenue comes from completing - tasks. Rewards scale with your prestige: `base × (1 + scale × (prestige − 1))`. -- **Domains**: There are 4 skill domains: **research**, **inference**, - **data_environment**, and **training**. Each has its own prestige level - (1.0-10.0). Higher prestige unlocks better-paying tasks. -- **Employees**: You have employees (Junior/Mid/Senior) with domain-specific - skill rates. **Throughput splits**: `effective_rate = base_rate / N` where N - is the number of active tasks assigned to that employee. Focus beats breadth. -- **Payroll**: Deducted automatically on the first business day of each month. - Running out of funds = bankruptcy = game over. -- **Time**: The simulation runs on business days (Mon-Fri), 09:00-18:00. - Time only advances when you call `yc-bench sim resume`. - -## Task Lifecycle - -1. Browse market tasks with `market browse` -2. Accept a task with `task accept` (this sets its deadline) -3. Assign employees with `task assign` -4. Dispatch with `task dispatch` to start work -5. Call `sim resume` to advance time and let employees make progress -6. Tasks complete when all domain requirements are fulfilled - -**Penalties for failure vary by difficulty preset.** Completing a task on time -earns full reward + prestige gain. Missing a deadline or cancelling a task -incurs prestige penalties -- cancelling is always more costly than letting a -task fail, so cancel only as a last resort. - -## CLI Commands - -### Observe -- `yc-bench company status` -- funds, prestige, runway -- `yc-bench employee list` -- skills, salary, active tasks -- `yc-bench market browse [--domain D] [--required-prestige-lte N]` -- available tasks -- `yc-bench task list [--status active|planned]` -- your tasks -- `yc-bench task inspect --task-id UUID` -- progress, deadline, assignments -- `yc-bench finance ledger [--category monthly_payroll|task_reward]` -- transaction history -- `yc-bench report monthly` -- monthly P&L - -### Act -- `yc-bench task accept --task-id UUID` -- accept from market -- `yc-bench task assign --task-id UUID --employee-id UUID` -- assign employee -- `yc-bench task dispatch --task-id UUID` -- start work (needs >=1 assignment) -- `yc-bench task cancel --task-id UUID --reason "text"` -- cancel (prestige penalty) -- `yc-bench sim resume` -- advance simulation clock - -### Memory (persists across context truncation) -- `yc-bench scratchpad read` -- read your persistent notes -- `yc-bench scratchpad write --content "text"` -- overwrite notes -- `yc-bench scratchpad append --content "text"` -- append to notes -- `yc-bench scratchpad clear` -- clear notes - -## Strategy Guidelines - -1. **Specialise in 2-3 domains** to climb the prestige ladder faster and unlock - high-reward tasks. Don't spread thin across all 4 domains early on. -2. **Focus employees** -- assigning one employee to many tasks halves their - throughput per additional task. Keep assignments concentrated. -3. **Use the scratchpad** to track your strategy, upcoming deadlines, and - employee assignments. This persists even if conversation context is truncated. -4. **Monitor runway** -- always know how many months of payroll you can cover. - Accept high-reward tasks before payroll dates. -5. **Don't over-accept** -- taking too many tasks and missing deadlines cascades - into prestige loss, locking you out of profitable contracts. -6. Use `finance ledger` and `report monthly` to track revenue trends. - -## Your Turn - -Each turn: -1. Call `yc-bench company status` and `yc-bench task list` to orient yourself. -2. Check for completed tasks and pending deadlines. -3. Browse market for profitable tasks within your prestige level. -4. Accept, assign, and dispatch tasks strategically. -5. Call `yc-bench sim resume` to advance time. -6. Repeat until the simulation ends. - -Think step by step before acting.""" - -# Starting funds in cents ($250,000) -INITIAL_FUNDS_CENTS = 25_000_000 - -# Default horizon per preset (years) -_PRESET_HORIZONS = { - "tutorial": 1, - "easy": 1, - "medium": 1, - "hard": 1, - "nightmare": 1, - "fast_test": 1, - "default": 3, - "high_reward": 1, -} - - -# ============================================================================= -# Configuration -# ============================================================================= - -class YCBenchEvalConfig(HermesAgentEnvConfig): - """ - Configuration for the YC-Bench evaluation environment. - - Extends HermesAgentEnvConfig with YC-Bench-specific settings for - preset selection, seed control, scoring, and simulation parameters. - """ - - presets: List[str] = Field( - default=["fast_test", "medium", "hard"], - description="YC-Bench preset names to evaluate.", - ) - seeds: List[int] = Field( - default=[1, 2, 3], - description="Random seeds -- each preset x seed = one run.", - ) - run_timeout: int = Field( - default=3600, - description="Maximum wall-clock seconds per run. Default 60 minutes.", - ) - survival_weight: float = Field( - default=0.5, - description="Weight of survival (0/1) in composite score.", - ) - funds_weight: float = Field( - default=0.5, - description="Weight of normalised final funds in composite score.", - ) - db_dir: str = Field( - default="/tmp/yc_bench_dbs", - description="Directory for per-run SQLite databases.", - ) - horizon_years: Optional[int] = Field( - default=None, - description=( - "Simulation horizon in years. If None (default), inferred from " - "preset name (1 year for most, 3 for 'default')." - ), - ) - company_name: str = Field( - default="BenchCo", - description="Name of the simulated company.", - ) - start_date: str = Field( - default="01/01/2025", - description="Simulation start date in MM/DD/YYYY format (yc-bench convention).", - ) - - -# ============================================================================= -# Scoring helpers -# ============================================================================= - -def _read_final_score(db_path: str) -> Dict[str, Any]: - """ - Read final game state from a YC-Bench SQLite database. - - Returns dict with final_funds_cents (int), survived (bool), - terminal_reason (str). - - Note: yc-bench table names are plural -- 'companies' not 'company', - 'sim_events' not 'simulation_log'. - """ - if not os.path.exists(db_path): - logger.warning("DB not found at %s", db_path) - return { - "final_funds_cents": 0, - "survived": False, - "terminal_reason": "db_missing", - } - - conn = None - try: - conn = sqlite3.connect(db_path) - cur = conn.cursor() - - # Read final funds from the 'companies' table - cur.execute("SELECT funds_cents FROM companies LIMIT 1") - row = cur.fetchone() - funds = row[0] if row else 0 - - # Determine terminal reason from 'sim_events' table - terminal_reason = "unknown" - try: - cur.execute( - "SELECT event_type FROM sim_events " - "WHERE event_type IN ('bankruptcy', 'horizon_end') " - "ORDER BY scheduled_at DESC LIMIT 1" - ) - event_row = cur.fetchone() - if event_row: - terminal_reason = event_row[0] - except sqlite3.OperationalError: - # Table may not exist if simulation didn't progress - pass - - survived = funds >= 0 and terminal_reason != "bankruptcy" - return { - "final_funds_cents": funds, - "survived": survived, - "terminal_reason": terminal_reason, - } - - except Exception as e: - logger.error("Failed to read DB %s: %s", db_path, e) - return { - "final_funds_cents": 0, - "survived": False, - "terminal_reason": f"db_error: {e}", - } - finally: - if conn: - conn.close() - - -def _compute_composite_score( - final_funds_cents: int, - survived: bool, - survival_weight: float = 0.5, - funds_weight: float = 0.5, - initial_funds_cents: int = INITIAL_FUNDS_CENTS, -) -> float: - """ - Compute composite score from survival and final funds. - - Score = survival_weight * survival_score - + funds_weight * normalised_funds_score - - Normalised funds uses log-scale relative to initial capital: - - funds <= 0: 0.0 - - funds == initial: ~0.15 - - funds == 10x: ~0.52 - - funds == 100x: 1.0 - """ - survival_score = 1.0 if survived else 0.0 - - if final_funds_cents <= 0: - funds_score = 0.0 - else: - max_ratio = 100.0 - ratio = final_funds_cents / max(initial_funds_cents, 1) - funds_score = min(math.log1p(ratio) / math.log1p(max_ratio), 1.0) - - return survival_weight * survival_score + funds_weight * funds_score - - -# ============================================================================= -# Main Environment -# ============================================================================= - -class YCBenchEvalEnv(HermesAgentBaseEnv): - """ - YC-Bench long-horizon agent benchmark environment (eval-only). - - Each eval item is a (preset, seed) pair. The environment initialises the - simulation via ``yc-bench sim init`` (NOT ``yc-bench run`` which would start - a competing built-in agent loop). The HermesAgentLoop then drives the - interaction by calling individual yc-bench CLI commands via the terminal tool. - - After the agent loop ends, the SQLite DB is read to extract the final score. - - Scoring: - composite = 0.5 * survival + 0.5 * normalised_funds - """ - - name = "yc-bench" - env_config_cls = YCBenchEvalConfig - - @classmethod - def config_init(cls) -> Tuple[YCBenchEvalConfig, List[APIServerConfig]]: - env_config = YCBenchEvalConfig( - enabled_toolsets=["terminal"], - disabled_toolsets=None, - distribution=None, - max_agent_turns=200, - max_token_length=32000, - agent_temperature=0.0, - system_prompt=YC_BENCH_SYSTEM_PROMPT, - terminal_backend="local", - terminal_timeout=60, - presets=["fast_test", "medium", "hard"], - seeds=[1, 2, 3], - run_timeout=3600, - survival_weight=0.5, - funds_weight=0.5, - db_dir="/tmp/yc_bench_dbs", - eval_handling=EvalHandlingEnum.STOP_TRAIN, - group_size=1, - steps_per_eval=1, - total_steps=1, - tokenizer_name="NousResearch/Hermes-3-Llama-3.1-8B", - use_wandb=True, - wandb_name="yc-bench", - ensure_scores_are_not_same=False, - ) - - server_configs = [ - APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-sonnet-4.6", - server_type="openai", - api_key=os.getenv("OPENROUTER_API_KEY", ""), - health_check=False, - ) - ] - - return env_config, server_configs - - # ========================================================================= - # Setup - # ========================================================================= - - async def setup(self): - """Verify yc-bench is installed and build the eval matrix.""" - # Verify yc-bench CLI is available - try: - result = subprocess.run( - ["yc-bench", "--help"], capture_output=True, text=True, timeout=10 - ) - if result.returncode != 0: - raise FileNotFoundError - except (FileNotFoundError, subprocess.TimeoutExpired): - raise RuntimeError( - "yc-bench CLI not found. Install with:\n" - ' pip install "hermes-agent[yc-bench]"\n' - "Or: git clone https://github.com/collinear-ai/yc-bench " - "&& cd yc-bench && pip install -e ." - ) - print("yc-bench CLI verified.") - - # Build eval matrix: preset x seed - self.all_eval_items = [ - {"preset": preset, "seed": seed} - for preset in self.config.presets - for seed in self.config.seeds - ] - self.iter = 0 - - os.makedirs(self.config.db_dir, exist_ok=True) - self.eval_metrics: List[Tuple[str, float]] = [] - - # Streaming JSONL log for crash-safe result persistence - log_dir = os.path.join(os.path.dirname(__file__), "logs") - os.makedirs(log_dir, exist_ok=True) - run_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - self._streaming_path = os.path.join(log_dir, f"samples_{run_ts}.jsonl") - self._streaming_file = open(self._streaming_path, "w", encoding="utf-8") - self._streaming_lock = threading.Lock() - - print(f"\nYC-Bench eval matrix: {len(self.all_eval_items)} runs") - for item in self.all_eval_items: - print(f" preset={item['preset']!r} seed={item['seed']}") - print(f"Streaming results to: {self._streaming_path}\n") - - def _save_result(self, result: Dict[str, Any]): - """Write a single run result to the streaming JSONL file immediately.""" - if not hasattr(self, "_streaming_file") or self._streaming_file.closed: - return - with self._streaming_lock: - self._streaming_file.write( - json.dumps(result, ensure_ascii=False, default=str) + "\n" - ) - self._streaming_file.flush() - - # ========================================================================= - # Training pipeline stubs (eval-only -- not used) - # ========================================================================= - - async def get_next_item(self): - item = self.all_eval_items[self.iter % len(self.all_eval_items)] - self.iter += 1 - return item - - def format_prompt(self, item: Dict[str, Any]) -> str: - preset = item["preset"] - seed = item["seed"] - return ( - f"A new YC-Bench simulation has been initialized " - f"(preset='{preset}', seed={seed}).\n" - f"Your company '{self.config.company_name}' is ready.\n\n" - "Begin by calling:\n" - "1. `yc-bench company status` -- see your starting funds and prestige\n" - "2. `yc-bench employee list` -- see your team and their skills\n" - "3. `yc-bench market browse --required-prestige-lte 1` -- find tasks " - "you can take\n\n" - "Then accept 2-3 tasks, assign employees, dispatch them, and call " - "`yc-bench sim resume` to advance time. Repeat this loop until the " - "simulation ends (horizon reached or bankruptcy)." - ) - - async def compute_reward(self, item, result, ctx) -> float: - return 0.0 - - async def collect_trajectories(self, item): - return None, [] - - async def score(self, rollout_group_data): - return None - - # ========================================================================= - # Per-run evaluation - # ========================================================================= - - async def rollout_and_score_eval(self, eval_item: Dict[str, Any]) -> Dict: - """ - Evaluate a single (preset, seed) run. - - 1. Sets DATABASE_URL and YC_BENCH_EXPERIMENT env vars - 2. Initialises the simulation via ``yc-bench sim init`` (NOT ``run``) - 3. Runs HermesAgentLoop with terminal tool - 4. Reads SQLite DB to compute final score - 5. Returns result dict with survival, funds, and composite score - """ - preset = eval_item["preset"] - seed = eval_item["seed"] - run_id = str(uuid.uuid4())[:8] - run_key = f"{preset}_seed{seed}_{run_id}" - - from tqdm import tqdm - tqdm.write(f" [START] preset={preset!r} seed={seed} (run_id={run_id})") - run_start = time.time() - - # Isolated DB per run -- prevents cross-run state leakage - db_path = os.path.join(self.config.db_dir, f"yc_bench_{run_key}.db") - os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" - os.environ["YC_BENCH_EXPERIMENT"] = preset - - # Determine horizon: explicit config override > preset lookup > default 1 - horizon = self.config.horizon_years or _PRESET_HORIZONS.get(preset, 1) - - try: - # ---------------------------------------------------------- - # Step 1: Initialise the simulation via CLI - # IMPORTANT: We use `sim init`, NOT `yc-bench run`. - # `yc-bench run` starts yc-bench's own LLM agent loop (via - # LiteLLM), which would compete with our HermesAgentLoop. - # `sim init` just sets up the world and returns. - # ---------------------------------------------------------- - init_cmd = [ - "yc-bench", "sim", "init", - "--seed", str(seed), - "--start-date", self.config.start_date, - "--company-name", self.config.company_name, - "--horizon-years", str(horizon), - ] - init_result = subprocess.run( - init_cmd, capture_output=True, text=True, timeout=30, - ) - if init_result.returncode != 0: - error_msg = (init_result.stderr or init_result.stdout).strip() - raise RuntimeError(f"yc-bench sim init failed: {error_msg}") - - tqdm.write(f" Simulation initialized (horizon={horizon}yr)") - - # ---------------------------------------------------------- - # Step 2: Run the HermesAgentLoop - # ---------------------------------------------------------- - tools, valid_names = self._resolve_tools_for_group() - - messages: List[Dict[str, Any]] = [ - {"role": "system", "content": YC_BENCH_SYSTEM_PROMPT}, - {"role": "user", "content": self.format_prompt(eval_item)}, - ] - - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=run_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - - # ---------------------------------------------------------- - # Step 3: Read final score from the simulation DB - # ---------------------------------------------------------- - score_data = _read_final_score(db_path) - final_funds = score_data["final_funds_cents"] - survived = score_data["survived"] - terminal_reason = score_data["terminal_reason"] - - composite = _compute_composite_score( - final_funds_cents=final_funds, - survived=survived, - survival_weight=self.config.survival_weight, - funds_weight=self.config.funds_weight, - ) - - elapsed = time.time() - run_start - status = "SURVIVED" if survived else "BANKRUPT" - if final_funds >= 0: - funds_str = f"${final_funds / 100:,.0f}" - else: - funds_str = f"-${abs(final_funds) / 100:,.0f}" - - tqdm.write( - f" [{status}] preset={preset!r} seed={seed} " - f"funds={funds_str} score={composite:.3f} " - f"turns={result.turns_used} ({elapsed:.0f}s)" - ) - - out = { - "preset": preset, - "seed": seed, - "survived": survived, - "final_funds_cents": final_funds, - "final_funds_usd": final_funds / 100, - "terminal_reason": terminal_reason, - "composite_score": composite, - "turns_used": result.turns_used, - "finished_naturally": result.finished_naturally, - "elapsed_seconds": elapsed, - "db_path": db_path, - "messages": result.messages, - } - self._save_result(out) - return out - - except Exception as e: - elapsed = time.time() - run_start - logger.error("Run %s failed: %s", run_key, e, exc_info=True) - tqdm.write( - f" [ERROR] preset={preset!r} seed={seed}: {e} ({elapsed:.0f}s)" - ) - out = { - "preset": preset, - "seed": seed, - "survived": False, - "final_funds_cents": 0, - "final_funds_usd": 0.0, - "terminal_reason": f"error: {e}", - "composite_score": 0.0, - "turns_used": 0, - "error": str(e), - "elapsed_seconds": elapsed, - } - self._save_result(out) - return out - - # ========================================================================= - # Evaluate - # ========================================================================= - - async def _run_with_timeout(self, item: Dict[str, Any]) -> Dict: - """Wrap a single rollout with a wall-clock timeout.""" - preset = item["preset"] - seed = item["seed"] - try: - return await asyncio.wait_for( - self.rollout_and_score_eval(item), - timeout=self.config.run_timeout, - ) - except asyncio.TimeoutError: - from tqdm import tqdm - tqdm.write( - f" [TIMEOUT] preset={preset!r} seed={seed} " - f"(exceeded {self.config.run_timeout}s)" - ) - out = { - "preset": preset, - "seed": seed, - "survived": False, - "final_funds_cents": 0, - "final_funds_usd": 0.0, - "terminal_reason": f"timeout ({self.config.run_timeout}s)", - "composite_score": 0.0, - "turns_used": 0, - "error": "timeout", - } - self._save_result(out) - return out - - async def evaluate(self, *args, **kwargs) -> None: - """ - Run YC-Bench evaluation over all (preset, seed) combinations. - - Runs sequentially -- each run is 100-500 turns, parallelising would - be prohibitively expensive and cause env var conflicts. - """ - start_time = time.time() - from tqdm import tqdm - - # --- tqdm-compatible logging handler (TB2 pattern) --- - class _TqdmHandler(logging.Handler): - def emit(self, record): - try: - tqdm.write(self.format(record)) - except Exception: - self.handleError(record) - - root = logging.getLogger() - handler = _TqdmHandler() - handler.setFormatter( - logging.Formatter("%(levelname)s %(name)s: %(message)s") - ) - root.handlers = [handler] - for noisy in ("httpx", "openai"): - logging.getLogger(noisy).setLevel(logging.WARNING) - - # --- Print config summary --- - print(f"\n{'='*60}") - print("Starting YC-Bench Evaluation") - print(f"{'='*60}") - print(f" Presets: {self.config.presets}") - print(f" Seeds: {self.config.seeds}") - print(f" Total runs: {len(self.all_eval_items)}") - print(f" Max turns/run: {self.config.max_agent_turns}") - print(f" Run timeout: {self.config.run_timeout}s") - print(f"{'='*60}\n") - - results = [] - pbar = tqdm( - total=len(self.all_eval_items), desc="YC-Bench", dynamic_ncols=True - ) - - try: - for item in self.all_eval_items: - result = await self._run_with_timeout(item) - results.append(result) - survived_count = sum(1 for r in results if r.get("survived")) - pbar.set_postfix_str( - f"survived={survived_count}/{len(results)}" - ) - pbar.update(1) - - except (KeyboardInterrupt, asyncio.CancelledError): - tqdm.write("\n[INTERRUPTED] Stopping evaluation...") - pbar.close() - try: - from tools.terminal_tool import cleanup_all_environments - cleanup_all_environments() - except Exception: - pass - if hasattr(self, "_streaming_file") and not self._streaming_file.closed: - self._streaming_file.close() - return - - pbar.close() - end_time = time.time() - - # --- Compute metrics --- - valid = [r for r in results if r is not None] - if not valid: - print("Warning: No valid results.") - return - - total = len(valid) - survived_total = sum(1 for r in valid if r.get("survived")) - survival_rate = survived_total / total if total else 0.0 - avg_score = ( - sum(r.get("composite_score", 0) for r in valid) / total - if total - else 0.0 - ) - - preset_results: Dict[str, List[Dict]] = defaultdict(list) - for r in valid: - preset_results[r["preset"]].append(r) - - eval_metrics = { - "eval/survival_rate": survival_rate, - "eval/avg_composite_score": avg_score, - "eval/total_runs": total, - "eval/survived_runs": survived_total, - "eval/evaluation_time_seconds": end_time - start_time, - } - - for preset, items in sorted(preset_results.items()): - ps = sum(1 for r in items if r.get("survived")) - pt = len(items) - pa = ( - sum(r.get("composite_score", 0) for r in items) / pt - if pt - else 0 - ) - key = preset.replace("-", "_") - eval_metrics[f"eval/survival_rate_{key}"] = ps / pt if pt else 0 - eval_metrics[f"eval/avg_score_{key}"] = pa - - self.eval_metrics = list(eval_metrics.items()) - - # --- Print summary --- - print(f"\n{'='*60}") - print("YC-Bench Evaluation Results") - print(f"{'='*60}") - print( - f"Overall survival rate: {survival_rate:.1%} " - f"({survived_total}/{total})" - ) - print(f"Average composite score: {avg_score:.4f}") - print(f"Evaluation time: {end_time - start_time:.1f}s") - - print("\nPer-preset breakdown:") - for preset, items in sorted(preset_results.items()): - ps = sum(1 for r in items if r.get("survived")) - pt = len(items) - pa = ( - sum(r.get("composite_score", 0) for r in items) / pt - if pt - else 0 - ) - print(f" {preset}: {ps}/{pt} survived avg_score={pa:.4f}") - for r in items: - status = "SURVIVED" if r.get("survived") else "BANKRUPT" - funds = r.get("final_funds_usd", 0) - print( - f" seed={r['seed']} [{status}] " - f"${funds:,.0f} " - f"score={r.get('composite_score', 0):.3f}" - ) - - print(f"{'='*60}\n") - - # --- Log results --- - samples = [ - {k: v for k, v in r.items() if k != "messages"} for r in valid - ] - - try: - await self.evaluate_log( - metrics=eval_metrics, - samples=samples, - start_time=start_time, - end_time=end_time, - generation_parameters={ - "temperature": self.config.agent_temperature, - "max_tokens": self.config.max_token_length, - "max_agent_turns": self.config.max_agent_turns, - }, - ) - except Exception as e: - print(f"Error logging results: {e}") - - # --- Cleanup (TB2 pattern) --- - if hasattr(self, "_streaming_file") and not self._streaming_file.closed: - self._streaming_file.close() - print(f"Results saved to: {self._streaming_path}") - - try: - from tools.terminal_tool import cleanup_all_environments - cleanup_all_environments() - except Exception: - pass - - try: - from environments.agent_loop import _tool_executor - _tool_executor.shutdown(wait=False, cancel_futures=True) - except Exception: - pass - - # ========================================================================= - # Wandb logging - # ========================================================================= - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None): - """Log YC-Bench-specific metrics to wandb.""" - if wandb_metrics is None: - wandb_metrics = {} - for k, v in self.eval_metrics: - wandb_metrics[k] = v - self.eval_metrics = [] - await super().wandb_log(wandb_metrics) - - -if __name__ == "__main__": - YCBenchEvalEnv.cli() diff --git a/environments/hermes_base_env.py b/environments/hermes_base_env.py deleted file mode 100644 index adefa9b7c3c..00000000000 --- a/environments/hermes_base_env.py +++ /dev/null @@ -1,714 +0,0 @@ -""" -HermesAgentBaseEnv -- Abstract Base Environment for Hermes-Agent + Atropos - -Provides the Atropos integration plumbing that all hermes-agent environments share: -- Two-mode operation (OpenAI server for Phase 1, VLLM ManagedServer for Phase 2) -- Per-group toolset/distribution resolution -- Agent loop orchestration via HermesAgentLoop -- ToolContext creation for reward functions -- ScoredDataGroup construction from ManagedServer state - -Subclasses only need to implement: - setup() -- Load dataset, initialize state - get_next_item() -- Return the next item from the dataset - format_prompt() -- Convert a dataset item into the user message - compute_reward() -- Score the rollout (has full ToolContext access) - evaluate() -- Periodic evaluation -""" - -import asyncio -import json -import logging -import os -import sys -import uuid -from abc import abstractmethod -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple, Union - -# Ensure the hermes-agent repo root is on sys.path so that imports like -# `from model_tools import ...` and `from environments.X import ...` work -# regardless of where the script is invoked from. -_repo_root = Path(__file__).resolve().parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from dotenv import load_dotenv -from pydantic import Field - -# Load API keys from hermes-agent/.env so all environments can access them -_env_path = _repo_root / ".env" -if _env_path.exists(): - load_dotenv(dotenv_path=_env_path) - -# Apply monkey patches for async-safe tool operation inside Atropos's event loop. -# This patches SwerexModalEnvironment to use a background thread instead of -# asyncio.run(), which would deadlock inside Atropos. Safe for normal CLI too. -from environments.patches import apply_patches -apply_patches() - -from atroposlib.envs.base import ( - BaseEnv, - BaseEnvConfig, - ScoredDataGroup, - ScoredDataItem, -) -from atroposlib.envs.server_handling.server_manager import ( - APIServerConfig, - ServerBaseline, - ServerManager, -) -from atroposlib.type_definitions import Item - -from environments.agent_loop import AgentResult, HermesAgentLoop -from environments.tool_context import ToolContext -from tools.budget_config import ( - DEFAULT_RESULT_SIZE_CHARS, - DEFAULT_TURN_BUDGET_CHARS, - DEFAULT_PREVIEW_SIZE_CHARS, -) - -# Import hermes-agent toolset infrastructure -from model_tools import get_tool_definitions -from toolset_distributions import sample_toolsets_from_distribution - -logger = logging.getLogger(__name__) - - -class HermesAgentEnvConfig(BaseEnvConfig): - """ - Configuration for hermes-agent Atropos environments. - - Extends BaseEnvConfig with agent-specific settings for toolsets, - terminal backend, dataset loading, and tool call parsing. - """ - - # --- Toolset configuration --- - # Mutually exclusive: use either enabled_toolsets OR distribution - enabled_toolsets: Optional[List[str]] = Field( - default=None, - description="Explicit list of hermes toolsets to enable (e.g., ['terminal', 'file', 'web']). " - "If None and distribution is also None, all available toolsets are enabled.", - ) - disabled_toolsets: Optional[List[str]] = Field( - default=None, - description="Toolsets to disable. Applied as a filter on top of enabled_toolsets or distribution.", - ) - distribution: Optional[str] = Field( - default=None, - description="Name of a toolset distribution from toolset_distributions.py " - "(e.g., 'development', 'terminal_tasks'). Sampled once per group. " - "Mutually exclusive with enabled_toolsets.", - ) - - # --- Agent loop configuration --- - max_agent_turns: int = Field( - default=30, - description="Maximum number of LLM calls (tool-calling iterations) per rollout.", - ) - system_prompt: Optional[str] = Field( - default=None, - description="System prompt for the agent. Tools are handled via the tools= parameter, " - "not embedded in the prompt text.", - ) - agent_temperature: float = Field( - default=1.0, - description="Sampling temperature for agent generation during rollouts.", - ) - - # --- Terminal backend --- - terminal_backend: str = Field( - default="local", - description="Terminal backend: 'local', 'docker', 'modal', 'daytona', 'ssh', 'singularity'. " - "Modal or Daytona recommended for production RL (cloud isolation per rollout).", - ) - terminal_timeout: int = Field( - default=120, - description="Per-command timeout in seconds for terminal tool calls. " - "Commands exceeding this are killed. Increase for tasks with long-running " - "commands (compilation, pip install, etc.).", - ) - terminal_lifetime: int = Field( - default=3600, - description="Sandbox inactivity lifetime in seconds. The cleanup thread kills " - "sandboxes that have been idle longer than this. Must be longer than " - "the longest gap between tool calls (e.g., waiting for LLM response).", - ) - - # --- Dataset --- - dataset_name: Optional[str] = Field( - default=None, - description="HuggingFace dataset name. Optional if tasks are defined inline.", - ) - dataset_split: str = Field( - default="train", - description="Dataset split to use.", - ) - prompt_field: str = Field( - default="prompt", - description="Which field in the dataset contains the prompt.", - ) - - # --- Thread pool --- - tool_pool_size: int = Field( - default=128, - description="Thread pool size for tool execution. Each concurrent task needs a " - "thread for tool calls. Must be large enough for parallel evaluation. " - "Too small = thread pool starvation.", - ) - - # --- Phase 2: Tool call parsing --- - tool_call_parser: str = Field( - default="hermes", - description="Tool call parser name for Phase 2 (VLLM server type). " - "Ignored in Phase 1 (OpenAI server type where VLLM parses natively). " - "Options: hermes, mistral, llama3_json, qwen, deepseek_v3, etc.", - ) - - # --- Tool result budget --- - # Defaults imported from tools.budget_config (single source of truth). - default_result_size_chars: int = Field( - default=DEFAULT_RESULT_SIZE_CHARS, - description="Default per-tool threshold (chars) for persisting large results " - "to sandbox. Results exceeding this are written to /tmp/hermes-results/ " - "and replaced with a preview. Per-tool registry values take precedence " - "unless overridden via tool_result_overrides.", - ) - turn_budget_chars: int = Field( - default=DEFAULT_TURN_BUDGET_CHARS, - description="Aggregate char budget per assistant turn. If all tool results " - "in a single turn exceed this, the largest are persisted to disk first.", - ) - preview_size_chars: int = Field( - default=DEFAULT_PREVIEW_SIZE_CHARS, - description="Size of the inline preview shown after a tool result is persisted.", - ) - tool_result_overrides: Optional[Dict[str, int]] = Field( - default=None, - description="Per-tool threshold overrides (chars). Keys are tool names, " - "values are char thresholds. Overrides both the default and registry " - "per-tool values. Example: {'terminal': 10000, 'search_files': 5000}. " - "Note: read_file is pinned to infinity and cannot be overridden.", - ) - - # --- Provider-specific parameters --- - # Passed as extra_body to the OpenAI client's chat.completions.create() call. - # Useful for OpenRouter provider preferences, transforms, route settings, etc. - # Example YAML: - # extra_body: - # provider: - # ignore: ["DeepInfra", "Fireworks"] - # order: ["Together"] - # transforms: ["middle-out"] - extra_body: Optional[Dict[str, Any]] = Field( - default=None, - description="Extra body parameters passed to the OpenAI client's " - "chat.completions.create(). Used for OpenRouter provider preferences, " - "transforms, and other provider-specific settings.", - ) - - def build_budget_config(self): - """Build a BudgetConfig from env config fields.""" - from tools.budget_config import BudgetConfig - return BudgetConfig( - default_result_size=self.default_result_size_chars, - turn_budget=self.turn_budget_chars, - preview_size=self.preview_size_chars, - tool_overrides=dict(self.tool_result_overrides) if self.tool_result_overrides else {}, - ) - - -class HermesAgentBaseEnv(BaseEnv): - """ - Abstract base environment for hermes-agent Atropos integration. - - Handles two modes of operation: - - Phase 1 (OpenAI server type): Uses server.chat_completion() directly. - The server (VLLM, SGLang, OpenRouter, OpenAI) handles tool call parsing - and reasoning extraction natively. DummyManagedServer provides placeholder - tokens. Good for SFT data gen, verifier testing, evaluation. - - - Phase 2 (VLLM server type): Uses ManagedServer for exact token IDs + logprobs - via /generate. Client-side tool call parser reconstructs structured tool_calls - from raw output. Full RL training capability. - - Subclasses must implement: - setup() -- Load dataset, initialize state - get_next_item() -- Return the next item to roll out - format_prompt() -- Convert a dataset item into the user message string - compute_reward() -- Score the rollout using ToolContext - evaluate() -- Periodic evaluation - """ - - name: Optional[str] = "hermes-agent" - env_config_cls = HermesAgentEnvConfig - - def __init__( - self, - config: HermesAgentEnvConfig, - server_configs: Union[ServerBaseline, List[APIServerConfig]], - slurm=False, - testing=False, - ): - super().__init__(config, server_configs, slurm, testing) - - # Set terminal environment variables so hermes tools pick them up. - # These can all be overridden per-environment via config fields instead - # of requiring users to set shell env vars. - if config.terminal_backend: - os.environ["TERMINAL_ENV"] = config.terminal_backend - os.environ["TERMINAL_TIMEOUT"] = str(config.terminal_timeout) - os.environ["TERMINAL_LIFETIME_SECONDS"] = str(config.terminal_lifetime) - print( - f"🖥️ Terminal: backend={config.terminal_backend}, " - f"timeout={config.terminal_timeout}s, lifetime={config.terminal_lifetime}s" - ) - - # Resize the agent loop's thread pool for tool execution. - # This must be large enough for the number of concurrent tasks - # (e.g., 89 parallel TB2 eval tasks each need a thread for tool calls). - from environments.agent_loop import resize_tool_pool - resize_tool_pool(config.tool_pool_size) - - # Set tool_parser on the ServerManager so ManagedServer uses it - # for bidirectional tool call translation (raw text ↔ OpenAI tool_calls). - if hasattr(self.server, 'tool_parser'): - self.server.tool_parser = config.tool_call_parser - print(f"🔧 Tool parser: {config.tool_call_parser}") - - # Current group's resolved tools (set in collect_trajectories) - self._current_group_tools: Optional[Tuple[List[Dict], Set[str]]] = None - - # Tool error tracking for wandb logging - self._tool_error_buffer: List[Dict[str, Any]] = [] - - # ========================================================================= - # Toolset resolution (per-group) - # ========================================================================= - - def _resolve_tools_for_group(self) -> Tuple[List[Dict[str, Any]], Set[str]]: - """ - Resolve toolsets for a group. Called once in collect_trajectories(), - then shared by all collect_trajectory() calls in the group. - - If distribution is set, samples probabilistically. - If enabled_toolsets is set, uses that explicit list. - disabled_toolsets is applied as a filter on top. - - Returns: - (tool_schemas, valid_tool_names) tuple - """ - config = self.config - - if config.distribution: - group_toolsets = sample_toolsets_from_distribution(config.distribution) - logger.info("Sampled toolsets from '%s': %s", config.distribution, group_toolsets) - else: - group_toolsets = config.enabled_toolsets # None means "all available" - if group_toolsets is None: - logger.warning( - "enabled_toolsets is None -- loading ALL tools including messaging. " - "Set explicit enabled_toolsets for RL training." - ) - - tools = get_tool_definitions( - enabled_toolsets=group_toolsets, - disabled_toolsets=config.disabled_toolsets, - quiet_mode=True, - ) - - valid_names = {t["function"]["name"] for t in tools} if tools else set() - logger.info("Resolved %d tools for group: %s", len(valid_names), sorted(valid_names)) - return tools, valid_names - - # ========================================================================= - # Server mode detection - # ========================================================================= - - def _use_managed_server(self) -> bool: - """ - Determine if we should use ManagedServer (Phase 2) or direct server (Phase 1). - - Phase 2 (ManagedServer) is used when the server type is 'vllm' or 'sglang', - which go through the /generate endpoint for exact token tracking. - - Phase 1 (direct server) is used for 'openai' server type, which uses - /v1/chat/completions with native tool call parsing. - """ - if not self.server.servers: - return False - - server = self.server.servers[0] - # If the server is an OpenAI server (not VLLM/SGLang), use direct mode - from atroposlib.envs.server_handling.openai_server import OpenAIServer - return not isinstance(server, OpenAIServer) - - # ========================================================================= - # Core Atropos integration - # ========================================================================= - - async def collect_trajectories( - self, item: Item - ) -> Tuple[ - Union[Optional[ScoredDataGroup], List[Optional[ScoredDataGroup]]], - List[Item], - ]: - """ - Override collect_trajectories to resolve toolsets once per group, - then delegate to the standard group-level collection. - - The default BaseEnv.collect_trajectories() calls collect_trajectory() - group_size times in parallel. We resolve tools once here and store - them for all those calls to use. - """ - # Resolve toolsets for this group (shared by all rollouts in the group) - self._current_group_tools = self._resolve_tools_for_group() - - # Delegate to the default implementation which calls collect_trajectory() - # group_size times via asyncio.gather - return await super().collect_trajectories(item) - - # ========================================================================= - # Wandb rollout display -- format trajectories nicely - # ========================================================================= - - @staticmethod - def _format_trajectory_for_display(messages: List[Dict[str, Any]]) -> str: - """ - Format a conversation's messages into a readable trajectory string - for wandb rollout tables. Shows tool calls, tool results, and reasoning - in a structured way instead of raw token decoding. - """ - parts = [] - for msg in messages: - role = msg.get("role", "unknown") - content = msg.get("content", "") - - if role == "system": - parts.append(f"[SYSTEM]\n{content}") - - elif role == "user": - parts.append(f"[USER]\n{content}") - - elif role == "assistant": - # Show reasoning if present - reasoning = msg.get("reasoning_content", "") - if reasoning: - # Truncate long reasoning for display - if len(reasoning) > 300: - reasoning = reasoning[:300] + "..." - parts.append(f"[ASSISTANT thinking]\n{reasoning}") - - # Show content - if content: - parts.append(f"[ASSISTANT]\n{content}") - - # Show tool calls - tool_calls = msg.get("tool_calls", []) - for tc in tool_calls: - func = tc.get("function", {}) - name = func.get("name", "?") - args = func.get("arguments", "{}") - # Truncate long arguments for display - if len(args) > 200: - args = args[:200] + "..." - parts.append(f"[TOOL CALL] {name}({args})") - - elif role == "tool": - tool_id = msg.get("tool_call_id", "") - result = content - # Truncate long tool results for display - if len(result) > 500: - result = result[:500] + "..." - parts.append(f"[TOOL RESULT] {result}") - - return "\n\n".join(parts) - - async def add_rollouts_for_wandb( - self, - scored_data, - item=None, - ): - """ - Override to show formatted trajectories with tool calls visible, - instead of raw token decoding which loses all structure. - """ - num_keep = self.config.num_rollouts_per_group_for_logging - if num_keep == -1: - num_keep = self.config.group_size - - group = [] - for i in range(min(num_keep, len(scored_data.get("scores", [])))): - score = scored_data["scores"][i] - - # Use messages if available for rich display - messages = None - if scored_data.get("messages") and i < len(scored_data["messages"]): - messages = scored_data["messages"][i] - - if messages: - text = self._format_trajectory_for_display(messages) - elif scored_data.get("tokens") and i < len(scored_data["tokens"]): - text = self.tokenizer.decode(scored_data["tokens"][i]) - else: - text = "(no data)" - - group.append((text, score)) - - self.rollouts_for_wandb.append(group) - if len(self.rollouts_for_wandb) > self.config.num_rollouts_to_keep: - self.rollouts_for_wandb.pop(0) - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None): - """Log base metrics including tool errors to wandb.""" - if wandb_metrics is None: - wandb_metrics = {} - - # Log tool error stats - if self._tool_error_buffer: - wandb_metrics["train/tool_errors_count"] = len(self._tool_error_buffer) - - # Log error details as a summary string (tables can crash wandb on tmp cleanup) - error_summaries = [] - for err in self._tool_error_buffer: - error_summaries.append( - f"[turn {err['turn']}] {err['tool']}({err['args'][:80]}) -> {err['error'][:150]}" - ) - wandb_metrics["train/tool_error_details"] = "\n".join(error_summaries) - - # Also print to stdout for immediate visibility - for summary in error_summaries: - print(f" Tool Error: {summary}") - - self._tool_error_buffer = [] - else: - wandb_metrics["train/tool_errors_count"] = 0 - - await super().wandb_log(wandb_metrics) - - async def collect_trajectory( - self, item: Item - ) -> Tuple[Optional[Union[ScoredDataItem, Any]], List[Item]]: - """ - Run a single rollout: agent loop + reward computation. - - This is called group_size times in parallel by collect_trajectories(). - Each call gets its own task_id for terminal/browser session isolation. - """ - task_id = str(uuid.uuid4()) - - # Get group-level tools (resolved once in collect_trajectories) - if self._current_group_tools is None: - # Fallback: resolve per-trajectory if called outside collect_trajectories - tools, valid_names = self._resolve_tools_for_group() - else: - tools, valid_names = self._current_group_tools - - # Build initial messages - messages: List[Dict[str, Any]] = [] - if self.config.system_prompt: - messages.append({"role": "system", "content": self.config.system_prompt}) - messages.append({"role": "user", "content": self.format_prompt(item)}) - - # Run the agent loop - result: AgentResult - if self._use_managed_server(): - # Phase 2: ManagedServer with ToolCallTranslator -- exact tokens + logprobs - # tool_parser is set on ServerManager in __init__ and passed through - # to ManagedServer, which uses ToolCallTranslator for bidirectional - # translation between raw text and OpenAI tool_calls. - try: - async with self.server.managed_server( - tokenizer=self.tokenizer, - preserve_think_blocks=bool(self.config.thinking_mode), - ) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - except NotImplementedError: - # DummyManagedServer not allowed -- fall back to Phase 1 - logger.warning( - "ManagedServer not available (OpenAI server?). " - "Falling back to direct server mode." - ) - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - else: - # Phase 1: OpenAI server -- native tool_calls, placeholder tokens - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=self.config.agent_temperature, - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - - # Skip reward computation if the agent loop produced no meaningful work - # (e.g., API call failed on turn 1). No point spinning up a Modal sandbox - # just to verify files that were never created. - only_system_and_user = all( - msg.get("role") in {"system", "user"} for msg in result.messages - ) - if result.turns_used == 0 or only_system_and_user: - logger.warning( - "Agent loop produced no output (turns=%d, msgs=%d). Skipping reward.", - result.turns_used, len(result.messages), - ) - reward = 0.0 - else: - # Compute reward using ToolContext (gives verifier full tool access) - ctx = ToolContext(task_id) - try: - reward = await self.compute_reward(item, result, ctx) - except Exception as e: - logger.error("compute_reward failed: %s", e) - reward = 0.0 - finally: - ctx.cleanup() - - # Track tool errors for wandb logging - if result.tool_errors: - for err in result.tool_errors: - self._tool_error_buffer.append({ - "turn": err.turn, - "tool": err.tool_name, - "args": err.arguments[:150], - "error": err.error[:300], - "result": err.tool_result[:300], - }) - - # Build ScoredDataItem from ManagedServer state - # Phase 2: real tokens/masks/logprobs from SequenceNodes - # Phase 1: placeholder tokens (still need a valid ScoredDataItem for the pipeline) - nodes = (result.managed_state or {}).get("nodes", []) - - if nodes: - # Phase 2 (or DummyManagedServer): use actual node data - node = nodes[-1] # Final sequence node = full trajectory - scored_item: Dict[str, Any] = { - "tokens": node.tokens, - "masks": node.masked_tokens, - "scores": reward, - } - - # Include logprobs if available (Phase 2) - if hasattr(node, "logprobs") and node.logprobs: - scored_item["advantages"] = None # Computed by trainer - scored_item["ref_logprobs"] = None - else: - # Phase 1 with no managed state: create placeholder tokens - # so the data pipeline doesn't break. These are NOT suitable - # for training but allow process mode (SFT data gen) to work. - # Tokenize the full conversation to get approximate tokens. - full_text = "\n".join( - msg.get("content", "") for msg in result.messages if msg.get("content") - ) - if self.tokenizer: - tokens = self.tokenizer.encode(full_text, add_special_tokens=True) - else: - tokens = list(range(min(len(full_text) // 4, 128))) - - scored_item = { - "tokens": tokens, - "masks": [-100] + tokens[1:], # Mask first token as prompt - "scores": reward, - } - - # Always include messages for wandb rollout display and data logging - scored_item["messages"] = result.messages - - return scored_item, [] - - # ========================================================================= - # Abstract methods -- subclasses must implement - # ========================================================================= - - @abstractmethod - async def setup(self): - """ - Load dataset, initialize state. - - Called once when the environment starts. Typical implementation: - self.dataset = load_dataset(self.config.dataset_name, split=self.config.dataset_split) - self.iter = 0 - """ - raise NotImplementedError - - @abstractmethod - async def get_next_item(self) -> Item: - """ - Return the next item from the dataset for rollout. - - Called by the base env's main loop to get items for workers. - Should cycle through the dataset. - """ - raise NotImplementedError - - @abstractmethod - def format_prompt(self, item: Item) -> str: - """ - Convert a dataset item into the user message for the agent. - - Args: - item: Dataset item (dict, tuple, etc.) - - Returns: - The prompt string to send to the agent - """ - raise NotImplementedError - - @abstractmethod - async def compute_reward( - self, item: Item, result: AgentResult, ctx: ToolContext - ) -> float: - """ - Score the rollout. Has full access to: - - item: the original dataset item (ground truth, test commands, etc.) - - result: AgentResult with full messages, turn count, reasoning, etc. - - ctx: ToolContext -- call ANY hermes-agent tool (terminal, file, web, - browser, vision...) scoped to this rollout's sandbox. Nothing - is off-limits. - - Args: - item: The dataset item that was rolled out - result: The agent's rollout result - ctx: ToolContext with full tool access for verification - - Returns: - Reward float (typically 0.0 to 1.0, but any float is valid) - """ - raise NotImplementedError - - @abstractmethod - async def evaluate(self, *args, **kwargs): - """ - Periodic evaluation. Called every steps_per_eval steps. - - Typical implementation runs the agent on a held-out eval set - and logs metrics via wandb/evaluate_log. - """ - raise NotImplementedError diff --git a/environments/hermes_swe_env/__init__.py b/environments/hermes_swe_env/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/hermes_swe_env/default.yaml b/environments/hermes_swe_env/default.yaml deleted file mode 100644 index 2d0113345f8..00000000000 --- a/environments/hermes_swe_env/default.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# SWE Environment -- Default Configuration -# -# SWE-bench style tasks with Modal sandboxes for cloud isolation. -# Uses terminal + file + web toolsets. -# -# Usage: -# python environments/hermes_swe_env/hermes_swe_env.py serve \ -# --config environments/hermes_swe_env/default.yaml - -env: - enabled_toolsets: ["terminal", "file", "web"] - max_agent_turns: 30 - max_token_length: 4096 - group_size: 4 - terminal_backend: "modal" - tool_call_parser: "hermes" - tokenizer_name: "NousResearch/DeepHermes-3-Llama-3-3B-Preview" - dataset_name: "bigcode/humanevalpack" - dataset_split: "test" - prompt_field: "prompt" - steps_per_eval: 50 - total_steps: 500 - use_wandb: true - wandb_name: "hermes-swe" - system_prompt: > - You are a skilled software engineer. You have access to a terminal, - file tools, and web search. Use these tools to complete the coding task. - Write clean, working code and verify it runs correctly before finishing. - -openai: - base_url: "http://localhost:8000/v1" - model_name: "NousResearch/DeepHermes-3-Llama-3-3B-Preview" - server_type: "openai" - api_key: "" diff --git a/environments/hermes_swe_env/hermes_swe_env.py b/environments/hermes_swe_env/hermes_swe_env.py deleted file mode 100644 index 49c521e5f76..00000000000 --- a/environments/hermes_swe_env/hermes_swe_env.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -HermesSweEnv -- SWE-Bench Style Environment with Modal Sandboxes - -A concrete environment for software engineering tasks where the model writes code -and the reward function runs tests to verify correctness. Uses Modal terminal -backend for cloud-isolated sandboxes per rollout. - -The reward function uses ToolContext.terminal() to run test commands in the same -Modal sandbox the model used during its agentic loop. All filesystem state from -the model's tool calls is preserved for verification. - -Usage: - # Phase 1: OpenAI server type - vllm serve YourModel --tool-parser hermes - run-api - python environments/hermes_swe_env.py serve \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name YourModel \\ - --openai.server_type openai \\ - --env.dataset_name bigcode/humanevalpack \\ - --env.terminal_backend modal - - # Phase 2: VLLM server type (full RL training) - python environments/hermes_swe_env.py serve \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name YourModel \\ - --openai.server_type vllm \\ - --env.tool_call_parser hermes \\ - --env.terminal_backend modal -""" - -import logging -import sys -import time -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union - -# Ensure repo root is on sys.path for imports -_repo_root = Path(__file__).resolve().parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from datasets import load_dataset - -from atroposlib.envs.base import ScoredDataGroup -from atroposlib.envs.server_handling.server_manager import APIServerConfig -from atroposlib.type_definitions import Item - -from environments.agent_loop import AgentResult -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from environments.tool_context import ToolContext - -logger = logging.getLogger(__name__) - - -class HermesSweEnvConfig(HermesAgentEnvConfig): - """Config with defaults for SWE-bench style tasks.""" - - pass # Inherits all fields, overrides defaults in config_init - - -class HermesSweEnv(HermesAgentBaseEnv): - """ - SWE-bench style environment using Modal terminal backend. - - The model gets a coding task, uses terminal + file + web tools to solve it, - and the reward function runs tests in the same Modal sandbox to verify. - - Subclass this for specific SWE datasets (HumanEval, SWE-bench, etc.) - and customize format_prompt() and compute_reward() as needed. - """ - - name = "hermes-swe" - env_config_cls = HermesSweEnvConfig - - @classmethod - def config_init(cls) -> Tuple[HermesSweEnvConfig, List[APIServerConfig]]: - """ - Default configuration for the SWE environment. - - Uses Modal terminal backend for cloud isolation and terminal + file + web toolsets. - """ - env_config = HermesSweEnvConfig( - # Toolsets: terminal for running code, file for reading/writing, web for docs - enabled_toolsets=["terminal", "file", "web"], - disabled_toolsets=None, - distribution=None, - # Agent settings -- SWE tasks need more turns - max_agent_turns=30, - max_token_length=4096, - agent_temperature=1.0, - system_prompt=( - "You are a skilled software engineer. You have access to a terminal, " - "file tools, and web search. Use these tools to complete the coding task. " - "Write clean, working code and verify it runs correctly before finishing." - ), - # Modal backend for cloud-isolated sandboxes - terminal_backend="modal", - # Dataset -- override via CLI for your specific SWE dataset - dataset_name="bigcode/humanevalpack", - dataset_split="test", - prompt_field="prompt", - # Atropos settings - group_size=4, - tokenizer_name="NousResearch/DeepHermes-3-Llama-3-3B-Preview", - tool_call_parser="hermes", - steps_per_eval=50, - total_steps=500, - use_wandb=True, - wandb_name="hermes-swe", - ) - - server_configs = [ - APIServerConfig( - base_url="http://localhost:8000/v1", - model_name="NousResearch/DeepHermes-3-Llama-3-3B-Preview", - server_type="openai", # Phase 1; switch to "vllm" for Phase 2 - api_key="", - ) - ] - - return env_config, server_configs - - async def setup(self): - """Load the SWE dataset.""" - if self.config.dataset_name: - self.dataset = load_dataset( - self.config.dataset_name, split=self.config.dataset_split - ) - else: - # Placeholder if no dataset specified - self.dataset = [] - self.iter = 0 - self.reward_buffer: List[float] = [] - - async def get_next_item(self) -> Dict[str, Any]: - """Cycle through the SWE dataset.""" - if not self.dataset: - raise ValueError("No dataset loaded. Set dataset_name in config.") - item = self.dataset[self.iter % len(self.dataset)] - self.iter += 1 - return item - - def format_prompt(self, item: Dict[str, Any]) -> str: - """ - Format the SWE task prompt. - - Override this in subclasses for different dataset formats. - Default assumes the dataset has a 'prompt' field and optionally a 'test' field. - """ - prompt = item.get(self.config.prompt_field, "") - - # If the dataset has test information, include it in the prompt - test_info = item.get("test", item.get("test_code", item.get("tests", ""))) - if test_info: - prompt += f"\n\nTests to pass:\n{test_info}" - - return prompt - - async def compute_reward( - self, item: Dict[str, Any], result: AgentResult, ctx: ToolContext - ) -> float: - """ - Score by running tests in the model's Modal sandbox. - - Default implementation: - - If the dataset item has a 'test' or 'test_code' field, run it - - Check exit code: 0 = pass, non-zero = fail - - Partial credit for file creation - - Override this in subclasses for more sophisticated reward logic. - """ - # Find the test command from the dataset item - test_code = item.get("test", item.get("test_code", item.get("tests", ""))) - - if test_code: - # Run the test in the model's sandbox - test_result = ctx.terminal( - f'cd /workspace && python3 -c "{test_code}"', timeout=60 - ) - - if test_result["exit_code"] == 0: - self.reward_buffer.append(1.0) - return 1.0 - - # Partial credit: check if the model created any Python files - file_check = ctx.terminal("find /workspace -name '*.py' -newer /tmp/.start_marker 2>/dev/null | head -5") - if file_check["exit_code"] == 0 and file_check.get("output", "").strip(): - self.reward_buffer.append(0.1) - return 0.1 - - self.reward_buffer.append(0.0) - return 0.0 - - async def evaluate(self, *args, **kwargs): - """ - Run evaluation on a held-out set. - - Override for dataset-specific evaluation logic. - """ - start_time = time.time() - end_time = time.time() - - eval_metrics = {"eval/placeholder": 0.0} - await self.evaluate_log( - metrics=eval_metrics, - start_time=start_time, - end_time=end_time, - ) - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None): - """Log SWE-specific metrics.""" - if wandb_metrics is None: - wandb_metrics = {} - - if self.reward_buffer: - wandb_metrics["train/avg_reward"] = sum(self.reward_buffer) / len( - self.reward_buffer - ) - wandb_metrics["train/pass_rate"] = sum( - 1 for r in self.reward_buffer if r == 1.0 - ) / len(self.reward_buffer) - self.reward_buffer = [] - - await super().wandb_log(wandb_metrics) - - -if __name__ == "__main__": - HermesSweEnv.cli() diff --git a/environments/patches.py b/environments/patches.py deleted file mode 100644 index a5afe751ece..00000000000 --- a/environments/patches.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Monkey patches for making hermes-agent tools work inside async frameworks (Atropos). - -Problem: - Some tools use asyncio.run() internally (e.g., Modal backend via SWE-ReX, - web_extract). This crashes when called from inside Atropos's event loop because - asyncio.run() can't be nested. - -Solution: - The Modal environment (tools/environments/modal.py) now uses a dedicated - _AsyncWorker thread internally, making it safe for both CLI and Atropos use. - No monkey-patching is required. - - This module is kept for backward compatibility. apply_patches() is a no-op. - -Usage: - Call apply_patches() once at import time (done automatically by hermes_base_env.py). - This is idempotent and safe to call multiple times. -""" - -import logging - -logger = logging.getLogger(__name__) - -_patches_applied = False - - -def apply_patches(): - """Apply all monkey patches needed for Atropos compatibility.""" - global _patches_applied - if _patches_applied: - return - - logger.debug("apply_patches() called; no patches needed (async safety is built-in)") - _patches_applied = True diff --git a/environments/terminal_test_env/__init__.py b/environments/terminal_test_env/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/environments/terminal_test_env/default.yaml b/environments/terminal_test_env/default.yaml deleted file mode 100644 index dc971071c3a..00000000000 --- a/environments/terminal_test_env/default.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Terminal Test Environment -- Default Configuration -# -# Simple file-creation tasks for validating the full Atropos + hermes-agent stack. -# Uses Modal terminal backend and OpenRouter (Claude) for inference. -# API keys loaded from ~/hermes-agent/.env -# -# Usage: -# run-api -# python environments/terminal_test_env/terminal_test_env.py serve \ -# --config environments/terminal_test_env/default.yaml - -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 10 - max_token_length: 2048 - group_size: 3 - total_steps: 3 - steps_per_eval: 3 - terminal_backend: "modal" - tool_call_parser: "hermes" - tokenizer_name: "NousResearch/DeepHermes-3-Llama-3-3B-Preview" - ensure_scores_are_not_same: false - use_wandb: false - system_prompt: > - You are a helpful assistant with access to a terminal and file tools. - Complete the user's request by using the available tools. - Be precise and follow instructions exactly. - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-opus-4.6" - server_type: "openai" - health_check: false - # api_key loaded from OPENROUTER_API_KEY in .env diff --git a/environments/terminal_test_env/terminal_test_env.py b/environments/terminal_test_env/terminal_test_env.py deleted file mode 100644 index 4d151ee7b76..00000000000 --- a/environments/terminal_test_env/terminal_test_env.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -TerminalTestEnv -- Simple Test Environment for Validating the Stack - -A self-contained environment with inline tasks (no external dataset needed). -Each task asks the model to create a file at a known path with specific content. -The reward verifier cats the file and checks if the content matches. - -Enables only terminal + file toolsets. Uses Modal terminal backend with -OpenRouter (Claude) by default. - -Training tasks (3): - 1. Create ~/greeting.txt with "Hello from Hermes Agent" - 2. Create ~/count.txt with numbers 1-5, one per line - 3. Create ~/answer.txt with the result of 123 + 456 - -Eval task (1): - 1. Create ~/result.txt with the result of 6 * 7 - -Usage: - # Start Atropos API server - run-api - - # Run environment (uses OpenRouter + Modal by default) - python environments/terminal_test_env.py serve - - # Process mode (no run-api needed, saves to JSONL) - python environments/terminal_test_env.py process \\ - --env.data_path_to_save_groups terminal_test_output.jsonl -""" - -import logging -import os -import sys -import time -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union - -# Ensure repo root is on sys.path for imports -_repo_root = Path(__file__).resolve().parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -from atroposlib.envs.base import ScoredDataGroup -from atroposlib.envs.server_handling.server_manager import APIServerConfig -from atroposlib.type_definitions import Item - -from environments.agent_loop import AgentResult -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from environments.tool_context import ToolContext - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Inline task definitions -- no external dataset needed -# ============================================================================= - -TRAIN_TASKS = [ - { - "prompt": "Create a file at ~/greeting.txt containing exactly the text: Hello from Hermes Agent", - "verify_path": "~/greeting.txt", - "expected_content": "Hello from Hermes Agent", - }, - { - "prompt": "Create a file at ~/count.txt containing the numbers 1 through 5, one per line", - "verify_path": "~/count.txt", - "expected_content": "1\n2\n3\n4\n5", - }, - { - "prompt": "Create a file at ~/answer.txt containing the result of 123 + 456", - "verify_path": "~/answer.txt", - "expected_content": "579", - }, -] - -EVAL_TASKS = [ - { - "prompt": "Create a file at ~/result.txt containing the result of 6 * 7", - "verify_path": "~/result.txt", - "expected_content": "42", - }, -] - - -class TerminalTestEnvConfig(HermesAgentEnvConfig): - """Config with defaults suitable for terminal testing.""" - - pass # Inherits all fields, overrides defaults in config_init - - -class TerminalTestEnv(HermesAgentBaseEnv): - """ - Simple test environment with inline file-creation tasks. - - All tasks follow the same pattern: "create a file at ~/X.txt with content Y". - The verifier runs `cat ~/X.txt` in the rollout's terminal and checks the output - against the expected string. Same verifier logic for all tasks. - - This environment is designed to validate the full stack end-to-end: - - Agent loop executes tool calls (terminal/file) - - ToolContext provides terminal access to the reward function - - Reward function verifies file content via cat - - Scored data flows through the Atropos pipeline - """ - - name = "terminal-test" - env_config_cls = TerminalTestEnvConfig - - @classmethod - def config_init(cls) -> Tuple[TerminalTestEnvConfig, List[APIServerConfig]]: - """ - Default configuration for the terminal test environment. - - Uses Modal terminal backend for cloud isolation and OpenRouter with - Claude for inference. API keys loaded from ~/hermes-agent/.env. - """ - env_config = TerminalTestEnvConfig( - # Terminal + file tools only - enabled_toolsets=["terminal", "file"], - disabled_toolsets=None, - distribution=None, - # Agent settings - max_agent_turns=10, # Simple tasks, don't need many turns - max_token_length=16000, - agent_temperature=1.0, - system_prompt=( - "You are a helpful assistant with access to a terminal and file tools. " - "Complete the user's request by using the available tools. " - "Be precise and follow instructions exactly." - ), - # Modal terminal backend for cloud-isolated sandboxes per rollout - terminal_backend="modal", - # Atropos settings - group_size=3, # 3 rollouts per group - tokenizer_name="NousResearch/q-30b-t-h45-e1", - tool_call_parser="hermes", - steps_per_eval=3, # Eval after all 3 steps - total_steps=3, # 3 groups total (1 group per step) - use_wandb=True, - wandb_name="terminal-test", - ensure_scores_are_not_same=False, # Allow all-same scores for simple tasks - # No external dataset - dataset_name=None, - ) - - # OpenRouter with Claude -- API key loaded from .env (OPENROUTER_API_KEY) - server_configs = [ - APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-opus-4.6", - server_type="openai", - api_key=os.getenv("OPENROUTER_API_KEY", ""), - health_check=False, # OpenRouter doesn't have a /health endpoint - ) - ] - - return env_config, server_configs - - async def setup(self): - """Initialize inline task lists.""" - self.train_tasks = list(TRAIN_TASKS) - self.eval_tasks = list(EVAL_TASKS) - self.iter = 0 - # Track reward stats for wandb logging - self.reward_buffer: List[float] = [] - - async def get_next_item(self) -> Dict[str, str]: - """Cycle through training tasks.""" - item = self.train_tasks[self.iter % len(self.train_tasks)] - self.iter += 1 - return item - - def format_prompt(self, item: Dict[str, str]) -> str: - """The prompt is directly in the task item.""" - return item["prompt"] - - async def compute_reward( - self, item: Dict[str, str], result: AgentResult, ctx: ToolContext - ) -> float: - """ - Verify by cat-ing the expected file path and checking content matches. - Same verifier for all tasks -- they all write a file at a known path. - - Scoring: - 1.0 = exact match - 0.5 = expected content is present but has extra stuff - 0.0 = file doesn't exist or content doesn't match - """ - verify_result = ctx.terminal(f"cat {item['verify_path']}") - - # File doesn't exist or can't be read - if verify_result["exit_code"] != 0: - self.reward_buffer.append(0.0) - return 0.0 - - actual = verify_result.get("output", "").strip() - expected = item["expected_content"].strip() - - # Exact match - if actual == expected: - self.reward_buffer.append(1.0) - return 1.0 - - # Partial credit: expected content is present but has extra stuff - if expected in actual: - self.reward_buffer.append(0.5) - return 0.5 - - self.reward_buffer.append(0.0) - return 0.0 - - async def evaluate(self, *args, **kwargs): - """ - Run eval tasks using the agent loop and verify results. - Logs accuracy metrics. - """ - start_time = time.time() - correct = 0 - total = len(self.eval_tasks) - samples = [] - - for eval_item in self.eval_tasks: - try: - # For eval, we do a simple single-turn completion (not full agent loop) - # to keep eval fast. The agent loop is tested via training. - completion = await self.server.chat_completion( - messages=[ - {"role": "system", "content": self.config.system_prompt or ""}, - {"role": "user", "content": eval_item["prompt"]}, - ], - n=1, - max_tokens=self.config.max_token_length, - temperature=0.0, - split="eval", - ) - - response_content = ( - completion.choices[0].message.content if completion.choices else "" - ) - - samples.append( - { - "prompt": eval_item["prompt"], - "response": response_content, - "expected": eval_item["expected_content"], - } - ) - - except Exception as e: - logger.error("Eval failed for item: %s", e) - samples.append( - { - "prompt": eval_item["prompt"], - "response": f"ERROR: {e}", - "expected": eval_item["expected_content"], - } - ) - - end_time = time.time() - - eval_metrics = { - "eval/num_samples": total, - } - - await self.evaluate_log( - metrics=eval_metrics, - samples=samples, - start_time=start_time, - end_time=end_time, - ) - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None): - """Log training metrics including reward stats and accuracy.""" - if wandb_metrics is None: - wandb_metrics = {} - - if self.reward_buffer: - total = len(self.reward_buffer) - correct = sum(1 for r in self.reward_buffer if r == 1.0) - partial = sum(1 for r in self.reward_buffer if r == 0.5) - - wandb_metrics["train/avg_reward"] = sum(self.reward_buffer) / total - wandb_metrics["train/accuracy"] = correct / total - wandb_metrics["train/partial_match_rate"] = partial / total - wandb_metrics["train/total_rollouts"] = total - self.reward_buffer = [] - - await super().wandb_log(wandb_metrics) - - -if __name__ == "__main__": - TerminalTestEnv.cli() diff --git a/environments/tool_call_parsers/__init__.py b/environments/tool_call_parsers/__init__.py deleted file mode 100644 index 8bff3f9d1f0..00000000000 --- a/environments/tool_call_parsers/__init__.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Tool Call Parser Registry - -Client-side parsers that extract structured tool_calls from raw model output text. -Used in Phase 2 (VLLM server type) where ManagedServer's /generate endpoint returns -raw text without tool call parsing. - -Each parser is a standalone reimplementation of the corresponding VLLM parser's -non-streaming extract_tool_calls() logic. No VLLM dependency -- only standard library -(re, json, uuid) and openai types. - -Usage: - from environments.tool_call_parsers import get_parser - - parser = get_parser("hermes") - content, tool_calls = parser.parse(raw_model_output) - # content = text with tool call markup stripped - # tool_calls = list of ChatCompletionMessageToolCall objects, or None -""" - -import logging -from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Tuple, Type - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, -) - -logger = logging.getLogger(__name__) - -# Type alias for parser return value -ParseResult = Tuple[Optional[str], Optional[List[ChatCompletionMessageToolCall]]] - - -class ToolCallParser(ABC): - """ - Base class for tool call parsers. - - Each parser knows how to extract structured tool_calls from a specific - model family's raw output text format. - """ - - @abstractmethod - def parse(self, text: str) -> ParseResult: - """ - Parse raw model output text for tool calls. - - Args: - text: Raw decoded text from the model's completion - - Returns: - Tuple of (content, tool_calls) where: - - content: text with tool call markup stripped (the message 'content' field), - or None if the entire output was tool calls - - tool_calls: list of ChatCompletionMessageToolCall objects, - or None if no tool calls were found - """ - raise NotImplementedError - - -# Global parser registry: name -> parser class -PARSER_REGISTRY: Dict[str, Type[ToolCallParser]] = {} - - -def register_parser(name: str): - """ - Decorator to register a parser class under a given name. - - Usage: - @register_parser("hermes") - class HermesToolCallParser(ToolCallParser): - ... - """ - - def decorator(cls: Type[ToolCallParser]) -> Type[ToolCallParser]: - PARSER_REGISTRY[name] = cls - return cls - - return decorator - - -def get_parser(name: str) -> ToolCallParser: - """ - Get a parser instance by name. - - Args: - name: Parser name (e.g., "hermes", "mistral", "llama3_json") - - Returns: - Instantiated parser - - Raises: - KeyError: If parser name is not found in registry - """ - if name not in PARSER_REGISTRY: - available = sorted(PARSER_REGISTRY.keys()) - raise KeyError( - f"Tool call parser '{name}' not found. Available parsers: {available}" - ) - return PARSER_REGISTRY[name]() - - -def list_parsers() -> List[str]: - """Return sorted list of registered parser names.""" - return sorted(PARSER_REGISTRY.keys()) - - -# Import all parser modules to trigger registration via @register_parser decorators -# Each module registers itself when imported -from environments.tool_call_parsers.hermes_parser import HermesToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.longcat_parser import LongcatToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.mistral_parser import MistralToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.llama_parser import LlamaToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.qwen_parser import QwenToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.deepseek_v3_parser import DeepSeekV3ToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.deepseek_v3_1_parser import DeepSeekV31ToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.kimi_k2_parser import KimiK2ToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.glm45_parser import Glm45ToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.glm47_parser import Glm47ToolCallParser # noqa: E402, F401 -from environments.tool_call_parsers.qwen3_coder_parser import Qwen3CoderToolCallParser # noqa: E402, F401 diff --git a/environments/tool_call_parsers/deepseek_v3_1_parser.py b/environments/tool_call_parsers/deepseek_v3_1_parser.py deleted file mode 100644 index 8456990c6ad..00000000000 --- a/environments/tool_call_parsers/deepseek_v3_1_parser.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -DeepSeek V3.1 tool call parser. - -Similar to V3 but with a slightly different format: - <|tool▁call▁begin|>function_name<|tool▁sep|>arguments<|tool▁call▁end|> - -Note: V3 has type+name before the separator, V3.1 has name before and args after. - -Based on VLLM's DeepSeekV31ToolParser.extract_tool_calls() -""" - -import re -import uuid -from typing import List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -@register_parser("deepseek_v3_1") -@register_parser("deepseek_v31") -class DeepSeekV31ToolCallParser(ToolCallParser): - """ - Parser for DeepSeek V3.1 tool calls. - - Slightly different regex than V3: function_name comes before the separator, - arguments come after (no type field, no json code block wrapper). - """ - - START_TOKEN = "<|tool▁calls▁begin|>" - - # Regex captures: function_name, function_arguments - PATTERN = re.compile( - r"<|tool▁call▁begin|>(?P.*?)<|tool▁sep|>(?P.*?)<|tool▁call▁end|>", - re.DOTALL, - ) - - def parse(self, text: str) -> ParseResult: - if self.START_TOKEN not in text: - return text, None - - try: - matches = self.PATTERN.findall(text) - if not matches: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - for match in matches: - func_name, func_args = match - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function( - name=func_name.strip(), - arguments=func_args.strip(), - ), - ) - ) - - if not tool_calls: - return text, None - - content = text[: text.find(self.START_TOKEN)].strip() - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/deepseek_v3_parser.py b/environments/tool_call_parsers/deepseek_v3_parser.py deleted file mode 100644 index 61d23d5fecc..00000000000 --- a/environments/tool_call_parsers/deepseek_v3_parser.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -DeepSeek V3 tool call parser. - -Format uses special unicode tokens: - <|tool▁calls▁begin|> - <|tool▁call▁begin|>type<|tool▁sep|>function_name - ```json - {"arg": "value"} - ``` - <|tool▁call▁end|> - <|tool▁calls▁end|> - -Fixes Issue #989: Support for multiple simultaneous tool calls. -""" - -import re -import uuid -import logging -from typing import List, Optional, Tuple - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - -logger = logging.getLogger(__name__) - -@register_parser("deepseek_v3") -class DeepSeekV3ToolCallParser(ToolCallParser): - """ - Parser for DeepSeek V3 tool calls. - - Uses special unicode tokens with fullwidth angle brackets and block elements. - Extracts type, function name, and JSON arguments from the structured format. - Ensures all tool calls are captured when the model executes multiple actions. - """ - - START_TOKEN = "<|tool▁calls▁begin|>" - - # Updated PATTERN: Using \s* instead of literal \n for increased robustness - # against variations in model formatting (Issue #989). - PATTERN = re.compile( - r"<|tool▁call▁begin|>(?P.*?)<|tool▁sep|>(?P.*?)\s*```json\s*(?P.*?)\s*```\s*<|tool▁call▁end|>", - re.DOTALL, - ) - - def parse(self, text: str) -> ParseResult: - """ - Parses the input text and extracts all available tool calls. - """ - if self.START_TOKEN not in text: - return text, None - - try: - # Using finditer to capture ALL tool calls in the sequence - matches = list(self.PATTERN.finditer(text)) - if not matches: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - - for match in matches: - func_name = match.group("function_name").strip() - func_args = match.group("function_arguments").strip() - - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function( - name=func_name, - arguments=func_args, - ), - ) - ) - - if tool_calls: - # Content is text before the first tool call block - content_index = text.find(self.START_TOKEN) - content = text[:content_index].strip() - return content if content else None, tool_calls - - return text, None - - except Exception as e: - logger.error(f"Error parsing DeepSeek V3 tool calls: {e}") - return text, None diff --git a/environments/tool_call_parsers/glm45_parser.py b/environments/tool_call_parsers/glm45_parser.py deleted file mode 100644 index e92e29881f1..00000000000 --- a/environments/tool_call_parsers/glm45_parser.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -GLM 4.5 (GLM-4-MoE) tool call parser. - -Format uses custom arg_key/arg_value tags rather than standard JSON: - function_name - param1value1 - param2value2 - - -Values are deserialized using json.loads -> ast.literal_eval -> raw string fallback. - -Based on VLLM's Glm4MoeModelToolParser.extract_tool_calls() -""" - -import ast -import json -import re -import uuid -from typing import Any, Dict, List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -def _deserialize_value(value: str) -> Any: - """ - Try to deserialize a string value to its native Python type. - Attempts json.loads, then ast.literal_eval, then returns raw string. - """ - try: - return json.loads(value) - except (json.JSONDecodeError, TypeError): - pass - - try: - return ast.literal_eval(value) - except (ValueError, SyntaxError, TypeError): - pass - - return value - - -@register_parser("glm45") -class Glm45ToolCallParser(ToolCallParser): - """ - Parser for GLM 4.5 (GLM-4-MoE) tool calls. - - Uses ... tags with / pairs - instead of standard JSON arguments. - """ - - FUNC_CALL_REGEX = re.compile(r".*?", re.DOTALL) - FUNC_DETAIL_REGEX = re.compile(r"([^\n]*)\n(.*)", re.DOTALL) - FUNC_ARG_REGEX = re.compile( - r"(.*?)\s*(.*?)", re.DOTALL - ) - - START_TOKEN = "" - - def parse(self, text: str) -> ParseResult: - if self.START_TOKEN not in text: - return text, None - - try: - matched_calls = self.FUNC_CALL_REGEX.findall(text) - if not matched_calls: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - - for match in matched_calls: - detail = self.FUNC_DETAIL_REGEX.search(match) - if not detail: - continue - - func_name = detail.group(1).strip() - func_args_raw = detail.group(2) - - # Parse arg_key/arg_value pairs - pairs = self.FUNC_ARG_REGEX.findall(func_args_raw) if func_args_raw else [] - arg_dict: Dict[str, Any] = {} - for key, value in pairs: - arg_key = key.strip() - arg_val = _deserialize_value(value.strip()) - arg_dict[arg_key] = arg_val - - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function( - name=func_name, - arguments=json.dumps(arg_dict, ensure_ascii=False), - ), - ) - ) - - if not tool_calls: - return text, None - - content = text[: text.find(self.START_TOKEN)].strip() - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/glm47_parser.py b/environments/tool_call_parsers/glm47_parser.py deleted file mode 100644 index 6631cf842ce..00000000000 --- a/environments/tool_call_parsers/glm47_parser.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -GLM 4.7 tool call parser. - -Same as GLM 4.5 but with slightly different regex patterns. -The tool_call tags may wrap differently and arg parsing handles -newlines between key/value pairs. - -Based on VLLM's Glm47MoeModelToolParser (extends Glm4MoeModelToolParser). -""" - -import re - -from environments.tool_call_parsers import ParseResult, register_parser -from environments.tool_call_parsers.glm45_parser import Glm45ToolCallParser - - -@register_parser("glm47") -class Glm47ToolCallParser(Glm45ToolCallParser): - """ - Parser for GLM 4.7 tool calls. - Extends GLM 4.5 with updated regex patterns. - """ - - def __init__(self): - super().__init__() - # GLM 4.7 uses a slightly different detail regex that includes - # the wrapper and optional arg_key content - self.FUNC_DETAIL_REGEX = re.compile( - r"(.*?)(.*?)?", re.DOTALL - ) - # GLM 4.7 handles newlines between arg_key and arg_value tags - self.FUNC_ARG_REGEX = re.compile( - r"(.*?)(?:\\n|\s)*(.*?)", - re.DOTALL, - ) diff --git a/environments/tool_call_parsers/hermes_parser.py b/environments/tool_call_parsers/hermes_parser.py deleted file mode 100644 index c6f911db04a..00000000000 --- a/environments/tool_call_parsers/hermes_parser.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Hermes tool call parser. - -Format: {"name": "func", "arguments": {...}} -Based on VLLM's Hermes2ProToolParser.extract_tool_calls() -""" - -import json -import re -import uuid -from typing import List, Optional, Tuple - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -@register_parser("hermes") -class HermesToolCallParser(ToolCallParser): - """ - Parser for Hermes-format tool calls. - - Matches ... tags containing JSON with "name" and "arguments". - Also handles unclosed at end-of-string (truncated generation). - """ - - # Matches both closed and unclosed tool_call tags - PATTERN = re.compile( - r"\s*(.*?)\s*|\s*(.*)", re.DOTALL - ) - - def parse(self, text: str) -> ParseResult: - if "" not in text: - return text, None - - try: - matches = self.PATTERN.findall(text) - if not matches: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - for match in matches: - # match is a tuple: (closed_content, unclosed_content) - raw_json = match[0] if match[0] else match[1] - if not raw_json.strip(): - continue - - tc_data = json.loads(raw_json) - if "name" not in tc_data: - continue - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function( - name=tc_data["name"], - arguments=json.dumps( - tc_data.get("arguments", {}), ensure_ascii=False - ), - ), - ) - ) - - if not tool_calls: - return text, None - - # Content is everything before the first tag - content = text[: text.find("")].strip() - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/kimi_k2_parser.py b/environments/tool_call_parsers/kimi_k2_parser.py deleted file mode 100644 index 29f40fc2435..00000000000 --- a/environments/tool_call_parsers/kimi_k2_parser.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Kimi K2 tool call parser. - -Format: - <|tool_calls_section_begin|> - <|tool_call_begin|>function_id:0<|tool_call_argument_begin|>{"arg": "val"}<|tool_call_end|> - <|tool_calls_section_end|> - -The function_id format is typically "functions.func_name:index" or "func_name:index". - -Based on VLLM's KimiK2ToolParser.extract_tool_calls() -""" - -import re -import uuid -from typing import List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -@register_parser("kimi_k2") -class KimiK2ToolCallParser(ToolCallParser): - """ - Parser for Kimi K2 tool calls. - - Uses section begin/end tokens wrapping individual tool call begin/end tokens. - The tool_call_id contains the function name (after last dot, before colon). - """ - - # Support both singular and plural variants - START_TOKENS = [ - "<|tool_calls_section_begin|>", - "<|tool_call_section_begin|>", - ] - - # Regex captures: tool_call_id (e.g., "functions.get_weather:0"), function_arguments - PATTERN = re.compile( - r"<\|tool_call_begin\|>\s*(?P[^<]+:\d+)\s*" - r"<\|tool_call_argument_begin\|>\s*" - r"(?P(?:(?!<\|tool_call_begin\|>).)*?)\s*" - r"<\|tool_call_end\|>", - re.DOTALL, - ) - - def parse(self, text: str) -> ParseResult: - # Check for any variant of the start token - has_start = any(token in text for token in self.START_TOKENS) - if not has_start: - return text, None - - try: - matches = self.PATTERN.findall(text) - if not matches: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - for match in matches: - function_id, function_args = match - - # Extract function name from ID format: "functions.get_weather:0" -> "get_weather" - function_name = function_id.split(":")[0].split(".")[-1] - - tool_calls.append( - ChatCompletionMessageToolCall( - id=function_id, # Preserve the original ID format - type="function", - function=Function( - name=function_name, - arguments=function_args.strip(), - ), - ) - ) - - if not tool_calls: - return text, None - - # Content is everything before the tool calls section - earliest_start = len(text) - for token in self.START_TOKENS: - idx = text.find(token) - if idx >= 0 and idx < earliest_start: - earliest_start = idx - - content = text[:earliest_start].strip() - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/llama_parser.py b/environments/tool_call_parsers/llama_parser.py deleted file mode 100644 index 8eb2136a11a..00000000000 --- a/environments/tool_call_parsers/llama_parser.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Llama 3.x / 4 tool call parser. - -Format: The model outputs JSON objects with "name" and "arguments" (or "parameters") keys. -May be preceded by <|python_tag|> token. Supports multiple JSON objects separated -by content or semicolons. - -Based on VLLM's Llama3JsonToolParser.extract_tool_calls() -""" - -import json -import re -import uuid -from typing import List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -@register_parser("llama3_json") -@register_parser("llama4_json") -class LlamaToolCallParser(ToolCallParser): - """ - Parser for Llama 3.x and 4 JSON-format tool calls. - - Finds JSON objects containing "name" + ("arguments" or "parameters") keys. - Uses Python's json.JSONDecoder.raw_decode for robust extraction of - JSON objects from mixed text. - """ - - BOT_TOKEN = "<|python_tag|>" - - # Regex to find the start of potential JSON objects - JSON_START = re.compile(r"\{") - - def parse(self, text: str) -> ParseResult: - # Quick check: need either the bot token or a JSON brace - if self.BOT_TOKEN not in text and "{" not in text: - return text, None - - try: - decoder = json.JSONDecoder() - tool_calls: List[ChatCompletionMessageToolCall] = [] - end_index = -1 # Track where the last parsed JSON ended - - for match in self.JSON_START.finditer(text): - start = match.start() - # Skip if this brace is inside a previously parsed JSON object - if start <= end_index: - continue - - try: - obj, json_end = decoder.raw_decode(text[start:]) - end_index = start + json_end - - # Must have "name" and either "arguments" or "parameters" - name = obj.get("name") - args = obj.get("arguments", obj.get("parameters")) - - if not name or args is None: - continue - - # Normalize arguments to JSON string - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) - elif not isinstance(args, str): - args = json.dumps(args, ensure_ascii=False) - - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function(name=name, arguments=args), - ) - ) - except (json.JSONDecodeError, KeyError, ValueError): - continue - - if not tool_calls: - return text, None - - # Content is everything before the first tool call JSON - # Find where the first tool call starts in the text - first_tc_start = text.find("{") - if self.BOT_TOKEN in text: - first_tc_start = text.find(self.BOT_TOKEN) - content = text[:first_tc_start].strip() if first_tc_start > 0 else None - - return content, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/longcat_parser.py b/environments/tool_call_parsers/longcat_parser.py deleted file mode 100644 index afecdb86292..00000000000 --- a/environments/tool_call_parsers/longcat_parser.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Longcat Flash Chat tool call parser. - -Same as Hermes but uses tags instead of . -Based on VLLM's LongcatFlashToolParser (extends Hermes2ProToolParser). -""" - -import json -import re -import uuid -from typing import List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -@register_parser("longcat") -class LongcatToolCallParser(ToolCallParser): - """ - Parser for Longcat Flash Chat tool calls. - Identical logic to Hermes, just different tag names. - """ - - PATTERN = re.compile( - r"\s*(.*?)\s*|\s*(.*)", - re.DOTALL, - ) - - def parse(self, text: str) -> ParseResult: - if "" not in text: - return text, None - - try: - matches = self.PATTERN.findall(text) - if not matches: - return text, None - - tool_calls: List[ChatCompletionMessageToolCall] = [] - for match in matches: - raw_json = match[0] if match[0] else match[1] - if not raw_json.strip(): - continue - - tc_data = json.loads(raw_json) - tool_calls.append( - ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:8]}", - type="function", - function=Function( - name=tc_data["name"], - arguments=json.dumps( - tc_data.get("arguments", {}), ensure_ascii=False - ), - ), - ) - ) - - if not tool_calls: - return text, None - - content = text[: text.find("")].strip() - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/mistral_parser.py b/environments/tool_call_parsers/mistral_parser.py deleted file mode 100644 index a23684e8739..00000000000 --- a/environments/tool_call_parsers/mistral_parser.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Mistral tool call parser. - -Supports two formats depending on tokenizer version: -- Pre-v11: content[TOOL_CALLS] [{"name": ..., "arguments": {...}}, ...] -- v11+: content[TOOL_CALLS]tool_name1{"arg": "val"}[TOOL_CALLS]tool_name2{"arg": "val"} - -Based on VLLM's MistralToolParser.extract_tool_calls() -The [TOOL_CALLS] token is the bot_token used by Mistral models. -""" - -import json -import uuid -from typing import List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -def _generate_mistral_id() -> str: - """Mistral tool call IDs are 9-char alphanumeric strings.""" - import random - import string - - return "".join(random.choices(string.ascii_letters + string.digits, k=9)) - - -@register_parser("mistral") -class MistralToolCallParser(ToolCallParser): - """ - Parser for Mistral-format tool calls. - - Detects format by checking if the content after [TOOL_CALLS] starts with '[' - (pre-v11 JSON array) or with a tool name (v11+ format). - """ - - # The [TOOL_CALLS] token -- may appear as different strings depending on tokenizer - BOT_TOKEN = "[TOOL_CALLS]" - - def parse(self, text: str) -> ParseResult: - if self.BOT_TOKEN not in text: - return text, None - - try: - parts = text.split(self.BOT_TOKEN) - content = parts[0].strip() - raw_tool_calls = parts[1:] - - # Detect format: if the first raw part starts with '[', it's pre-v11 - first_raw = raw_tool_calls[0].strip() if raw_tool_calls else "" - is_pre_v11 = first_raw.startswith("[") or first_raw.startswith("{") - - tool_calls: List[ChatCompletionMessageToolCall] = [] - - if not is_pre_v11: - # v11+ format: [TOOL_CALLS]tool_name{args}[TOOL_CALLS]tool_name2{args2} - for raw in raw_tool_calls: - raw = raw.strip() - if not raw or "{" not in raw: - continue - - brace_idx = raw.find("{") - tool_name = raw[:brace_idx].strip() - args_str = raw[brace_idx:] - - # Validate and clean the JSON arguments - try: - parsed_args = json.loads(args_str) - args_str = json.dumps(parsed_args, ensure_ascii=False) - except json.JSONDecodeError: - pass # Keep raw if parsing fails - - tool_calls.append( - ChatCompletionMessageToolCall( - id=_generate_mistral_id(), - type="function", - function=Function(name=tool_name, arguments=args_str), - ) - ) - else: - # Pre-v11 format: [TOOL_CALLS] [{"name": ..., "arguments": {...}}] - try: - parsed = json.loads(first_raw) - if isinstance(parsed, dict): - parsed = [parsed] - - for tc in parsed: - if "name" not in tc: - continue - args = tc.get("arguments", {}) - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) - - tool_calls.append( - ChatCompletionMessageToolCall( - id=_generate_mistral_id(), - type="function", - function=Function( - name=tc["name"], arguments=args - ), - ) - ) - except json.JSONDecodeError: - # Fallback: extract JSON objects using raw_decode - decoder = json.JSONDecoder() - idx = 0 - while idx < len(first_raw): - try: - obj, end_idx = decoder.raw_decode(first_raw, idx) - if isinstance(obj, dict) and "name" in obj: - args = obj.get("arguments", {}) - if isinstance(args, dict): - args = json.dumps(args, ensure_ascii=False) - tool_calls.append( - ChatCompletionMessageToolCall( - id=_generate_mistral_id(), - type="function", - function=Function( - name=obj["name"], arguments=args - ), - ) - ) - idx = end_idx - except json.JSONDecodeError: - idx += 1 - - if not tool_calls: - return text, None - - return content if content else None, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/qwen3_coder_parser.py b/environments/tool_call_parsers/qwen3_coder_parser.py deleted file mode 100644 index 042e46f7bf9..00000000000 --- a/environments/tool_call_parsers/qwen3_coder_parser.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Qwen3-Coder tool call parser. - -Format uses XML-style nested tags: - - - value - value2 - - - -Parameters are extracted from value tags and -type-converted using the schema if available, otherwise treated as strings. - -Based on VLLM's Qwen3CoderToolParser.extract_tool_calls() -""" - -import ast -import json -import re -import uuid -from typing import Any, Dict, List, Optional - -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, -) - -from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser - - -def _try_convert_value(value: str) -> Any: - """ - Try to convert a parameter value string to a native Python type. - Handles null, numbers, booleans, JSON objects/arrays, and falls back to string. - """ - stripped = value.strip() - - # Handle null - if stripped.lower() == "null": - return None - - # Try JSON first (handles objects, arrays, strings, numbers, booleans) - try: - return json.loads(stripped) - except (json.JSONDecodeError, TypeError): - pass - - # Try Python literal eval (handles tuples, etc.) - try: - return ast.literal_eval(stripped) - except (ValueError, SyntaxError, TypeError): - pass - - # Return as string - return stripped - - -@register_parser("qwen3_coder") -class Qwen3CoderToolCallParser(ToolCallParser): - """ - Parser for Qwen3-Coder XML-format tool calls. - - Uses nested XML tags: val - """ - - START_TOKEN = "" - FUNCTION_PREFIX = "(.*?)|(.*?)$", re.DOTALL - ) - - # Find function blocks within a tool_call - FUNCTION_REGEX = re.compile( - r"||(?=)|$)", - re.DOTALL, - ) - - def _parse_function_call(self, function_str: str) -> Optional[ChatCompletionMessageToolCall]: - """Parse a single ... block into a ToolCall.""" - try: - # Extract function name: everything before the first '>' - gt_idx = function_str.index(">") - func_name = function_str[:gt_idx].strip() - params_str = function_str[gt_idx + 1:] - - # Extract parameters - param_dict: Dict[str, Any] = {} - for match_text in self.PARAMETER_REGEX.findall(params_str): - if ">" not in match_text: - continue - eq_idx = match_text.index(">") - param_name = match_text[:eq_idx].strip() - param_value = match_text[eq_idx + 1:] - - # Clean up whitespace - if param_value.startswith("\n"): - param_value = param_value[1:] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - param_dict[param_name] = _try_convert_value(param_value) - - return ChatCompletionMessageToolCall( - id=f"call_{uuid.uuid4().hex[:24]}", - type="function", - function=Function( - name=func_name, - arguments=json.dumps(param_dict, ensure_ascii=False), - ), - ) - except (ValueError, IndexError): - return None - - def parse(self, text: str) -> ParseResult: - if self.FUNCTION_PREFIX not in text: - return text, None - - try: - # Find all tool_call blocks - tc_matches = self.TOOL_CALL_REGEX.findall(text) - raw_blocks = [m[0] if m[0] else m[1] for m in tc_matches] - - # Fallback: if no tool_call tags, try the whole text - if not raw_blocks: - raw_blocks = [text] - - # Find function blocks within each tool_call - function_strs: List[str] = [] - for block in raw_blocks: - func_matches = self.FUNCTION_REGEX.findall(block) - function_strs.extend(m[0] if m[0] else m[1] for m in func_matches) - - if not function_strs: - return text, None - - # Parse each function call - tool_calls: List[ChatCompletionMessageToolCall] = [] - for func_str in function_strs: - tc = self._parse_function_call(func_str) - if tc is not None: - tool_calls.append(tc) - - if not tool_calls: - return text, None - - # Content before tool calls - first_tc = text.find(self.START_TOKEN) - if first_tc < 0: - first_tc = text.find(self.FUNCTION_PREFIX) - content = text[:first_tc].strip() if first_tc > 0 else None - - return content, tool_calls - - except Exception: - return text, None diff --git a/environments/tool_call_parsers/qwen_parser.py b/environments/tool_call_parsers/qwen_parser.py deleted file mode 100644 index 9c8a8141997..00000000000 --- a/environments/tool_call_parsers/qwen_parser.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Qwen 2.5 tool call parser. - -Uses the same format as Hermes. -Registered as a separate parser name for clarity when using --tool-parser=qwen. -""" - -from environments.tool_call_parsers import register_parser -from environments.tool_call_parsers.hermes_parser import HermesToolCallParser - - -@register_parser("qwen") -class QwenToolCallParser(HermesToolCallParser): - """ - Parser for Qwen 2.5 tool calls. - Same {"name": ..., "arguments": ...} format as Hermes. - """ - - pass # Identical format -- inherits everything from Hermes diff --git a/environments/tool_context.py b/environments/tool_context.py deleted file mode 100644 index 9756dadaf7c..00000000000 --- a/environments/tool_context.py +++ /dev/null @@ -1,473 +0,0 @@ -""" -ToolContext -- Unrestricted Tool Access for Reward Functions - -A per-rollout handle that gives reward/verification functions direct access to -ALL hermes-agent tools, scoped to the rollout's task_id. The same task_id means -the terminal/browser session is the SAME one the model used during its rollout -- -all state (files, processes, browser tabs) is preserved. - -The verifier author decides which tools to use. Nothing is hardcoded or gated. - -Example usage in a compute_reward(): - async def compute_reward(self, item, result, ctx): - # Run tests in the model's terminal sandbox - test = ctx.terminal("pytest -v") - if test["exit_code"] == 0: - return 1.0 - - # Check if a file was created - content = ctx.read_file("/workspace/solution.py") - if content.get("content"): - return 0.5 - - return 0.0 -""" - -import json -import logging -import os -from typing import Any, Dict, List, Optional - -import asyncio -import concurrent.futures - -from model_tools import handle_function_call -from tools.terminal_tool import cleanup_vm -from tools.browser_tool import cleanup_browser - -logger = logging.getLogger(__name__) - -# Thread pool for running sync tool calls that internally use asyncio.run() -_tool_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) - - -def _run_tool_in_thread(tool_name: str, arguments: Dict[str, Any], task_id: str) -> str: - """ - Run a tool call in a thread pool executor so backends that use asyncio.run() - internally (modal, docker, daytona) get a clean event loop. - - If we're already in an async context, executes handle_function_call() in a - disposable worker thread and blocks for the result. - If not (e.g., called from sync code), runs directly. - """ - try: - loop = asyncio.get_running_loop() - # We're in an async context -- need to run in thread - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - handle_function_call, tool_name, arguments, task_id - ) - return future.result(timeout=300) - except RuntimeError: - # No running event loop -- safe to call directly - return handle_function_call(tool_name, arguments, task_id) - - -class ToolContext: - """ - Open-ended access to all hermes-agent tools for a specific rollout. - - Passed to compute_reward() so verifiers can use any tool they need: - terminal commands, file reads/writes, web searches, browser automation, etc. - All calls share the rollout's task_id for session isolation. - """ - - def __init__(self, task_id: str): - self.task_id = task_id - - # ------------------------------------------------------------------------- - # Terminal tools - # ------------------------------------------------------------------------- - - def terminal(self, command: str, timeout: int = 180) -> Dict[str, Any]: - """ - Run a command in the rollout's terminal session. - - Args: - command: Shell command to execute - timeout: Command timeout in seconds - - Returns: - Dict with 'exit_code' (int) and 'output' (str) - """ - import os - backend = os.getenv("TERMINAL_ENV", "local") - logger.debug("ToolContext.terminal [%s backend] task=%s: %s", backend, self.task_id[:8], command[:100]) - - # Run via thread helper so modal/docker/daytona backends' asyncio.run() doesn't deadlock - result = _run_tool_in_thread( - "terminal", - {"command": command, "timeout": timeout}, - self.task_id, - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"exit_code": -1, "output": result} - - # ------------------------------------------------------------------------- - # File tools - # ------------------------------------------------------------------------- - - def read_file(self, path: str) -> Dict[str, Any]: - """ - Read a file from the rollout's filesystem. - - Args: - path: File path to read - - Returns: - Dict with file content or error - """ - result = handle_function_call( - "read_file", {"path": path}, task_id=self.task_id - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - def write_file(self, path: str, content: str) -> Dict[str, Any]: - """ - Write a TEXT file in the rollout's filesystem. - - Uses a shell heredoc under the hood, so this is only safe for text content. - For binary files (images, compiled artifacts, etc.), use upload_file() instead. - - Args: - path: File path to write - content: Text content to write - - Returns: - Dict with success status or error - """ - result = handle_function_call( - "write_file", {"path": path, "content": content}, task_id=self.task_id - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - def upload_file(self, local_path: str, remote_path: str) -> Dict[str, Any]: - """ - Upload a local file to the rollout's sandbox (binary-safe). - - Unlike write_file() which passes content through a shell heredoc (text-only), - this method base64-encodes the file and decodes it inside the sandbox. - Safe for any file type: binaries, images, archives, etc. - - For large files (>1MB), the content is split into chunks to avoid - hitting shell command-length limits. - - Args: - local_path: Path to a local file on the host - remote_path: Destination path inside the sandbox - - Returns: - Dict with 'exit_code' and 'output' - """ - import base64 - from pathlib import Path as _Path - - local = _Path(local_path) - if not local.exists(): - return {"exit_code": -1, "output": f"Local file not found: {local_path}"} - - raw = local.read_bytes() - b64 = base64.b64encode(raw).decode("ascii") - - # Ensure parent directory exists in the sandbox - parent = str(_Path(remote_path).parent) - if parent not in {".", "/"}: - self.terminal(f"mkdir -p {parent}", timeout=10) - - # For small files, single command is fine - chunk_size = 60_000 # ~60KB per chunk (well within shell limits) - if len(b64) <= chunk_size: - result = self.terminal( - f"printf '%s' '{b64}' | base64 -d > {remote_path}", - timeout=30, - ) - else: - # For larger files, write base64 in chunks then decode - tmp_b64 = "/tmp/_hermes_upload.b64" - self.terminal(f": > {tmp_b64}", timeout=5) # truncate - for i in range(0, len(b64), chunk_size): - chunk = b64[i : i + chunk_size] - self.terminal(f"printf '%s' '{chunk}' >> {tmp_b64}", timeout=15) - result = self.terminal( - f"base64 -d {tmp_b64} > {remote_path} && rm -f {tmp_b64}", - timeout=30, - ) - - return result - - def upload_dir(self, local_dir: str, remote_dir: str) -> List[Dict[str, Any]]: - """ - Upload an entire local directory to the rollout's sandbox (binary-safe). - - Recursively uploads all files, preserving directory structure. - - Args: - local_dir: Path to a local directory on the host - remote_dir: Destination directory inside the sandbox - - Returns: - List of results, one per file uploaded - """ - from pathlib import Path as _Path - - local = _Path(local_dir) - if not local.exists() or not local.is_dir(): - return [{"exit_code": -1, "output": f"Local directory not found: {local_dir}"}] - - results = [] - for file_path in sorted(local.rglob("*")): - if file_path.is_file(): - relative = file_path.relative_to(local) - target = f"{remote_dir}/{relative}" - results.append(self.upload_file(str(file_path), target)) - return results - - def download_file(self, remote_path: str, local_path: str) -> Dict[str, Any]: - """ - Download a file from the rollout's sandbox to the host (binary-safe). - - The inverse of upload_file(). Base64-encodes the file inside the sandbox, - reads the encoded data through the terminal, and decodes it locally. - Safe for any file type. - - Args: - remote_path: Path to the file inside the sandbox - local_path: Destination path on the host - - Returns: - Dict with 'success' (bool) and 'bytes' (int) or 'error' (str) - """ - import base64 - from pathlib import Path as _Path - - # Base64-encode the file inside the sandbox and capture output - result = self.terminal( - f"base64 {remote_path} 2>/dev/null", - timeout=30, - ) - - if result.get("exit_code", -1) != 0: - return { - "success": False, - "error": f"Failed to read remote file: {result.get('output', '')}", - } - - b64_data = result.get("output", "").strip() - if not b64_data: - return {"success": False, "error": f"Remote file is empty or missing: {remote_path}"} - - try: - raw = base64.b64decode(b64_data) - except Exception as e: - return {"success": False, "error": f"Base64 decode failed: {e}"} - - # Write to local host filesystem - local = _Path(local_path) - local.parent.mkdir(parents=True, exist_ok=True) - local.write_bytes(raw) - - return {"success": True, "bytes": len(raw)} - - def download_dir(self, remote_dir: str, local_dir: str) -> List[Dict[str, Any]]: - """ - Download a directory from the rollout's sandbox to the host (binary-safe). - - Lists all files in the remote directory, then downloads each one. - Preserves directory structure. - - Args: - remote_dir: Path to the directory inside the sandbox - local_dir: Destination directory on the host - - Returns: - List of results, one per file downloaded - """ - from pathlib import Path as _Path - - # List files in the remote directory - ls_result = self.terminal( - f"find {remote_dir} -type f 2>/dev/null", - timeout=15, - ) - - if ls_result.get("exit_code", -1) != 0: - return [{"success": False, "error": f"Failed to list remote dir: {remote_dir}"}] - - file_list = ls_result.get("output", "").strip() - if not file_list: - return [{"success": False, "error": f"Remote directory is empty or missing: {remote_dir}"}] - - results = [] - for remote_file in file_list.splitlines(): - remote_file = remote_file.strip() - if not remote_file: - continue - # Compute the relative path to preserve directory structure - if remote_file.startswith(remote_dir): - relative = remote_file[len(remote_dir):].lstrip("/") - else: - relative = _Path(remote_file).name - local_file = str(_Path(local_dir) / relative) - results.append(self.download_file(remote_file, local_file)) - - return results - - def search(self, query: str, path: str = ".") -> Dict[str, Any]: - """ - Search for text in the rollout's filesystem. - - Args: - query: Search query - path: Directory to search in - - Returns: - Dict with search results - """ - result = handle_function_call( - "search_files", {"pattern": query, "path": path}, task_id=self.task_id - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - # ------------------------------------------------------------------------- - # Web tools - # ------------------------------------------------------------------------- - - def web_search(self, query: str) -> Dict[str, Any]: - """ - Search the web. - - Args: - query: Search query - - Returns: - Dict with search results - """ - result = handle_function_call("web_search", {"query": query}) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - def web_extract(self, urls: List[str]) -> Dict[str, Any]: - """ - Extract content from URLs. - - Args: - urls: List of URLs to extract content from - - Returns: - Dict with extracted content - """ - result = handle_function_call("web_extract", {"urls": urls}) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - # ------------------------------------------------------------------------- - # Browser tools - # ------------------------------------------------------------------------- - - def browser_navigate(self, url: str) -> Dict[str, Any]: - """ - Navigate the rollout's browser session to a URL. - - Args: - url: URL to navigate to - - Returns: - Dict with page snapshot or error - """ - result = handle_function_call( - "browser_navigate", {"url": url}, task_id=self.task_id - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - def browser_snapshot(self) -> Dict[str, Any]: - """ - Take a snapshot of the current browser page. - - Returns: - Dict with page content/accessibility snapshot - """ - result = handle_function_call( - "browser_snapshot", {}, task_id=self.task_id - ) - try: - return json.loads(result) - except json.JSONDecodeError: - return {"error": result} - - # ------------------------------------------------------------------------- - # Generic tool access - # ------------------------------------------------------------------------- - - def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str: - """ - Call any hermes-agent tool by name. - - This is the generic escape hatch -- if a tool doesn't have a convenience - wrapper above, you can call it directly here. - - Args: - tool_name: Name of the tool (e.g., "vision_analyze", "skills_list") - arguments: Dict of arguments for the tool - - Returns: - Raw JSON string result from the tool - """ - return _run_tool_in_thread(tool_name, arguments, self.task_id) - - # ------------------------------------------------------------------------- - # Cleanup - # ------------------------------------------------------------------------- - - def cleanup(self): - """ - Release all resources (terminal VMs, browser sessions, background processes) - for this rollout. - - Called automatically by the base environment via try/finally after - compute_reward() completes. You generally don't need to call this yourself. - """ - # Kill any background processes from this rollout (safety net) - try: - from tools.process_registry import process_registry - killed = process_registry.kill_all(task_id=self.task_id) - if killed: - logger.debug("Process cleanup for task %s: killed %d process(es)", self.task_id, killed) - except Exception as e: - logger.debug("Process cleanup for task %s: %s", self.task_id, e) - - try: - cleanup_vm(self.task_id) - except Exception as e: - logger.debug("VM cleanup for task %s: %s", self.task_id, e) - - # Suppress browser_tool's noisy debug prints during cleanup. - # The cleanup still runs (safe), it just doesn't spam the console. - _prev_quiet = os.environ.get("HERMES_QUIET") - os.environ["HERMES_QUIET"] = "1" - try: - cleanup_browser(self.task_id) - except Exception as e: - logger.debug("Browser cleanup for task %s: %s", self.task_id, e) - finally: - if _prev_quiet is None: - os.environ.pop("HERMES_QUIET", None) - else: - os.environ["HERMES_QUIET"] = _prev_quiet diff --git a/environments/web_research_env.py b/environments/web_research_env.py deleted file mode 100644 index c637a7cbeae..00000000000 --- a/environments/web_research_env.py +++ /dev/null @@ -1,719 +0,0 @@ -""" -WebResearchEnv — RL Environment for Multi-Step Web Research -============================================================ - -Trains models to do accurate, efficient, multi-source web research. - -Reward signals: - - Answer correctness (LLM judge, 0.0–1.0) - - Source diversity (used ≥2 distinct domains) - - Efficiency (penalizes excessive tool calls) - - Tool usage (bonus for actually using web tools) - -Dataset: FRAMES benchmark (Google, 2024) — multi-hop factual questions - HuggingFace: google/frames-benchmark - Fallback: built-in sample questions (no HF token needed) - -Usage: - # Phase 1 (OpenAI-compatible server) - python environments/web_research_env.py serve \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name YourModel \\ - --openai.server_type openai - - # Process mode (offline data generation) - python environments/web_research_env.py process \\ - --env.data_path_to_save_groups data/web_research.jsonl - - # Standalone eval - python environments/web_research_env.py evaluate \\ - --openai.base_url http://localhost:8000/v1 \\ - --openai.model_name YourModel - -Built by: github.com/jackx707 -Inspired by: GroceryMind — production Hermes agent doing live web research - across German grocery stores (firecrawl + hermes-agent) -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import random -import re -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlparse - -from pydantic import Field - -# Ensure hermes-agent root is on path -_repo_root = Path(__file__).resolve().parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -# --------------------------------------------------------------------------- -# Optional HuggingFace datasets import -# --------------------------------------------------------------------------- -try: - from datasets import load_dataset - HF_AVAILABLE = True -except ImportError: - HF_AVAILABLE = False - -from atroposlib.envs.base import ScoredDataGroup -from atroposlib.envs.server_handling.server_manager import APIServerConfig -from atroposlib.type_definitions import Item - -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from environments.agent_loop import AgentResult -from environments.tool_context import ToolContext - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Fallback sample dataset (used when HuggingFace is unavailable) -# Multi-hop questions requiring real web search to answer. -# --------------------------------------------------------------------------- -SAMPLE_QUESTIONS = [ - { - "question": "What is the current population of the capital city of the country that won the 2022 FIFA World Cup?", - "answer": "Buenos Aires has approximately 3 million people in the city proper, or around 15 million in the greater metro area.", - "difficulty": "medium", - "hops": 2, - }, - { - "question": "Who is the CEO of the company that makes the most widely used open-source container orchestration platform?", - "answer": "The Linux Foundation oversees Kubernetes. CNCF (Cloud Native Computing Foundation) is the specific body — it does not have a traditional CEO but has an executive director.", - "difficulty": "medium", - "hops": 2, - }, - { - "question": "What programming language was used to write the original version of the web framework used by Instagram?", - "answer": "Django, which Instagram was built on, is written in Python.", - "difficulty": "easy", - "hops": 2, - }, - { - "question": "In what year was the university founded where the inventor of the World Wide Web currently holds a professorship?", - "answer": "Tim Berners-Lee holds a professorship at MIT (founded 1861) and the University of Southampton (founded 1952).", - "difficulty": "hard", - "hops": 3, - }, - { - "question": "What is the latest stable version of the programming language that ranks #1 on the TIOBE index as of this year?", - "answer": "Python is currently #1 on TIOBE. The latest stable version should be verified via the official python.org site.", - "difficulty": "medium", - "hops": 2, - }, - { - "question": "How many employees does the parent company of Instagram have?", - "answer": "Meta Platforms (parent of Instagram) employs approximately 70,000+ people as of recent reports.", - "difficulty": "medium", - "hops": 2, - }, - { - "question": "What is the current interest rate set by the central bank of the country where the Eiffel Tower is located?", - "answer": "The European Central Bank sets rates for France/eurozone. The current rate should be verified — it has changed frequently in 2023-2025.", - "difficulty": "hard", - "hops": 2, - }, - { - "question": "Which company acquired the startup founded by the creator of Oculus VR?", - "answer": "Palmer Luckey founded Oculus VR, which was acquired by Facebook (now Meta). He later founded Anduril Industries.", - "difficulty": "medium", - "hops": 2, - }, - { - "question": "What is the market cap of the company that owns the most popular search engine in Russia?", - "answer": "Yandex (now split into separate entities after 2024 restructuring). Current market cap should be verified via financial sources.", - "difficulty": "hard", - "hops": 2, - }, - { - "question": "What was the GDP growth rate of the country that hosted the most recent Summer Olympics?", - "answer": "Paris, France hosted the 2024 Summer Olympics. France's recent GDP growth should be verified via World Bank or IMF data.", - "difficulty": "hard", - "hops": 2, - }, -] - - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -class WebResearchEnvConfig(HermesAgentEnvConfig): - """Configuration for the web research RL environment.""" - - # Reward weights - correctness_weight: float = Field( - default=0.6, - description="Weight for answer correctness in reward (LLM judge score).", - ) - tool_usage_weight: float = Field( - default=0.2, - description="Weight for tool usage signal (did the model actually use web tools?).", - ) - efficiency_weight: float = Field( - default=0.2, - description="Weight for efficiency signal (penalizes excessive tool calls).", - ) - diversity_bonus: float = Field( - default=0.1, - description="Bonus reward for citing ≥2 distinct domains.", - ) - - # Efficiency thresholds - efficient_max_calls: int = Field( - default=5, - description="Maximum tool calls before efficiency penalty begins.", - ) - heavy_penalty_calls: int = Field( - default=10, - description="Tool call count where efficiency penalty steepens.", - ) - - # Eval - eval_size: int = Field( - default=20, - description="Number of held-out items for evaluation.", - ) - eval_split_ratio: float = Field( - default=0.1, - description="Fraction of dataset to hold out for evaluation (0.0–1.0).", - ) - - # Dataset - dataset_name: str = Field( - default="google/frames-benchmark", - description="HuggingFace dataset name for research questions.", - ) - - -# --------------------------------------------------------------------------- -# Environment -# --------------------------------------------------------------------------- - -class WebResearchEnv(HermesAgentBaseEnv): - """ - RL environment for training multi-step web research skills. - - The model is given a factual question requiring 2-3 hops of web research - and must use web_search / web_extract tools to find and synthesize the answer. - - Reward is multi-signal: - 60% — answer correctness (LLM judge) - 20% — tool usage (did the model actually search the web?) - 20% — efficiency (penalizes >5 tool calls) - - Bonus +0.1 for source diversity (≥2 distinct domains cited). - """ - - name = "web-research" - env_config_cls = WebResearchEnvConfig - - # Default toolsets for this environment — web + file for saving notes - default_toolsets = ["web", "file"] - - @classmethod - def config_init(cls) -> Tuple[WebResearchEnvConfig, List[APIServerConfig]]: - """Default configuration for the web research environment.""" - env_config = WebResearchEnvConfig( - enabled_toolsets=["web", "file"], - max_agent_turns=15, - agent_temperature=1.0, - system_prompt=( - "You are a highly capable research agent. When asked a factual question, " - "always use web_search to find current, accurate information before answering. " - "Cite at least 2 sources. Be concise and accurate." - ), - group_size=4, - total_steps=1000, - steps_per_eval=100, - use_wandb=True, - wandb_name="web-research", - ) - - server_configs = [ - APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-sonnet-4.5", - server_type="openai", - api_key=os.getenv("OPENROUTER_API_KEY", ""), - health_check=False, - ) - ] - - return env_config, server_configs - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._items: list[dict] = [] - self._eval_items: list[dict] = [] - self._index: int = 0 - - # Metrics tracking for wandb - self._reward_buffer: list[float] = [] - self._correctness_buffer: list[float] = [] - self._tool_usage_buffer: list[float] = [] - self._efficiency_buffer: list[float] = [] - self._diversity_buffer: list[float] = [] - - # ------------------------------------------------------------------ - # 1. Setup — load dataset - # ------------------------------------------------------------------ - - async def setup(self) -> None: - """Load the FRAMES benchmark or fall back to built-in samples.""" - if HF_AVAILABLE: - try: - logger.info("Loading FRAMES benchmark from HuggingFace...") - ds = load_dataset(self.config.dataset_name, split="test") - self._items = [ - { - "question": row["Prompt"], - "answer": row["Answer"], - "difficulty": row.get("reasoning_types", "unknown"), - "hops": 2, - } - for row in ds - ] - # Hold out for eval - eval_size = max( - self.config.eval_size, - int(len(self._items) * self.config.eval_split_ratio), - ) - random.shuffle(self._items) - self._eval_items = self._items[:eval_size] - self._items = self._items[eval_size:] - logger.info( - f"Loaded {len(self._items)} train / {len(self._eval_items)} eval items " - f"from FRAMES benchmark." - ) - return - except Exception as e: - logger.warning(f"Could not load FRAMES from HuggingFace: {e}. Using built-in samples.") - - # Fallback - random.shuffle(SAMPLE_QUESTIONS) - split = max(1, len(SAMPLE_QUESTIONS) * 8 // 10) - self._items = SAMPLE_QUESTIONS[:split] - self._eval_items = SAMPLE_QUESTIONS[split:] - logger.info( - f"Using built-in sample dataset: {len(self._items)} train / " - f"{len(self._eval_items)} eval items." - ) - - # ------------------------------------------------------------------ - # 2. get_next_item — return the next question - # ------------------------------------------------------------------ - - async def get_next_item(self) -> dict: - """Return the next item, cycling through the dataset.""" - if not self._items: - raise RuntimeError("Dataset is empty. Did you call setup()?") - item = self._items[self._index % len(self._items)] - self._index += 1 - return item - - # ------------------------------------------------------------------ - # 3. format_prompt — build the user-facing prompt - # ------------------------------------------------------------------ - - def format_prompt(self, item: dict) -> str: - """Format the research question as a task prompt.""" - return ( - f"Research the following question thoroughly using web search. " - f"You MUST search the web to find current, accurate information — " - f"do not rely solely on your training data.\n\n" - f"Question: {item['question']}\n\n" - f"Requirements:\n" - f"- Use web_search and/or web_extract tools to find information\n" - f"- Search at least 2 different sources\n" - f"- Provide a concise, accurate answer (2-4 sentences)\n" - f"- Cite the sources you used" - ) - - # ------------------------------------------------------------------ - # 4. compute_reward — multi-signal scoring - # ------------------------------------------------------------------ - - async def compute_reward( - self, - item: dict, - result: AgentResult, - ctx: ToolContext, - ) -> float: - """ - Multi-signal reward function: - - correctness_weight * correctness — LLM judge comparing answer to ground truth - tool_usage_weight * tool_used — binary: did the model use web tools? - efficiency_weight * efficiency — penalizes wasteful tool usage - + diversity_bonus — source diversity (≥2 distinct domains) - """ - # Extract final response from messages (last assistant message with content) - final_response = "" - tools_used: list[str] = [] - for msg in reversed(result.messages): - if msg.get("role") == "assistant" and msg.get("content") and not final_response: - final_response = msg["content"] - # Collect tool names from tool call messages - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - name = fn.get("name", "") - if name: - tools_used.append(name) - tool_call_count: int = result.turns_used or len(tools_used) - - cfg = self.config - - # ---- Signal 1: Answer correctness (LLM judge) ---------------- - correctness = await self._llm_judge( - question=item["question"], - expected=item["answer"], - model_answer=final_response, - ) - - # ---- Signal 2: Web tool usage -------------------------------- - web_tools = {"web_search", "web_extract", "search", "firecrawl"} - tool_used = 1.0 if any(t in web_tools for t in tools_used) else 0.0 - - # ---- Signal 3: Efficiency ------------------------------------ - if tool_call_count <= cfg.efficient_max_calls: - efficiency = 1.0 - elif tool_call_count <= cfg.heavy_penalty_calls: - efficiency = 1.0 - (tool_call_count - cfg.efficient_max_calls) * 0.08 - else: - efficiency = max(0.0, 1.0 - (tool_call_count - cfg.efficient_max_calls) * 0.12) - - # ---- Bonus: Source diversity --------------------------------- - domains = self._extract_domains(final_response) - diversity = cfg.diversity_bonus if len(domains) >= 2 else 0.0 - - # ---- Combine ------------------------------------------------ - reward = ( - cfg.correctness_weight * correctness - + cfg.tool_usage_weight * tool_used - + cfg.efficiency_weight * efficiency - + diversity - ) - reward = min(1.0, max(0.0, reward)) # clamp to [0, 1] - - # Track for wandb - self._reward_buffer.append(reward) - self._correctness_buffer.append(correctness) - self._tool_usage_buffer.append(tool_used) - self._efficiency_buffer.append(efficiency) - self._diversity_buffer.append(diversity) - - logger.debug( - f"Reward breakdown — correctness={correctness:.2f}, " - f"tool_used={tool_used:.1f}, efficiency={efficiency:.2f}, " - f"diversity={diversity:.1f} → total={reward:.3f}" - ) - - return reward - - # ------------------------------------------------------------------ - # 5. evaluate — run on held-out eval split - # ------------------------------------------------------------------ - - async def evaluate(self, *args, **kwargs) -> None: - """Run evaluation on the held-out split using the full agent loop with tools. - - Each eval item runs through the same agent loop as training — - the model can use web_search, web_extract, etc. to research answers. - This measures actual agentic research capability, not just knowledge. - """ - import time - import uuid - from environments.agent_loop import HermesAgentLoop - from environments.tool_context import ToolContext - - items = self._eval_items - if not items: - logger.warning("No eval items available.") - return - - eval_size = min(self.config.eval_size, len(items)) - eval_items = items[:eval_size] - - logger.info(f"Running eval on {len(eval_items)} questions (with agent loop + tools)...") - start_time = time.time() - samples = [] - - # Resolve tools once for all eval items - tools, valid_names = self._resolve_tools_for_group() - - for i, item in enumerate(eval_items): - task_id = str(uuid.uuid4()) - logger.info(f"Eval [{i+1}/{len(eval_items)}]: {item['question'][:80]}...") - - try: - # Build messages - messages: List[Dict[str, Any]] = [] - if self.config.system_prompt: - messages.append({"role": "system", "content": self.config.system_prompt}) - messages.append({"role": "user", "content": self.format_prompt(item)}) - - # Run the full agent loop with tools - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=0.0, # Deterministic for eval - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - budget_config=self.config.build_budget_config(), - ) - result = await agent.run(messages) - - # Extract final response and tool usage from messages - final_response = "" - tool_call_count = 0 - for msg in reversed(result.messages): - if msg.get("role") == "assistant" and msg.get("content") and not final_response: - final_response = msg["content"] - if msg.get("role") == "assistant" and msg.get("tool_calls"): - tool_call_count += len(msg["tool_calls"]) - - # Compute reward (includes LLM judge for correctness) - # Temporarily save buffer lengths so we can extract the - # correctness score without calling judge twice, and avoid - # polluting training metric buffers with eval data. - buf_len = len(self._correctness_buffer) - ctx = ToolContext(task_id) - try: - reward = await self.compute_reward(item, result, ctx) - finally: - ctx.cleanup() - - # Extract correctness from the buffer (compute_reward appended it) - # then remove eval entries from training buffers - correctness = ( - self._correctness_buffer[buf_len] - if len(self._correctness_buffer) > buf_len - else 0.0 - ) - # Roll back buffers to avoid polluting training metrics - for buf in ( - self._reward_buffer, self._correctness_buffer, - self._tool_usage_buffer, self._efficiency_buffer, - self._diversity_buffer, - ): - if len(buf) > buf_len: - buf.pop() - - samples.append({ - "prompt": item["question"], - "response": final_response[:500], - "expected": item["answer"], - "correctness": correctness, - "reward": reward, - "tool_calls": tool_call_count, - "turns": result.turns_used, - }) - - logger.info( - f" → correctness={correctness:.2f}, reward={reward:.3f}, " - f"tools={tool_call_count}, turns={result.turns_used}" - ) - - except Exception as e: - logger.error(f"Eval error on item: {e}") - samples.append({ - "prompt": item["question"], - "response": f"ERROR: {e}", - "expected": item["answer"], - "correctness": 0.0, - "reward": 0.0, - "tool_calls": 0, - "turns": 0, - }) - - end_time = time.time() - - # Compute aggregate metrics - correctness_scores = [s["correctness"] for s in samples] - rewards = [s["reward"] for s in samples] - tool_counts = [s["tool_calls"] for s in samples] - n = len(samples) - - eval_metrics = { - "eval/mean_correctness": sum(correctness_scores) / n if n else 0.0, - "eval/mean_reward": sum(rewards) / n if n else 0.0, - "eval/mean_tool_calls": sum(tool_counts) / n if n else 0.0, - "eval/tool_usage_rate": sum(1 for t in tool_counts if t > 0) / n if n else 0.0, - "eval/n_items": n, - } - - logger.info( - f"Eval complete — correctness={eval_metrics['eval/mean_correctness']:.3f}, " - f"reward={eval_metrics['eval/mean_reward']:.3f}, " - f"tool_usage={eval_metrics['eval/tool_usage_rate']:.0%}" - ) - - await self.evaluate_log( - metrics=eval_metrics, - samples=samples, - start_time=start_time, - end_time=end_time, - ) - - # ------------------------------------------------------------------ - # 6. wandb_log — custom metrics - # ------------------------------------------------------------------ - - async def wandb_log(self, wandb_metrics: Optional[Dict] = None) -> None: - """Log reward breakdown metrics to wandb.""" - if wandb_metrics is None: - wandb_metrics = {} - - if self._reward_buffer: - n = len(self._reward_buffer) - wandb_metrics["train/mean_reward"] = sum(self._reward_buffer) / n - wandb_metrics["train/mean_correctness"] = sum(self._correctness_buffer) / n - wandb_metrics["train/mean_tool_usage"] = sum(self._tool_usage_buffer) / n - wandb_metrics["train/mean_efficiency"] = sum(self._efficiency_buffer) / n - wandb_metrics["train/mean_diversity"] = sum(self._diversity_buffer) / n - wandb_metrics["train/total_rollouts"] = n - - # Accuracy buckets - wandb_metrics["train/correct_rate"] = ( - sum(1 for c in self._correctness_buffer if c >= 0.7) / n - ) - wandb_metrics["train/tool_usage_rate"] = ( - sum(1 for t in self._tool_usage_buffer if t > 0) / n - ) - - # Clear buffers - self._reward_buffer.clear() - self._correctness_buffer.clear() - self._tool_usage_buffer.clear() - self._efficiency_buffer.clear() - self._diversity_buffer.clear() - - await super().wandb_log(wandb_metrics) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - async def _llm_judge( - self, - question: str, - expected: str, - model_answer: str, - ) -> float: - """ - Use the server's LLM to judge answer correctness. - Falls back to keyword heuristic if LLM call fails. - """ - if not model_answer or not model_answer.strip(): - return 0.0 - - judge_prompt = ( - "You are an impartial judge evaluating the quality of an AI research answer.\n\n" - f"Question: {question}\n\n" - f"Reference answer: {expected}\n\n" - f"Model answer: {model_answer}\n\n" - "Score the model answer on a scale from 0.0 to 1.0 where:\n" - " 1.0 = fully correct and complete\n" - " 0.7 = mostly correct with minor gaps\n" - " 0.4 = partially correct\n" - " 0.1 = mentions relevant topic but wrong or very incomplete\n" - " 0.0 = completely wrong or no answer\n\n" - "Consider: factual accuracy, completeness, and relevance.\n" - 'Respond with ONLY a JSON object: {"score": , "reason": ""}' - ) - - try: - response = await self.server.chat_completion( - messages=[{"role": "user", "content": judge_prompt}], - n=1, - max_tokens=150, - temperature=0.0, - split="eval", - ) - text = response.choices[0].message.content if response.choices else "" - parsed = self._parse_judge_json(text) - if parsed is not None: - return float(parsed) - except Exception as e: - logger.debug(f"LLM judge failed: {e}. Using heuristic.") - - return self._heuristic_score(expected, model_answer) - - @staticmethod - def _parse_judge_json(text: str) -> Optional[float]: - """Extract the score float from LLM judge JSON response.""" - try: - clean = re.sub(r"```(?:json)?|```", "", text).strip() - data = json.loads(clean) - score = float(data.get("score", -1)) - if 0.0 <= score <= 1.0: - return score - except Exception: - match = re.search(r'"score"\s*:\s*([0-9.]+)', text) - if match: - score = float(match.group(1)) - if 0.0 <= score <= 1.0: - return score - return None - - @staticmethod - def _heuristic_score(expected: str, model_answer: str) -> float: - """Lightweight keyword overlap score as fallback.""" - stopwords = { - "the", "a", "an", "is", "are", "was", "were", "of", "in", "on", - "at", "to", "for", "with", "and", "or", "but", "it", "its", - "this", "that", "as", "by", "from", "be", "has", "have", "had", - } - - def tokenize(text: str) -> set: - tokens = re.findall(r'\b\w+\b', text.lower()) - return {t for t in tokens if t not in stopwords and len(t) > 2} - - expected_tokens = tokenize(expected) - answer_tokens = tokenize(model_answer) - - if not expected_tokens: - return 0.5 - - overlap = len(expected_tokens & answer_tokens) - union = len(expected_tokens | answer_tokens) - - jaccard = overlap / union if union > 0 else 0.0 - recall = overlap / len(expected_tokens) - return min(1.0, 0.4 * jaccard + 0.6 * recall) - - @staticmethod - def _extract_domains(text: str) -> set: - """Extract unique domains from URLs cited in the response.""" - urls = re.findall(r'https?://[^\s\)>\]"\']+', text) - domains = set() - for url in urls: - try: - parsed = urlparse(url) - domain = parsed.netloc.lower().lstrip("www.") - if domain: - domains.add(domain) - except Exception: - pass - return domains - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - WebResearchEnv.cli() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c3a8152f4a7..a560e1e6a1e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2138,22 +2138,6 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, - "TINKER_API_KEY": { - "description": "Tinker API key for RL training", - "prompt": "Tinker API key", - "url": "https://tinker-console.thinkingmachines.ai/keys", - "tools": ["rl_start_training", "rl_check_status", "rl_stop_training"], - "password": True, - "category": "tool", - }, - "WANDB_API_KEY": { - "description": "Weights & Biases API key for experiment tracking", - "prompt": "WandB API key", - "url": "https://wandb.ai/authorize", - "tools": ["rl_get_results", "rl_check_status"], - "password": True, - "category": "tool", - }, "VOICE_TOOLS_OPENAI_KEY": { "description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS", "prompt": "OpenAI API Key (for Whisper STT + TTS)", @@ -4990,8 +4974,7 @@ def set_config_value(key: str, value: str): 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', 'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', - 'GITHUB_TOKEN', 'HONCHO_API_KEY', 'WANDB_API_KEY', - 'TINKER_API_KEY', + 'GITHUB_TOKEN', 'HONCHO_API_KEY', ] if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'): diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index a551d4d204e..c2035b03e6e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1595,28 +1595,6 @@ def run_doctor(args): for _issue in _r.issues: issues.append(_issue) - # ========================================================================= - # Check: Submodules - # ========================================================================= - print() - print(color("◆ Submodules", Colors.CYAN, Colors.BOLD)) - - # tinker-atropos (RL training backend) - tinker_dir = PROJECT_ROOT / "tinker-atropos" - if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): - if py_version >= (3, 11): - try: - __import__("tinker_atropos") - check_ok("tinker-atropos", "(RL training backend)") - except ImportError: - install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos" - check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})") - issues.append(f"Install tinker-atropos: {install_cmd}") - else: - check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})") - else: - check_warn("tinker-atropos not found", "(run: git submodule update --init --recursive)") - # ========================================================================= # Check: Tool Availability # ========================================================================= diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 6a8bf950589..5d635b2c464 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -522,14 +522,6 @@ def _print_setup_summary(config: dict, hermes_home): elif managed_nous_tools_enabled() and subscription_features.nous_auth_present: tool_status.append(("Modal Execution (optional via Nous subscription)", True, None)) - # Tinker + WandB (RL training) - if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"): - tool_status.append(("RL Training (Tinker)", True, None)) - elif get_env_value("TINKER_API_KEY"): - tool_status.append(("RL Training (Tinker)", False, "WANDB_API_KEY")) - else: - tool_status.append(("RL Training (Tinker)", False, "TINKER_API_KEY")) - # Home Assistant if get_env_value("HASS_TOKEN"): tool_status.append(("Smart Home (Home Assistant)", True, None)) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index b4417091ca7..f2164ac8a4d 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -141,8 +141,6 @@ def show_status(args): "Browser Use": "BROWSER_USE_API_KEY", # Optional — local browser works without this "Browserbase": "BROWSERBASE_API_KEY", # Optional — direct credentials only "FAL": "FAL_KEY", - "Tinker": "TINKER_API_KEY", - "WandB": "WANDB_API_KEY", "ElevenLabs": "ELEVENLABS_API_KEY", "GitHub": "GITHUB_TOKEN", } diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 87474040530..fc5b1acf5cf 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -71,7 +71,6 @@ CONFIGURABLE_TOOLSETS = [ ("delegation", "👥 Task Delegation", "delegate_task"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), ("messaging", "📨 Cross-Platform Messaging", "send_message"), - ("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("homeassistant", "🏠 Home Assistant", "smart home device control"), ("spotify", "🎵 Spotify", "playback, search, playlists, library"), ("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"), @@ -87,7 +86,7 @@ CONFIGURABLE_TOOLSETS = [ # 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"} +_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "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 @@ -424,22 +423,6 @@ TOOL_CATEGORIES = { }, ], }, - "rl": { - "name": "RL Training", - "icon": "🧪", - "requires_python": (3, 11), - "providers": [ - { - "name": "Tinker / Atropos", - "tag": "RL training platform", - "env_vars": [ - {"key": "TINKER_API_KEY", "prompt": "Tinker API key", "url": "https://tinker-console.thinkingmachines.ai/keys"}, - {"key": "WANDB_API_KEY", "prompt": "WandB API key", "url": "https://wandb.ai/authorize"}, - ], - "post_setup": "rl_training", - }, - ], - }, "langfuse": { "name": "Langfuse Observability", "icon": "📊", @@ -912,24 +895,6 @@ def _run_post_setup(post_setup_key: str): _print_warning(f" Spotify login failed: {exc}") _print_info(" Run manually: hermes auth spotify") - elif post_setup_key == "rl_training": - try: - __import__("tinker_atropos") - except ImportError: - tinker_dir = PROJECT_ROOT / "tinker-atropos" - if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): - _print_info(" Installing tinker-atropos submodule...") - result = _pip_install(["-e", str(tinker_dir)]) - if result.returncode == 0: - _print_success(" tinker-atropos installed") - else: - _print_warning(" tinker-atropos install failed - run manually:") - _print_info(' uv pip install -e "./tinker-atropos"') - else: - _print_warning(" tinker-atropos submodule not found - run:") - _print_info(" git submodule update --init --recursive") - _print_info(' uv pip install -e "./tinker-atropos"') - elif post_setup_key == "langfuse": # Install the langfuse SDK. try: diff --git a/model_tools.py b/model_tools.py index 0b9178111a5..db19bb67e53 100644 --- a/model_tools.py +++ b/model_tools.py @@ -97,9 +97,7 @@ def _run_async(coro): asyncio.run()'s create-and-destroy lifecycle. This is the single source of truth for sync->async bridging in tool - handlers. The RL paths (agent_loop.py, tool_context.py) also provide - outer thread-pool wrapping as defense-in-depth, but each handler is - self-protecting via this function. + handlers. Each handler is self-protecting via this function. """ try: loop = asyncio.get_running_loop() @@ -231,13 +229,6 @@ _LEGACY_TOOLSET_MAP = { "browser_vision", "browser_console" ], "cronjob_tools": ["cronjob"], - "rl_tools": [ - "rl_list_environments", "rl_select_environment", - "rl_get_current_config", "rl_edit_config", - "rl_start_training", "rl_check_status", - "rl_stop_training", "rl_get_results", - "rl_list_runs", "rl_test_inference" - ], "file_tools": ["read_file", "write_file", "patch", "search_files"], "tts_tools": ["text_to_speech"], } diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index ce8be16cfdd..6c391878cc5 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -192,7 +192,6 @@ stdenv.mkDerivation { source .venv/bin/activate uv pip install -e ".[all]" [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true - [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true mkdir -p .nix-stamps echo "$STAMP_VALUE" > "$STAMP" else diff --git a/optional-skills/mlops/hermes-atropos-environments/SKILL.md b/optional-skills/mlops/hermes-atropos-environments/SKILL.md deleted file mode 100644 index 6766c381014..00000000000 --- a/optional-skills/mlops/hermes-atropos-environments/SKILL.md +++ /dev/null @@ -1,303 +0,0 @@ ---- -name: hermes-atropos-environments -description: Build, test, and debug Hermes Agent RL environments for Atropos training. Covers the HermesAgentBaseEnv interface, reward functions, agent loop integration, evaluation with tools, wandb logging, and the three CLI modes (serve/process/evaluate). Use when creating, reviewing, or fixing RL environments in the hermes-agent repo. -version: 1.1.0 -author: Hermes Agent -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [atropos, rl, environments, training, reinforcement-learning, reward-functions] - related_skills: [axolotl, fine-tuning-with-trl, lm-evaluation-harness] ---- - -# Hermes Agent Atropos Environments - -Guide for building RL environments in the hermes-agent repo that integrate with the Atropos training framework. - -## Architecture Overview - -``` -Atropos BaseEnv (atroposlib/envs/base.py) - └── HermesAgentBaseEnv (environments/hermes_base_env.py) - ├── Handles agent loop orchestration - ├── Handles tool resolution per group - ├── Handles ToolContext for reward verification - └── YOUR ENVIRONMENT (environments/your_env.py) - Only implements: setup, get_next_item, format_prompt, - compute_reward, evaluate, wandb_log -``` - -Hermes environments are special because they run a **multi-turn agent loop with tool calling** — not just single-turn completions. The base env handles the loop; you implement the task and scoring. - -## File Locations - -| File | Purpose | -|------|---------| -| `environments/hermes_base_env.py` | Base class with agent loop + tool resolution | -| `environments/agent_loop.py` | `HermesAgentLoop` + `AgentResult` dataclass | -| `environments/tool_context.py` | `ToolContext` for reward verification | -| `environments/tool_call_parsers.py` | Phase 2 tool call parsers (hermes, mistral, etc.) | -| `environments/your_env.py` | Your environment implementation | - -## Inference Setup — Ask the User First - -**IMPORTANT:** Before running any test, evaluation, or data generation command, always ask the user how they want to handle inference. Do NOT assume OpenRouter or any specific endpoint. Present these options: - -1. **OpenRouter** — Ask which model they want to use (e.g., `anthropic/claude-sonnet-4.5`, `google/gemini-2.5-pro`, `meta-llama/llama-3.3-70b-instruct`, etc.). Requires `OPENROUTER_API_KEY` in environment. -2. **Self-hosted VLLM endpoint** — Ask for their base URL (e.g., `http://localhost:8000/v1`) and model name. Set `--openai.server_type vllm`. -3. **Other OpenAI-compatible API** — Ask for the base URL, model name, and any required API key. Set `--openai.server_type openai` and `--openai.health_check false`. -4. **Local Atropos training server** — For `serve` mode with a live training loop. Default `http://localhost:8000/v1`. - -Once the user tells you their setup, use those values in all CLI commands for that session. Example prompts: - -> "Before I run this, how would you like to handle inference? -> 1. OpenRouter (I'll need your preferred model, e.g. claude-sonnet-4.5) -> 2. A self-hosted VLLM endpoint (give me the URL and model name) -> 3. Another OpenAI-compatible API (give me the URL, model, and any auth details) -> 4. Local Atropos training server (serve mode)" - -### Key flags by provider: - -| Provider | `--openai.server_type` | `--openai.health_check` | `--openai.api_key` | -|----------|----------------------|------------------------|-------------------| -| OpenRouter | `openai` | `false` | `$OPENROUTER_API_KEY` | -| VLLM (self-hosted) | `vllm` | (default) | (not needed) | -| Other OpenAI-compatible | `openai` | `false` | As needed | -| Local Atropos | (default) | (default) | (not needed) | - -## Required Methods - -### 1. `setup()` — Load dataset and initialize state - -```python -async def setup(self) -> None: - """Called once at startup. Load datasets, initialize state.""" - # Try HuggingFace first, fallback to built-in samples - try: - from datasets import load_dataset - ds = load_dataset("your/dataset", split="test") - self._items = [...] - except Exception: - self._items = BUILTIN_SAMPLES - - # Always split into train/eval - random.shuffle(self._items) - eval_size = max(20, int(len(self._items) * 0.1)) - self._eval_items = self._items[:eval_size] - self._items = self._items[eval_size:] -``` - -### 2. `get_next_item()` — Return next training item - -```python -async def get_next_item(self) -> dict: - """Return next item, cycling through dataset.""" - item = self._items[self._index % len(self._items)] - self._index += 1 - return item -``` - -### 3. `format_prompt(item)` — Convert item to user message - -```python -def format_prompt(self, item: dict) -> str: - """Convert a dataset item into the user-facing prompt.""" - return f"Research this question: {item['question']}" -``` - -### 4. `compute_reward(item, result, ctx)` — Score the rollout - -**CRITICAL**: `result` is an `AgentResult`, NOT a dict. It has these attributes: -- `result.messages` — List of message dicts (OpenAI format) -- `result.turns_used` — Number of LLM calls made -- `result.finished_naturally` — True if model stopped voluntarily -- `result.tool_errors` — List of ToolError objects - -**AgentResult does NOT have**: `final_response`, `tool_calls`, `tools_used`. -You must extract these from `result.messages`: - -```python -async def compute_reward(self, item, result: AgentResult, ctx: ToolContext) -> float: - # Extract final response (last assistant message with content) - final_response = "" - tools_used = [] - for msg in reversed(result.messages): - if msg.get("role") == "assistant" and msg.get("content") and not final_response: - final_response = msg["content"] - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - name = fn.get("name", "") - if name: - tools_used.append(name) - - # Score using LLM judge, heuristic, or ToolContext verification - correctness = await self._llm_judge(item, final_response) - return correctness -``` - -`ctx` (ToolContext) gives you terminal/file access to the agent's sandbox for verification: -```python -# Run tests in the agent's sandbox -result = ctx.terminal("pytest /workspace/test.py") -return 1.0 if result["exit_code"] == 0 else 0.0 -``` - -### 5. `evaluate()` — Periodic evaluation with full agent loop - -**MUST use the full agent loop with tools**, not single-turn chat_completion. -The whole point of hermes-agent environments is agentic evaluation: - -```python -async def evaluate(self, *args, **kwargs) -> None: - import time, uuid - from environments.agent_loop import HermesAgentLoop - from environments.tool_context import ToolContext - - start_time = time.time() - tools, valid_names = self._resolve_tools_for_group() - samples = [] - - for item in self._eval_items[:self.config.eval_size]: - task_id = str(uuid.uuid4()) - messages = [] - if self.config.system_prompt: - messages.append({"role": "system", "content": self.config.system_prompt}) - messages.append({"role": "user", "content": self.format_prompt(item)}) - - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=0.0, # Deterministic for eval - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - ) - result = await agent.run(messages) - - ctx = ToolContext(task_id) - try: - reward = await self.compute_reward(item, result, ctx) - finally: - ctx.cleanup() - - samples.append({"prompt": ..., "response": ..., "reward": reward}) - - eval_metrics = {"eval/mean_reward": ...} - await self.evaluate_log(metrics=eval_metrics, samples=samples, - start_time=start_time, end_time=time.time()) -``` - -### 6. `wandb_log()` — Custom metrics logging - -Always call `super().wandb_log()` at the end: - -```python -async def wandb_log(self, wandb_metrics=None): - if wandb_metrics is None: - wandb_metrics = {} - if self._reward_buffer: - n = len(self._reward_buffer) - wandb_metrics["train/mean_reward"] = sum(self._reward_buffer) / n - self._reward_buffer.clear() - await super().wandb_log(wandb_metrics) # MUST call super -``` - -**Pitfall**: `compute_reward` appends to metric buffers. During eval, this pollutes training metrics. Roll back buffer entries added during eval. - -## Config Class - -Always create a custom config subclass with Pydantic Field descriptors. Key inherited fields you can tune: `enabled_toolsets`, `max_agent_turns`, `agent_temperature`, `system_prompt`, `terminal_backend`, `group_size`, `steps_per_eval`, `total_steps`. - -## config_init() — Default Configuration - -Classmethod returning `(YourEnvConfig, [APIServerConfig(...)])`. Set server_type to "openai" for OpenRouter/external APIs. Load API key from environment variable. - -## Three CLI Modes - -```bash -# SERVE — Full training loop (connects to Atropos API server) -python environments/my_env.py serve --openai.base_url http://localhost:8000/v1 - -# PROCESS — Offline data generation (saves JSONL) -python environments/my_env.py process --env.total_steps 10 --env.group_size 1 \ - --env.use_wandb false --env.data_path_to_save_groups output.jsonl \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type --openai.health_check false - -# EVALUATE — Standalone eval (runs setup + evaluate only) -python environments/my_env.py evaluate --env.eval_size 20 \ - --env.data_dir_to_save_evals /tmp/eval_results \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type --openai.health_check false -``` - -Config priority: CLI args > YAML file > config_init() defaults. - -## Common Pitfalls - -1. **AgentResult has .messages, not .final_response** — Extract the final response by iterating reversed(result.messages) looking for the last assistant message with content. - -2. **evaluate() must use HermesAgentLoop, not chat_completion** — Single-turn chat_completion has no tools. The whole point of hermes-agent benchmarks is agentic evaluation with tool use. - -3. **Don't call _llm_judge twice** — If compute_reward already calls it, extract the score from the buffer instead of calling judge separately in evaluate(). - -4. **Eval pollutes training buffers** — compute_reward appends to metric buffers. During eval, roll back buffer entries to keep training metrics clean. - -5. **Always set health_check=false for OpenRouter** — OpenRouter has no /health endpoint. - -6. **Set data_dir_to_save_evals in evaluate mode** — Without it, results aren't saved. - -7. **default_toolsets class variable vs enabled_toolsets config** — The class variable is a hint; the config field is what actually controls tool resolution. - -8. **Tool call parsing in messages** — Tool calls are dicts with `{"function": {"name": ..., "arguments": ...}}`. Always check `isinstance(tc, dict)`. - -9. **ToolContext.cleanup()** — Always call in a finally block to release sandbox resources. - -10. **server_type must be "openai" for external APIs** — Without it, Atropos assumes a local VLLM server. - -11. **Always ask the user for their inference setup** — Never hardcode or assume a specific provider/model. See the "Inference Setup" section above. - -## Reward Function Patterns - -### LLM Judge (for open-ended tasks) -Use `self.server.chat_completion()` with a scoring prompt. Parse JSON response for score float. Always include a heuristic fallback (keyword overlap) for when the judge call fails. - -### Binary Verification (for code/terminal tasks) -Use `ctx.terminal("pytest test.py -q")` to run tests in the agent's sandbox. Return 1.0 for pass, 0.0 for fail. - -### Multi-Signal (combine multiple indicators) -Weight correctness (0.6) + tool usage (0.2) + efficiency (0.2) + optional bonuses. Clamp to [0, 1]. - -## Testing Your Environment - -1. **Import test**: `python -c "from environments.my_env import MyEnv; print('OK')"` -2. **Ask the user for inference setup** (see "Inference Setup" section above) -3. **Process mode** (1 item): Verify JSONL output has valid tokens, masks, scores -4. **Evaluate mode**: Verify full agent loop runs with tools, metrics logged correctly -5. **Check reward range**: Scores should be in [0, 1], not all identical - -## Minimum Implementation Checklist - -```python -class MyEnv(HermesAgentBaseEnv): - name = "my-env" - env_config_cls = MyEnvConfig - - @classmethod - def config_init(cls): ... # Default server + env config - async def setup(self): ... # Load dataset + train/eval split - async def get_next_item(self): ... # Cycle through training items - def format_prompt(self, item): ... # Item → user message string - async def compute_reward(self, item, result, ctx): ... # Score rollout - async def evaluate(self, *args, **kwargs): ... # Full agent loop eval - async def wandb_log(self, metrics=None): ... # Custom metrics + super() - -if __name__ == "__main__": - MyEnv.cli() -``` diff --git a/optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md b/optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md deleted file mode 100644 index bc6d6050581..00000000000 --- a/optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md +++ /dev/null @@ -1,59 +0,0 @@ -# AgentResult Fields Reference - -`AgentResult` is defined in `environments/agent_loop.py` as a dataclass. - -## Fields - -| Field | Type | Description | -|-------|------|-------------| -| `messages` | `List[Dict[str, Any]]` | Full conversation history in OpenAI message format | -| `managed_state` | `Optional[Dict]` | ManagedServer.get_state() if Phase 2, else None | -| `turns_used` | `int` | Number of LLM calls made during the loop | -| `finished_naturally` | `bool` | True if model stopped calling tools on its own | -| `reasoning_per_turn` | `List[Optional[str]]` | Extracted reasoning content per turn | -| `tool_errors` | `List[ToolError]` | Tool errors encountered during the loop | - -## ToolError Fields - -| Field | Type | Description | -|-------|------|-------------| -| `turn` | `int` | Which turn the error occurred | -| `tool_name` | `str` | Name of the tool that failed | -| `arguments` | `str` | Arguments passed to the tool | -| `error` | `str` | Error message | -| `tool_result` | `str` | The result returned to the model | - -## Extracting Data from Messages - -Messages follow OpenAI format. Common patterns: - -```python -# Get final assistant response -for msg in reversed(result.messages): - if msg.get("role") == "assistant" and msg.get("content"): - final_response = msg["content"] - break - -# Get all tool names used -tools = [] -for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - tools.append(fn.get("name", "")) - -# Get tool results -for msg in result.messages: - if msg.get("role") == "tool": - tool_output = msg.get("content", "") - call_id = msg.get("tool_call_id", "") -``` - -## Fields that DO NOT EXIST - -These are common mistakes — AgentResult does NOT have: -- `final_response` — extract from messages -- `tool_calls` — extract from messages -- `tools_used` — extract from messages -- `output` — extract from messages -- `response` — extract from messages diff --git a/optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md b/optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md deleted file mode 100644 index e76895905e1..00000000000 --- a/optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md +++ /dev/null @@ -1,65 +0,0 @@ -# Atropos BaseEnv Reference - -Source: `atroposlib/envs/base.py` (~2124 lines) - -## Abstract Methods (MUST implement) - -| Method | Signature | Description | -|--------|-----------|-------------| -| `get_next_item()` | `async def get_next_item(self) -> Item` | Return next item for trajectory. Return None to pause. | -| `evaluate()` | `async def evaluate(self, *args, **kwargs)` | Called every steps_per_eval steps. | -| `setup()` | `async def setup(self)` | Called once at start. Load datasets, init models. | -| `collect_trajectory()` | `async def collect_trajectory(self, item) -> Tuple[Optional[ScoredDataItem], List[Item]]` | Single rollout. Or override collect_trajectories instead. | - -## Overridable Methods - -| Method | Default Behavior | Override When | -|--------|-----------------|---------------| -| `collect_trajectories()` | Runs collect_trajectory group_size times in parallel | Batch generation, MCTS, coupled rollouts | -| `wandb_log()` | Logs completion lengths, rollout table, perf stats | Add custom metrics (always call super) | -| `config_init()` | Returns (env_config_cls(), ServerBaseline()) | Custom defaults + server configs | -| `postprocess_histories()` | Passthrough | Final processing before sending to trainer | -| `save_checkpoint()` | Saves JSON to checkpoint_dir | Custom serialization | -| `cleanup()` | No-op | Release resources after each rollout | - -## ScoredDataGroup Structure - -```python -ScoredDataGroup = TypedDict with: - tokens: List[List[int]] # Token IDs per rollout - masks: List[List[int]] # -100=prompt, token_id=completion - scores: List[float] # Score per rollout - advantages: Optional[...] # Per-token advantages - ref_logprobs: Optional[...] # Reference model logprobs - messages: Optional[...] # OpenAI-format messages - inference_logprobs: Optional[...] # Inference logprobs -``` - -## BaseEnvConfig Key Fields - -| Field | Default | Description | -|-------|---------|-------------| -| `group_size` | 4 | Responses grouped for scoring | -| `steps_per_eval` | 100 | Steps between evaluations | -| `max_token_length` | 2048 | Max token length for generations | -| `total_steps` | 1000 | Total training steps | -| `use_wandb` | True | Enable wandb logging | -| `tokenizer_name` | DeepHermes-3 | Tokenizer for token encoding | -| `ensure_scores_are_not_same` | True | Skip groups with identical scores | -| `worker_timeout` | 600 | Task timeout seconds | - -## Data Flow - -``` -env_manager() → add_train_workers() → handle_env() - → collect_trajectories() → postprocess_histories() - → handle_send_to_api() → training server -``` - -## Atropos Environment Statistics (82 environments analyzed) - -- 95% implement setup, collect_trajectories, evaluate, get_next_item -- 76% override wandb_log -- 54% have custom config class -- Most use collect_trajectories (plural), not collect_trajectory (singular) -- Common reward patterns: LLM-judge (~40), regex-extract (~35), code-exec (~12) diff --git a/optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md b/optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md deleted file mode 100644 index 5d4b3c1e820..00000000000 --- a/optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md +++ /dev/null @@ -1,199 +0,0 @@ -# Usage Patterns — Testing Environments and Evaluating Models - -## Pattern 1: Test Your Environment Works (process mode) - -Use `process` mode to verify your environment runs end-to-end before -committing. This generates trajectories without needing an Atropos -training server. - -**Before running:** Ask the user for their inference setup (see SKILL.md "Inference Setup" section). Replace ``, ``, and `` below with their chosen values. - -### Step 1: Run 1 trajectory - -```bash -cd ~/.hermes/hermes-agent -source venv/bin/activate - -python environments/your_env.py process \ - --env.total_steps 1 \ - --env.group_size 1 \ - --env.use_wandb false \ - --env.data_path_to_save_groups /tmp/test_output.jsonl \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type \ - --openai.health_check false -``` - -### Step 2: Verify the output - -```python -import json -for line in open("/tmp/test_output.jsonl"): - data = json.loads(line) - print(f"Scores: {data.get('scores', [])}") - print(f"Token sequences: {len(data.get('tokens', []))}") - # Check messages include tool calls - for msg_list in data.get("messages", []): - roles = [m.get("role") for m in msg_list] - print(f"Roles: {roles}") - for m in reversed(msg_list): - if m.get("role") == "assistant" and m.get("content"): - print(f"Response: {m['content'][:200]}...") - break -``` - -### What to check: -- **Scores are not all 0.0** — if so, compute_reward is broken -- **Scores are in [0, 1]** — not negative, not >1 -- **Messages include "tool" role entries** — agent used tools -- **Token sequences are non-empty** -- **An HTML visualization is generated** next to the .jsonl - -### Common failures: -- `'AgentResult' object has no attribute 'X'` — accessing a field that doesn't exist. See agentresult-fields.md. -- Score always 0.0 — reward function erroring silently -- Score always 1.0 — verification too lenient or not running - - -## Pattern 2: Evaluate a Model (evaluate mode) - -Use `evaluate` mode to benchmark a model on your environment's eval -split. This runs the full agent loop with tools for each eval item. - -### Step 1: Run evaluation - -```bash -python environments/your_env.py evaluate \ - --env.eval_size 20 \ - --env.use_wandb false \ - --env.data_dir_to_save_evals /tmp/eval_results \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type \ - --openai.health_check false -``` - -### Step 2: Read results - -Stdout shows a lighteval-compatible table: - -``` -Evaluation Results: your-env_eval -|Metric | Value| -|mean correctness| 0.850 | -|mean reward | 0.920 | -|mean tool calls | 4.300 | -|n items | 20 | -Evaluation completed in 367 seconds -``` - -JSON results saved to the eval directory: - -```python -import json -data = json.load(open("/tmp/eval_results/metrics.json")) -for metric, value in data["results"]["all"].items(): - print(f"{metric}: {value}") -``` - -### Step 3: Compare models - -Run evaluate with different models and compare the metrics.json files. - -### What to check: -- **"data_dir_to_save_evals is not set"** — you forgot the flag, results won't be saved -- **Tool usage rate = 0** — evaluate() is using chat_completion instead of HermesAgentLoop -- **All scores identical** — judge failing, falling back to heuristic -- **Very slow** — each item runs a full agent loop (~30-90s). Use `--env.eval_size 5` for quick checks. - - -## Pattern 3: Generate Training Data (process mode, larger scale) - -Generate trajectory data for offline training or analysis: - -```bash -python environments/your_env.py process \ - --env.total_steps 50 \ - --env.group_size 4 \ - --env.use_wandb false \ - --env.data_path_to_save_groups data/trajectories.jsonl \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type \ - --openai.health_check false -``` - -### Analyze the distribution: - -```python -import json -scores = [] -for line in open("data/trajectories.jsonl"): - data = json.loads(line) - scores.extend(data.get("scores", [])) - -print(f"Total: {len(scores)}, Mean: {sum(scores)/len(scores):.3f}") -for bucket in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]: - count = sum(1 for s in scores if abs(s - bucket) < 0.1) - print(f" {bucket:.1f}: {'█' * count} ({count})") -``` - -### What to check: -- **Score distribution has variance** — RL needs score variance. All-same scores are useless. - - -## Pattern 4: Full RL Training (serve mode) - -For actual RL training with Atropos: - -```bash -# Terminal 1: Start Atropos API server -run-api - -# Terminal 2: Start your environment -python environments/your_env.py serve \ - --config environments/your_env/default.yaml -``` - -For Phase 2 with VLLM: - -```bash -# Terminal 1: VLLM server -python -m vllm.entrypoints.openai.api_server --model your-model --port 8000 - -# Terminal 2: Atropos API -run-api - -# Terminal 3: Environment -python environments/your_env.py serve \ - --openai.base_url http://localhost:8000/v1 \ - --openai.model_name your-model \ - --openai.server_type vllm -``` - - -## Pattern 5: Quick Smoke Test - -Verify imports and config before spending money on API calls: - -```python -from environments.your_env import YourEnv -print(f"Name: {YourEnv.name}") -cfg, servers = YourEnv.config_init() -print(f"Toolsets: {cfg.enabled_toolsets}") -print(f"Server: {servers[0].model_name}") -print("All imports OK") -``` - - -## Timing Expectations - -| Mode | Items | Time per item | Total | -|------|-------|--------------|-------| -| process (1 item) | 1 | 30-90s | ~1 min | -| evaluate (5 items) | 5 | 30-90s | ~5 min | -| evaluate (20 items) | 20 | 30-90s | ~15-30 min | -| process (50 items) | 50 | 30-90s | ~30-75 min | - -Times are for cloud APIs with Claude Sonnet-class models. Local models may be faster or slower depending on hardware. diff --git a/pyproject.toml b/pyproject.toml index a880bcb05bf..982dc01be17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,14 +166,6 @@ youtube = [ ] # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"] -rl = [ - "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", - "tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b", - "fastapi==0.133.1", - "uvicorn[standard]==0.41.0", - "wandb==0.25.1", -] -yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"] all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every @@ -215,7 +207,7 @@ hermes-agent = "run_agent:main" hermes-acp = "acp_adapter.entry:main" [tool.setuptools] -py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] +py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*"] @@ -238,11 +230,7 @@ python-version = "3.13" unknown-argument = "warn" redundant-cast = "ignore" -[tool.ty.src] -exclude = ["tinker-atropos"] - [tool.ruff] -exclude = ["tinker-atropos"] preview = true # required for PLW1514 (unspecified-encoding) — preview rule [tool.ruff.lint] diff --git a/rl_cli.py b/rl_cli.py deleted file mode 100644 index e3996a29df6..00000000000 --- a/rl_cli.py +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env python3 -""" -RL Training CLI Runner - -Dedicated CLI runner for RL training workflows with: -- Extended timeouts for long-running training -- RL-focused system prompts -- Full toolset including RL training tools -- Special handling for 30-minute check intervals - -Usage: - python rl_cli.py "Train a model on GSM8k for math reasoning" - python rl_cli.py --interactive - python rl_cli.py --list-environments - -Environment Variables: - TINKER_API_KEY: API key for Tinker service (required) - WANDB_API_KEY: API key for WandB metrics (required) - OPENROUTER_API_KEY: API key for OpenRouter (required for agent) -""" - -import asyncio -import os -import sys -from pathlib import Path - -import fire -import yaml - -from hermes_constants import OPENROUTER_BASE_URL, get_hermes_home - -# Load .env from ~/.hermes/.env first, then project root as dev fallback. -# User-managed env files should override stale shell exports on restart. -_hermes_home = get_hermes_home() -_project_env = Path(__file__).parent / '.env' - -from hermes_cli.env_loader import load_hermes_dotenv - -_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) -for _env_path in _loaded_env_paths: - print(f"✅ Loaded environment variables from {_env_path}") - -# Set terminal working directory to tinker-atropos submodule -# This ensures terminal commands run in the right context for RL work -tinker_atropos_dir = Path(__file__).parent / 'tinker-atropos' -if tinker_atropos_dir.exists(): - os.environ['TERMINAL_CWD'] = str(tinker_atropos_dir) - os.environ['HERMES_QUIET'] = '1' # Disable temp subdirectory creation - print(f"📂 Terminal working directory: {tinker_atropos_dir}") -else: - # Fall back to hermes-agent directory if submodule not found - os.environ['TERMINAL_CWD'] = str(Path(__file__).parent) - os.environ['HERMES_QUIET'] = '1' - print(f"⚠️ tinker-atropos submodule not found, using: {Path(__file__).parent}") - -# Import agent and tools -from run_agent import AIAgent -from tools.rl_training_tool import get_missing_keys - - -# ============================================================================ -# Config Loading -# ============================================================================ - -DEFAULT_MODEL = "anthropic/claude-opus-4.5" -DEFAULT_BASE_URL = OPENROUTER_BASE_URL - - -def load_hermes_config() -> dict: - """ - Load configuration from ~/.hermes/config.yaml. - - Returns: - dict: Configuration with model, base_url, etc. - """ - config_path = _hermes_home / 'config.yaml' - - config = { - "model": DEFAULT_MODEL, - "base_url": DEFAULT_BASE_URL, - } - - if config_path.exists(): - try: - with open(config_path, "r", encoding='utf-8') as f: - file_config = yaml.safe_load(f) or {} - - # Get model from config - if "model" in file_config: - if isinstance(file_config["model"], str): - config["model"] = file_config["model"] - elif isinstance(file_config["model"], dict): - config["model"] = file_config["model"].get("default", DEFAULT_MODEL) - - # Get base_url if specified - if "base_url" in file_config: - config["base_url"] = file_config["base_url"] - - except Exception as e: - print(f"⚠️ Warning: Failed to load config.yaml: {e}") - - return config - - -# ============================================================================ -# RL-Specific Configuration -# ============================================================================ - -# Extended timeouts for long-running RL operations -RL_MAX_ITERATIONS = 200 # Allow many more iterations for long workflows - -# RL-focused system prompt -RL_SYSTEM_PROMPT = """You are an automated post-training engineer specializing in reinforcement learning for language models. - -## Your Capabilities - -You have access to RL training tools for running reinforcement learning on models through Tinker-Atropos: - -1. **DISCOVER**: Use `rl_list_environments` to see available RL environments -2. **INSPECT**: Read environment files to understand how they work (verifiers, data loading, rewards) -3. **INSPECT DATA**: Use terminal to explore HuggingFace datasets and understand their format -4. **CREATE**: Copy existing environments as templates, modify for your needs -5. **CONFIGURE**: Use `rl_select_environment` and `rl_edit_config` to set up training -6. **TEST**: Always use `rl_test_inference` before full training to validate your setup -7. **TRAIN**: Use `rl_start_training` to begin, `rl_check_status` to monitor -8. **EVALUATE**: Use `rl_get_results` and analyze WandB metrics to assess performance - -## Environment Files - -Environment files are located in: `tinker-atropos/tinker_atropos/environments/` - -Study existing environments to learn patterns. Look for: -- `load_dataset()` calls - how data is loaded -- `score_answer()` / `score()` - verification logic -- `get_next_item()` - prompt formatting -- `system_prompt` - instruction format -- `config_init()` - default configuration - -## Creating New Environments - -To create a new environment: -1. Read an existing environment file (e.g., gsm8k_tinker.py) -2. Use terminal to explore the target dataset format -3. Copy the environment file as a template -4. Modify the dataset loading, prompt formatting, and verifier logic -5. Test with `rl_test_inference` before training - -## Important Guidelines - -- **Always test before training**: Training runs take hours - verify everything works first -- **Monitor metrics**: Check WandB for reward/mean and percent_correct -- **Status check intervals**: Wait at least 30 minutes between status checks -- **Early stopping**: Stop training early if metrics look bad or stagnant -- **Iterate quickly**: Start with small total_steps to validate, then scale up - -## Available Toolsets - -You have access to: -- **RL tools**: Environment discovery, config management, training, testing -- **Terminal**: Run commands, inspect files, explore datasets -- **Web**: Search for information, documentation, papers -- **File tools**: Read and modify code files - -When asked to train a model, follow this workflow: -1. List available environments -2. Select and configure the appropriate environment -3. Test with sample prompts -4. Start training with conservative settings -5. Monitor progress and adjust as needed -""" - -# Toolsets to enable for RL workflows -RL_TOOLSETS = ["terminal", "web", "rl"] - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def check_requirements(): - """Check that all required environment variables and services are available.""" - errors = [] - - # Check API keys - if not os.getenv("OPENROUTER_API_KEY"): - errors.append("OPENROUTER_API_KEY not set - required for agent") - - missing_rl_keys = get_missing_keys() - if missing_rl_keys: - errors.append(f"Missing RL API keys: {', '.join(missing_rl_keys)}") - - if errors: - print("❌ Missing requirements:") - for error in errors: - print(f" - {error}") - print("\nPlease set these environment variables in your .env file or shell.") - return False - - return True - - -def check_tinker_atropos(): - """Check if tinker-atropos submodule is properly set up.""" - tinker_path = Path(__file__).parent / "tinker-atropos" - - if not tinker_path.exists(): - return False, "tinker-atropos submodule not found. Run: git submodule update --init" - - envs_path = tinker_path / "tinker_atropos" / "environments" - if not envs_path.exists(): - return False, f"environments directory not found at {envs_path}" - - env_files = list(envs_path.glob("*.py")) - env_files = [f for f in env_files if not f.name.startswith("_")] - - return True, {"path": str(tinker_path), "environments_count": len(env_files)} - - -def list_environments_sync(): - """List available environments (synchronous wrapper).""" - from tools.rl_training_tool import rl_list_environments - import json - - async def _list(): - result = await rl_list_environments() - return json.loads(result) - - return asyncio.run(_list()) - - -# ============================================================================ -# Main CLI -# ============================================================================ - -def main( - task: str = None, - model: str = None, - api_key: str = None, - base_url: str = None, - max_iterations: int = RL_MAX_ITERATIONS, - interactive: bool = False, - list_environments: bool = False, - check_server: bool = False, - verbose: bool = False, - save_trajectories: bool = True, -): - """ - RL Training CLI - Dedicated runner for RL training workflows. - - Args: - task: The training task/goal (e.g., "Train a model on GSM8k for math") - model: Model to use for the agent (reads from ~/.hermes/config.yaml if not provided) - api_key: OpenRouter API key (uses OPENROUTER_API_KEY env var if not provided) - base_url: API base URL (reads from config or defaults to OpenRouter) - max_iterations: Maximum agent iterations (default: 200 for long workflows) - interactive: Run in interactive mode (multiple conversations) - list_environments: Just list available RL environments and exit - check_server: Check if RL API server is running and exit - verbose: Enable verbose logging - save_trajectories: Save conversation trajectories (default: True for RL) - - Examples: - # Train on a specific environment - python rl_cli.py "Train a model on GSM8k math problems" - - # Interactive mode - python rl_cli.py --interactive - - # List available environments - python rl_cli.py --list-environments - - # Check server status - python rl_cli.py --check-server - """ - # Load config from ~/.hermes/config.yaml - config = load_hermes_config() - - # Use config values if not explicitly provided - if model is None: - model = config["model"] - if base_url is None: - base_url = config["base_url"] - - print("🎯 RL Training Agent") - print("=" * 60) - - # Handle setup check - if check_server: - print("\n🔍 Checking tinker-atropos setup...") - ok, result = check_tinker_atropos() - if ok: - print("✅ tinker-atropos submodule found") - print(f" Path: {result.get('path')}") - print(f" Environments found: {result.get('environments_count', 0)}") - - # Also check API keys - missing = get_missing_keys() - if missing: - print(f"\n⚠️ Missing API keys: {', '.join(missing)}") - print(" Add them to ~/.hermes/.env") - else: - print("✅ API keys configured") - else: - print(f"❌ tinker-atropos not set up: {result}") - print("\nTo set up:") - print(" git submodule update --init") - print(" pip install -e ./tinker-atropos") - return - - # Handle environment listing - if list_environments: - print("\n📋 Available RL Environments:") - print("-" * 40) - try: - data = list_environments_sync() - if "error" in data: - print(f"❌ Error: {data['error']}") - return - - envs = data.get("environments", []) - if not envs: - print("No environments found.") - print("\nMake sure tinker-atropos is set up:") - print(" git submodule update --init") - return - - for env in envs: - print(f"\n 📦 {env['name']}") - print(f" Class: {env['class_name']}") - print(f" Path: {env['file_path']}") - if env.get('description'): - desc = env['description'][:100] + "..." if len(env.get('description', '')) > 100 else env.get('description', '') - print(f" Description: {desc}") - - print(f"\n📊 Total: {len(envs)} environments") - print("\nUse `rl_select_environment(name)` to select an environment for training.") - except Exception as e: - print(f"❌ Error listing environments: {e}") - print("\nMake sure tinker-atropos is set up:") - print(" git submodule update --init") - print(" pip install -e ./tinker-atropos") - return - - # Check requirements - if not check_requirements(): - sys.exit(1) - - # Set default task if none provided - if not task and not interactive: - print("\n⚠️ No task provided. Use --interactive for interactive mode or provide a task.") - print("\nExamples:") - print(' python rl_cli.py "Train a model on GSM8k math problems"') - print(' python rl_cli.py "Create an RL environment for code generation"') - print(' python rl_cli.py --interactive') - return - - # Get API key - api_key = api_key or os.getenv("OPENROUTER_API_KEY") - if not api_key: - print("❌ No API key provided. Set OPENROUTER_API_KEY or pass --api-key") - sys.exit(1) - - print(f"\n🤖 Model: {model}") - print(f"🔧 Max iterations: {max_iterations}") - print(f"📁 Toolsets: {', '.join(RL_TOOLSETS)}") - print("=" * 60) - - # Create agent with RL configuration - agent = AIAgent( - base_url=base_url, - api_key=api_key, - model=model, - max_iterations=max_iterations, - enabled_toolsets=RL_TOOLSETS, - save_trajectories=save_trajectories, - verbose_logging=verbose, - quiet_mode=False, - ephemeral_system_prompt=RL_SYSTEM_PROMPT, - ) - - if interactive: - # Interactive mode - multiple conversations - print("\n🔄 Interactive RL Training Mode") - print("Type 'quit' or 'exit' to end the session.") - print("Type 'status' to check active training runs.") - print("-" * 40) - - while True: - try: - user_input = input("\n🎯 RL Task> ").strip() - - if not user_input: - continue - - if user_input.lower() in {'quit', 'exit', 'q'}: - print("\n👋 Goodbye!") - break - - if user_input.lower() == 'status': - # Quick status check - from tools.rl_training_tool import rl_list_runs - import json - result = asyncio.run(rl_list_runs()) - runs = json.loads(result) - if isinstance(runs, list) and runs: - print("\n📊 Active Runs:") - for run in runs: - print(f" - {run['run_id']}: {run['environment']} ({run['status']})") - else: - print("\nNo active runs.") - continue - - # Run the agent - print("\n" + "=" * 60) - agent.run_conversation(user_input) - print("\n" + "=" * 60) - - except KeyboardInterrupt: - print("\n\n👋 Interrupted. Goodbye!") - break - except Exception as e: - print(f"\n❌ Error: {e}") - if verbose: - import traceback - traceback.print_exc() - else: - # Single task mode - print(f"\n📝 Task: {task}") - print("-" * 40) - - try: - agent.run_conversation(task) - print("\n" + "=" * 60) - print("✅ Task completed") - except KeyboardInterrupt: - print("\n\n⚠️ Interrupted by user") - except Exception as e: - print(f"\n❌ Error: {e}") - if verbose: - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - fire.Fire(main) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 36cdf76ec70..2cf81969beb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -958,20 +958,6 @@ except Exception: } } - # tinker-atropos (RL training) is optional and OFF by default. Matches the - # Linux/macOS install.sh behavior. Reasons not to auto-install: - # - tinker-atropos/pyproject.toml pulls atroposlib + tinker from git+https - # (NousResearch/atropos + thinking-machines-lab/tinker) which can fail on - # locked-down networks, flaky DNS, or rate-limited github.com and would - # previously kill the whole install mid-flight on Windows. - # - It's an RL training submodule, not part of the default agent surface. - # Users who don't do RL training never need it. - # Users who do want it can run the one-liner we print below. - if (Test-Path "tinker-atropos\pyproject.toml") { - Write-Info "tinker-atropos submodule found — skipping install (optional, for RL training)" - Write-Info " To install later: $UvCmd pip install -e `".\tinker-atropos`"" - } - Pop-Location Write-Success "All dependencies installed" diff --git a/scripts/install.sh b/scripts/install.sh index cf24912cc51..9c5db6b1c08 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1051,11 +1051,6 @@ install_deps() { log_info "Termux note: matrix e2ee and local faster-whisper extras are excluded from .[termux-all] due to upstream Android wheel/toolchain blockers." log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps." - if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then - log_info "tinker-atropos submodule found — skipping install (optional, for RL training)" - log_info " To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\"" - fi - log_success "All dependencies installed" return 0 fi @@ -1243,13 +1238,6 @@ PY log_success "Main package installed" - # tinker-atropos (RL training) is optional — skip by default. - # To enable RL tools: git submodule update --init tinker-atropos && uv pip install -e "./tinker-atropos" - if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then - log_info "tinker-atropos submodule found — skipping install (optional, for RL training)" - log_info " To install: $UV_CMD pip install -e \"./tinker-atropos\"" - fi - log_success "All dependencies installed" } diff --git a/setup-hermes.sh b/setup-hermes.sh index 2aa773c1c9c..bdb8c1e9653 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -267,22 +267,6 @@ else fi # ============================================================================ -# Submodules (terminal backend + RL training) -# ============================================================================ - -echo -e "${CYAN}→${NC} Installing optional submodules..." - -# tinker-atropos (RL training backend) -if is_termux; then - echo -e "${CYAN}→${NC} Skipping tinker-atropos on Termux (not part of the tested Android path)" -elif [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then - $UV_CMD pip install -e "./tinker-atropos" && \ - echo -e "${GREEN}✓${NC} tinker-atropos installed" || \ - echo -e "${YELLOW}⚠${NC} tinker-atropos install failed (RL tools may not work)" -else - echo -e "${YELLOW}⚠${NC} tinker-atropos not found (run: git submodule update --init --recursive)" -fi - # ============================================================================ # Optional: ripgrep (for faster file search) # ============================================================================ diff --git a/tests/conftest.py b/tests/conftest.py index d9ae0c86ea6..aa2b1b1fbcb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,7 +101,6 @@ _CREDENTIAL_NAMES = frozenset({ "RETAINDB_API_KEY", "HINDSIGHT_API_KEY", "HINDSIGHT_LLM_API_KEY", - "TINKER_API_KEY", "DAYTONA_API_KEY", "TWILIO_AUTH_TOKEN", "TELEGRAM_BOT_TOKEN", diff --git a/tests/environments/benchmarks/test_terminalbench2_env_security.py b/tests/environments/benchmarks/test_terminalbench2_env_security.py deleted file mode 100644 index b2610757762..00000000000 --- a/tests/environments/benchmarks/test_terminalbench2_env_security.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Security tests for Terminal-Bench 2 archive extraction.""" - -import base64 -import importlib -import io -import sys -import tarfile -import types - -import pytest - - -def _stub_module(name: str, **attrs): - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - return module - - -def _load_terminalbench_module(monkeypatch): - class _EvalHandlingEnum: - STOP_TRAIN = "stop_train" - - class _APIServerConfig: - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - class _AgentResult: - pass - - class _HermesAgentLoop: - pass - - class _HermesAgentBaseEnv: - pass - - class _HermesAgentEnvConfig: - pass - - class _ToolContext: - pass - - stub_modules = { - "atroposlib": _stub_module("atroposlib"), - "atroposlib.envs": _stub_module("atroposlib.envs"), - "atroposlib.envs.base": _stub_module( - "atroposlib.envs.base", - EvalHandlingEnum=_EvalHandlingEnum, - ), - "atroposlib.envs.server_handling": _stub_module("atroposlib.envs.server_handling"), - "atroposlib.envs.server_handling.server_manager": _stub_module( - "atroposlib.envs.server_handling.server_manager", - APIServerConfig=_APIServerConfig, - ), - "environments.agent_loop": _stub_module( - "environments.agent_loop", - AgentResult=_AgentResult, - HermesAgentLoop=_HermesAgentLoop, - ), - "environments.hermes_base_env": _stub_module( - "environments.hermes_base_env", - HermesAgentBaseEnv=_HermesAgentBaseEnv, - HermesAgentEnvConfig=_HermesAgentEnvConfig, - ), - "environments.tool_context": _stub_module( - "environments.tool_context", - ToolContext=_ToolContext, - ), - "tools.terminal_tool": _stub_module( - "tools.terminal_tool", - register_task_env_overrides=lambda *args, **kwargs: None, - clear_task_env_overrides=lambda *args, **kwargs: None, - cleanup_vm=lambda *args, **kwargs: None, - ), - } - - stub_modules["atroposlib"].envs = stub_modules["atroposlib.envs"] - stub_modules["atroposlib.envs"].base = stub_modules["atroposlib.envs.base"] - stub_modules["atroposlib.envs"].server_handling = stub_modules["atroposlib.envs.server_handling"] - stub_modules["atroposlib.envs.server_handling"].server_manager = stub_modules[ - "atroposlib.envs.server_handling.server_manager" - ] - - for name, module in stub_modules.items(): - monkeypatch.setitem(sys.modules, name, module) - - module_name = "environments.benchmarks.terminalbench_2.terminalbench2_env" - sys.modules.pop(module_name, None) - return importlib.import_module(module_name) - - -def _build_tar_b64(entries): - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tar: - for entry in entries: - kind = entry["kind"] - info = tarfile.TarInfo(entry["name"]) - - if kind == "dir": - info.type = tarfile.DIRTYPE - tar.addfile(info) - continue - - if kind == "file": - data = entry["data"].encode("utf-8") - info.size = len(data) - tar.addfile(info, io.BytesIO(data)) - continue - - if kind == "symlink": - info.type = tarfile.SYMTYPE - info.linkname = entry["target"] - tar.addfile(info) - continue - - raise ValueError(f"Unknown tar entry kind: {kind}") - - return base64.b64encode(buf.getvalue()).decode("ascii") - - -def test_extract_base64_tar_allows_safe_files(tmp_path, monkeypatch): - module = _load_terminalbench_module(monkeypatch) - archive = _build_tar_b64( - [ - {"kind": "dir", "name": "nested"}, - {"kind": "file", "name": "nested/hello.txt", "data": "hello"}, - ] - ) - - target = tmp_path / "extract" - module._extract_base64_tar(archive, target) - - assert (target / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello" - - -def test_extract_base64_tar_rejects_path_traversal(tmp_path, monkeypatch): - module = _load_terminalbench_module(monkeypatch) - archive = _build_tar_b64( - [ - {"kind": "file", "name": "../escape.txt", "data": "owned"}, - ] - ) - - target = tmp_path / "extract" - with pytest.raises(ValueError, match="Unsafe archive member path"): - module._extract_base64_tar(archive, target) - - assert not (tmp_path / "escape.txt").exists() - - -def test_extract_base64_tar_rejects_symlinks(tmp_path, monkeypatch): - module = _load_terminalbench_module(monkeypatch) - archive = _build_tar_b64( - [ - {"kind": "symlink", "name": "link", "target": "../../escape.txt"}, - ] - ) - - target = tmp_path / "extract" - with pytest.raises(ValueError, match="Unsupported archive member type"): - module._extract_base64_tar(archive, target) - - assert not (target / "link").exists() diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 617a915e322..39faa83cf58 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -39,8 +39,6 @@ class TestExplicitAllowlist: "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "WANDB_API_KEY", - "TINKER_API_KEY", "HONCHO_API_KEY", "FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", diff --git a/tests/hermes_cli/test_setup_hermes_script.py b/tests/hermes_cli/test_setup_hermes_script.py index 7978e660a89..a4eb5ccb7d0 100644 --- a/tests/hermes_cli/test_setup_hermes_script.py +++ b/tests/hermes_cli/test_setup_hermes_script.py @@ -18,4 +18,3 @@ def test_setup_hermes_script_has_termux_path(): assert ".[termux]" in content assert "constraints-termux.txt" in content assert "$PREFIX/bin" in content - assert "Skipping tinker-atropos on Termux" in content diff --git a/tests/run_agent/test_agent_loop.py b/tests/run_agent/test_agent_loop.py deleted file mode 100644 index bd9e41b91e2..00000000000 --- a/tests/run_agent/test_agent_loop.py +++ /dev/null @@ -1,505 +0,0 @@ -""" -Tests for environments/agent_loop.py — HermesAgentLoop. - -Tests the multi-turn agent engine using mocked servers, without needing -real API keys or running servers. -""" - -import asyncio -import json -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List, Optional -from unittest.mock import MagicMock - -import pytest - -# Ensure repo root is importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) - -try: - from environments.agent_loop import ( - AgentResult, - HermesAgentLoop, - ToolError, - _extract_reasoning_from_message, - resize_tool_pool, - ) -except ImportError: - pytest.skip("atroposlib not installed", allow_module_level=True) - - -# ─── Mock server infrastructure ───────────────────────────────────────── - - -@dataclass -class MockFunction: - name: str - arguments: str - - -@dataclass -class MockToolCall: - id: str - function: MockFunction - type: str = "function" - - -@dataclass -class MockMessage: - content: Optional[str] - role: str = "assistant" - tool_calls: Optional[List[MockToolCall]] = None - reasoning_content: Optional[str] = None - reasoning: Optional[str] = None - reasoning_details: Optional[list] = None - - -@dataclass -class MockChoice: - message: MockMessage - finish_reason: str = "stop" - index: int = 0 - - -@dataclass -class MockChatCompletion: - choices: List[MockChoice] - id: str = "chatcmpl-mock" - model: str = "mock-model" - - -class MockServer: - """ - Mock server that returns pre-configured responses in sequence. - Mimics the chat_completion() interface. - """ - - def __init__(self, responses: List[MockChatCompletion]): - self.responses = responses - self.call_count = 0 - self.call_history: List[Dict[str, Any]] = [] - - async def chat_completion(self, **kwargs) -> MockChatCompletion: - self.call_history.append(kwargs) - if self.call_count >= len(self.responses): - # Return a simple text response if we run out - return MockChatCompletion( - choices=[MockChoice(message=MockMessage(content="Done."))] - ) - resp = self.responses[self.call_count] - self.call_count += 1 - return resp - - -def make_text_response(content: str) -> MockChatCompletion: - """Create a simple text-only response (no tool calls).""" - return MockChatCompletion( - choices=[MockChoice(message=MockMessage(content=content))] - ) - - -def make_tool_response( - tool_name: str, - arguments: dict, - content: str = "", - tool_call_id: str = "call_001", -) -> MockChatCompletion: - """Create a response with a single tool call.""" - return MockChatCompletion( - choices=[ - MockChoice( - message=MockMessage( - content=content, - tool_calls=[ - MockToolCall( - id=tool_call_id, - function=MockFunction( - name=tool_name, - arguments=json.dumps(arguments), - ), - ) - ], - ), - finish_reason="tool_calls", - ) - ] - ) - - -# ─── Tests ─────────────────────────────────────────────────────────────── - - -class TestAgentResult: - def test_defaults(self): - result = AgentResult(messages=[]) - assert result.messages == [] - assert result.managed_state is None - assert result.turns_used == 0 - assert result.finished_naturally is False - assert result.reasoning_per_turn == [] - assert result.tool_errors == [] - - -class TestExtractReasoning: - def test_reasoning_content_field(self): - msg = MockMessage(content="hello", reasoning_content="I think...") - assert _extract_reasoning_from_message(msg) == "I think..." - - def test_reasoning_field(self): - msg = MockMessage(content="hello", reasoning="Let me consider...") - assert _extract_reasoning_from_message(msg) == "Let me consider..." - - def test_reasoning_details(self): - detail = MagicMock() - detail.text = "Detail reasoning" - msg = MockMessage(content="hello", reasoning_details=[detail]) - assert _extract_reasoning_from_message(msg) == "Detail reasoning" - - def test_reasoning_details_dict_format(self): - msg = MockMessage( - content="hello", - reasoning_details=[{"text": "Dict reasoning"}], - ) - assert _extract_reasoning_from_message(msg) == "Dict reasoning" - - def test_no_reasoning(self): - msg = MockMessage(content="hello") - assert _extract_reasoning_from_message(msg) is None - - def test_reasoning_content_takes_priority(self): - msg = MockMessage( - content="hello", - reasoning_content="First", - reasoning="Second", - ) - assert _extract_reasoning_from_message(msg) == "First" - - -class TestHermesAgentLoop: - """Test the agent loop with mock servers.""" - - @pytest.fixture - def basic_tools(self): - """Minimal tool schema for testing.""" - return [ - { - "type": "function", - "function": { - "name": "terminal", - "description": "Run a command", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to run", - } - }, - "required": ["command"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - }, - "required": ["path"], - }, - }, - }, - ] - - @pytest.fixture - def valid_names(self): - return {"terminal", "read_file", "todo"} - - @pytest.mark.asyncio - async def test_simple_text_response(self, basic_tools, valid_names): - """Model responds with text only, no tool calls.""" - server = MockServer([make_text_response("Hello! How can I help?")]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - result = await agent.run(messages) - - assert result.finished_naturally is True - assert result.turns_used == 1 - assert len(result.messages) >= 2 # user + assistant - assert result.messages[-1]["role"] == "assistant" - assert result.messages[-1]["content"] == "Hello! How can I help?" - - @pytest.mark.asyncio - async def test_tool_call_then_text(self, basic_tools, valid_names): - """Model calls a tool, then responds with text.""" - server = MockServer([ - make_tool_response("todo", {"todos": [{"id": "1", "content": "test", "status": "pending"}]}), - make_text_response("I created a todo for you."), - ]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Create a todo"}] - result = await agent.run(messages) - - assert result.finished_naturally is True - assert result.turns_used == 2 - # Should have: user, assistant (tool_call), tool (result), assistant (text) - roles = [m["role"] for m in result.messages] - assert roles == ["user", "assistant", "tool", "assistant"] - - @pytest.mark.asyncio - async def test_max_turns_reached(self, basic_tools, valid_names): - """Model keeps calling tools until max_turns is hit.""" - # Create responses that always call a tool - responses = [ - make_tool_response("todo", {"todos": [{"id": str(i), "content": f"task {i}", "status": "pending"}]}, tool_call_id=f"call_{i}") - for i in range(10) - ] - server = MockServer(responses) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=3, - ) - messages = [{"role": "user", "content": "Keep going"}] - result = await agent.run(messages) - - assert result.finished_naturally is False - assert result.turns_used == 3 - - @pytest.mark.asyncio - async def test_unknown_tool_name(self, basic_tools, valid_names): - """Model calls a tool not in valid_tool_names.""" - server = MockServer([ - make_tool_response("nonexistent_tool", {"arg": "val"}), - make_text_response("OK, that didn't work."), - ]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Call something weird"}] - result = await agent.run(messages) - - # Should record a tool error - assert len(result.tool_errors) >= 1 - assert result.tool_errors[0].tool_name == "nonexistent_tool" - - @pytest.mark.asyncio - async def test_empty_response(self, basic_tools, valid_names): - """Server returns empty response.""" - server = MockServer([MockChatCompletion(choices=[])]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - result = await agent.run(messages) - - assert result.finished_naturally is False - assert result.turns_used == 1 - - @pytest.mark.asyncio - async def test_api_error_handling(self, basic_tools, valid_names): - """Server raises an exception.""" - - class FailingServer: - async def chat_completion(self, **kwargs): - raise ConnectionError("Server unreachable") - - agent = HermesAgentLoop( - server=FailingServer(), - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - result = await agent.run(messages) - - assert result.finished_naturally is False - assert result.turns_used == 1 - - @pytest.mark.asyncio - async def test_tools_passed_to_server(self, basic_tools, valid_names): - """Verify tools are passed in the chat_completion kwargs.""" - server = MockServer([make_text_response("OK")]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - await agent.run(messages) - - assert len(server.call_history) == 1 - assert "tools" in server.call_history[0] - assert server.call_history[0]["tools"] == basic_tools - - @pytest.mark.asyncio - async def test_extra_body_forwarded(self, basic_tools, valid_names): - """extra_body should be forwarded to server.""" - extra = {"provider": {"ignore": ["DeepInfra"]}} - server = MockServer([make_text_response("OK")]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - extra_body=extra, - ) - messages = [{"role": "user", "content": "Hi"}] - await agent.run(messages) - - assert server.call_history[0].get("extra_body") == extra - - @pytest.mark.asyncio - async def test_managed_state_returned(self, basic_tools, valid_names): - """If server has get_state(), result should include managed_state.""" - server = MockServer([make_text_response("OK")]) - server.get_state = lambda: {"nodes": [{"test": True}]} - - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - result = await agent.run(messages) - - assert result.managed_state is not None - assert "nodes" in result.managed_state - - @pytest.mark.asyncio - async def test_no_managed_state_without_get_state(self, basic_tools, valid_names): - """Regular server without get_state() should return None managed_state.""" - server = MockServer([make_text_response("OK")]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "Hi"}] - result = await agent.run(messages) - - assert result.managed_state is None - - @pytest.mark.asyncio - async def test_memory_tool_blocked(self, basic_tools): - """Memory tool should return error in RL environments.""" - valid = {"terminal", "read_file", "todo", "memory"} - server = MockServer([ - make_tool_response("memory", {"action": "add", "target": "user", "content": "test"}), - make_text_response("Done"), - ]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid, - max_turns=10, - ) - messages = [{"role": "user", "content": "Remember this"}] - result = await agent.run(messages) - - # Find the tool response - tool_msgs = [m for m in result.messages if m["role"] == "tool"] - assert len(tool_msgs) >= 1 - tool_result = json.loads(tool_msgs[0]["content"]) - assert "error" in tool_result - assert "not available" in tool_result["error"].lower() - - @pytest.mark.asyncio - async def test_session_search_blocked(self, basic_tools): - """session_search should return error in RL environments.""" - valid = {"terminal", "read_file", "todo", "session_search"} - server = MockServer([ - make_tool_response("session_search", {"query": "test"}), - make_text_response("Done"), - ]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid, - max_turns=10, - ) - messages = [{"role": "user", "content": "Search sessions"}] - result = await agent.run(messages) - - tool_msgs = [m for m in result.messages if m["role"] == "tool"] - assert len(tool_msgs) >= 1 - tool_result = json.loads(tool_msgs[0]["content"]) - assert "error" in tool_result - - @pytest.mark.asyncio - async def test_reasoning_content_preserved(self, basic_tools, valid_names): - """Reasoning content should be extracted and preserved.""" - resp = MockChatCompletion( - choices=[ - MockChoice( - message=MockMessage( - content="The answer is 42.", - reasoning_content="Let me think about this step by step...", - ) - ) - ] - ) - server = MockServer([resp]) - agent = HermesAgentLoop( - server=server, - tool_schemas=basic_tools, - valid_tool_names=valid_names, - max_turns=10, - ) - messages = [{"role": "user", "content": "What is the meaning of life?"}] - result = await agent.run(messages) - - assert len(result.reasoning_per_turn) == 1 - assert result.reasoning_per_turn[0] == "Let me think about this step by step..." - - -class TestResizeToolPool: - def test_resize_works(self): - """resize_tool_pool should not raise.""" - resize_tool_pool(16) # Small pool for testing - resize_tool_pool(128) # Restore default - - def test_resize_shuts_down_previous_executor(self, monkeypatch): - """Replacing the global tool executor should shut down the old pool.""" - import environments.agent_loop as agent_loop_module - - old_executor = MagicMock() - new_executor = MagicMock() - - monkeypatch.setattr(agent_loop_module, "_tool_executor", old_executor) - monkeypatch.setattr( - agent_loop_module.concurrent.futures, - "ThreadPoolExecutor", - MagicMock(return_value=new_executor), - ) - - resize_tool_pool(16) - - old_executor.shutdown.assert_called_once_with(wait=False) - assert agent_loop_module._tool_executor is new_executor diff --git a/tests/run_agent/test_agent_loop_tool_calling.py b/tests/run_agent/test_agent_loop_tool_calling.py deleted file mode 100644 index 3b8d6ac5988..00000000000 --- a/tests/run_agent/test_agent_loop_tool_calling.py +++ /dev/null @@ -1,552 +0,0 @@ -"""Integration tests for HermesAgentLoop tool calling. - -Tests the full agent loop with real LLM calls via OpenRouter. -Uses stepfun/step-3.5-flash:free by default (zero cost), falls back -to anthropic/claude-sonnet-4 if the free model is unavailable. - -These tests verify: -1. Single tool call: model calls a tool, gets result, responds -2. Multi-tool call: model calls multiple tools in one turn -3. Multi-turn: model calls tools across multiple turns -4. Unknown tool rejection: model calling a non-existent tool gets an error -5. Max turns: loop stops when max_turns is reached -6. No tools: model responds without calling any tools -7. Tool error handling: tool execution errors are captured - -Run: - pytest tests/test_agent_loop_tool_calling.py -v - pytest tests/test_agent_loop_tool_calling.py -v -k "single" # run one test -""" - -import asyncio -import json -import os -import sys -from pathlib import Path -from typing import Any, Dict, List, Set -from unittest.mock import patch - -import pytest - -# pytestmark removed — tests skip gracefully via OPENROUTER_API_KEY check on line 59 - -# Ensure repo root is importable -_repo_root = Path(__file__).resolve().parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -try: - from environments.agent_loop import AgentResult, HermesAgentLoop - from atroposlib.envs.server_handling.openai_server import OpenAIServer # noqa: F401 -except ImportError: - pytest.skip("atroposlib not installed", allow_module_level=True) - - -# ========================================================================= -# Test infrastructure -# ========================================================================= - -# Models to try, in order of preference (free first) -_MODELS = [ - "stepfun/step-3.5-flash:free", - "google/gemini-2.0-flash-001", - "anthropic/claude-sonnet-4", -] - -def _get_api_key(): - key = os.getenv("OPENROUTER_API_KEY", "") - if not key: - pytest.skip("OPENROUTER_API_KEY not set") - return key - - -def _make_server(model: str = None): - """Create an OpenAI server for testing.""" - from atroposlib.envs.server_handling.openai_server import OpenAIServer - from atroposlib.envs.server_handling.server_manager import APIServerConfig - - config = APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name=model or _MODELS[0], - server_type="openai", - api_key=_get_api_key(), - health_check=False, - ) - return OpenAIServer(config) - - -async def _try_models(test_fn): - """Try running a test with each model until one works.""" - last_error = None - for model in _MODELS: - try: - server = _make_server(model) - return await test_fn(server, model) - except Exception as e: - last_error = e - if "rate" in str(e).lower() or "limit" in str(e).lower(): - continue # Rate limited, try next model - raise # Real error - pytest.skip(f"All models failed. Last error: {last_error}") - - -# ========================================================================= -# Fake tools for testing -# ========================================================================= - -# Simple calculator tool -CALC_TOOL = { - "type": "function", - "function": { - "name": "calculate", - "description": "Calculate a math expression. Returns the numeric result.", - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "Math expression to evaluate, e.g. '2 + 3'" - } - }, - "required": ["expression"], - }, - }, -} - -# Weather lookup tool -WEATHER_TOOL = { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a city. Returns temperature and conditions.", - "parameters": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "City name, e.g. 'Tokyo'" - } - }, - "required": ["city"], - }, - }, -} - -# Lookup tool (always succeeds) -LOOKUP_TOOL = { - "type": "function", - "function": { - "name": "lookup", - "description": "Look up a fact. Returns a short answer string.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "What to look up" - } - }, - "required": ["query"], - }, - }, -} - -# Error tool (always fails) -ERROR_TOOL = { - "type": "function", - "function": { - "name": "failing_tool", - "description": "A tool that always fails with an error.", - "parameters": { - "type": "object", - "properties": { - "input": {"type": "string"} - }, - "required": ["input"], - }, - }, -} - - -def _fake_tool_handler(tool_name: str, args: Dict[str, Any], **kwargs) -> str: - """Handle fake tool calls for testing.""" - if tool_name == "calculate": - expr = args.get("expression", "0") - try: - # Safe eval for simple math - result = eval(expr, {"__builtins__": {}}, {}) - return json.dumps({"result": result}) - except Exception as e: - return json.dumps({"error": str(e)}) - - elif tool_name == "get_weather": - city = args.get("city", "Unknown") - # Return canned weather - return json.dumps({ - "city": city, - "temperature": 22, - "conditions": "sunny", - "humidity": 45, - }) - - elif tool_name == "lookup": - query = args.get("query", "") - return json.dumps({"answer": f"The answer to '{query}' is 42."}) - - elif tool_name == "failing_tool": - raise RuntimeError("This tool always fails!") - - return json.dumps({"error": f"Unknown tool: {tool_name}"}) - - -# ========================================================================= -# Tests -# ========================================================================= - -@pytest.mark.asyncio -async def test_single_tool_call(): - """Model should call a single tool, get the result, and respond.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": "What's the weather in Tokyo? Use the get_weather tool."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - assert isinstance(result, AgentResult) - assert result.turns_used >= 2, f"Expected at least 2 turns (tool call + response), got {result.turns_used}" - - # Verify a tool call happened - tool_calls_found = False - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - if tc["function"]["name"] == "get_weather": - tool_calls_found = True - args = json.loads(tc["function"]["arguments"]) - assert "city" in args - assert tool_calls_found, "Model should have called get_weather" - - # Verify tool result is in conversation - tool_results = [m for m in result.messages if m.get("role") == "tool"] - assert len(tool_results) >= 1, "Should have at least one tool result" - - # Verify the final response references the weather - final_msg = result.messages[-1] - assert final_msg["role"] == "assistant" - assert final_msg["content"], "Final response should have content" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_multi_tool_single_turn(): - """Model should call multiple tools in a single turn.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[WEATHER_TOOL, CALC_TOOL], - valid_tool_names={"get_weather", "calculate"}, - max_turns=5, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": ( - "I need two things at once: " - "1) What's the weather in Paris? Use get_weather. " - "2) What is 15 * 7? Use calculate. " - "Call BOTH tools in a single response." - )}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Count distinct tools called - tools_called = set() - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - tools_called.add(tc["function"]["name"]) - - # At minimum, both tools should have been called (maybe in different turns) - assert "get_weather" in tools_called, f"get_weather not called. Called: {tools_called}" - assert "calculate" in tools_called, f"calculate not called. Called: {tools_called}" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_multi_turn_conversation(): - """Agent should handle multiple turns of tool calls.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[LOOKUP_TOOL, CALC_TOOL], - valid_tool_names={"lookup", "calculate"}, - max_turns=10, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": ( - "First, use the lookup tool to look up 'meaning of life'. " - "Then use calculate to compute 6 * 7. " - "Do these in separate tool calls, one at a time." - )}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Should have used both tools - tools_called = set() - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - tools_called.add(tc["function"]["name"]) - - assert "lookup" in tools_called, f"lookup not called. Called: {tools_called}" - assert "calculate" in tools_called, f"calculate not called. Called: {tools_called}" - - # Should finish naturally - assert result.finished_naturally, "Should finish naturally after answering" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_unknown_tool_rejected(): - """If the model calls a tool not in valid_tool_names, it gets an error.""" - - async def _run(server, model): - # Only allow "calculate" but give schema for both - agent = HermesAgentLoop( - server=server, - tool_schemas=[CALC_TOOL, WEATHER_TOOL], - valid_tool_names={"calculate"}, # weather NOT allowed - max_turns=5, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": "What's the weather in London? Use get_weather."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Check if get_weather was called and rejected - if result.tool_errors: - weather_errors = [e for e in result.tool_errors if e.tool_name == "get_weather"] - assert len(weather_errors) > 0, "get_weather should have been rejected" - assert "Unknown tool" in weather_errors[0].error - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_max_turns_limit(): - """Agent should stop after max_turns even if model keeps calling tools.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[LOOKUP_TOOL], - valid_tool_names={"lookup"}, - max_turns=2, # Very low limit - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": ( - "Keep looking up facts. Look up 'fact 1', then 'fact 2', " - "then 'fact 3', then 'fact 4'. Do them one at a time." - )}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - assert result.turns_used <= 2, f"Should stop at max_turns=2, used {result.turns_used}" - assert not result.finished_naturally, "Should NOT finish naturally (hit max_turns)" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_no_tools_direct_response(): - """When no tools are useful, model should respond directly.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.0, - max_tokens=200, - ) - - messages = [ - {"role": "user", "content": "What is 2 + 2? Just answer directly, no tools needed."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - assert result.finished_naturally, "Should finish naturally with a direct response" - assert result.turns_used == 1, f"Should take exactly 1 turn for a direct answer, took {result.turns_used}" - - final = result.messages[-1] - assert final["role"] == "assistant" - assert final["content"], "Should have text content" - assert "4" in final["content"], "Should contain the answer '4'" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_tool_error_handling(): - """Tool execution errors should be captured and reported to the model.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[ERROR_TOOL], - valid_tool_names={"failing_tool"}, - max_turns=5, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": "Please call the failing_tool with input 'test'."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # The tool error should be recorded - assert len(result.tool_errors) >= 1, "Should have at least one tool error" - assert "RuntimeError" in result.tool_errors[0].error or "always fails" in result.tool_errors[0].error - - # The error should be in the conversation as a tool result - tool_results = [m for m in result.messages if m.get("role") == "tool"] - assert len(tool_results) >= 1 - error_result = json.loads(tool_results[0]["content"]) - assert "error" in error_result - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_agent_result_structure(): - """Verify the AgentResult has all expected fields populated.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[CALC_TOOL], - valid_tool_names={"calculate"}, - max_turns=5, - temperature=0.0, - max_tokens=300, - ) - - messages = [ - {"role": "user", "content": "What is 3 + 4? Use the calculate tool."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Structural checks - assert isinstance(result, AgentResult) - assert isinstance(result.messages, list) - assert len(result.messages) >= 3, "Should have user + assistant(tool) + tool_result + assistant(final)" - assert isinstance(result.turns_used, int) - assert result.turns_used > 0 - assert isinstance(result.finished_naturally, bool) - assert isinstance(result.tool_errors, list) - assert isinstance(result.reasoning_per_turn, list) - - # Messages should follow OpenAI format - for msg in result.messages: - assert "role" in msg, f"Message missing 'role': {msg}" - assert msg["role"] in ("system", "user", "assistant", "tool"), f"Invalid role: {msg['role']}" - - return result - - await _try_models(_run) - - -@pytest.mark.asyncio -async def test_conversation_history_preserved(): - """The full conversation history should be in result.messages.""" - - async def _run(server, model): - agent = HermesAgentLoop( - server=server, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.0, - max_tokens=500, - ) - - messages = [ - {"role": "system", "content": "You are a helpful weather assistant."}, - {"role": "user", "content": "What's the weather in Berlin? Use get_weather."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # System message should be preserved - assert result.messages[0]["role"] == "system" - assert "weather assistant" in result.messages[0]["content"] - - # User message should be preserved - assert result.messages[1]["role"] == "user" - assert "Berlin" in result.messages[1]["content"] - - # Should have assistant + tool + assistant sequence - roles = [m["role"] for m in result.messages] - assert "tool" in roles, "Should have tool results in conversation" - - return result - - await _try_models(_run) diff --git a/tests/run_agent/test_agent_loop_vllm.py b/tests/run_agent/test_agent_loop_vllm.py deleted file mode 100644 index d4284909414..00000000000 --- a/tests/run_agent/test_agent_loop_vllm.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Integration tests for HermesAgentLoop with a local vLLM server. - -Tests the full Phase 2 flow: ManagedServer + tool calling with a real -vLLM backend, producing actual token IDs and logprobs for RL training. - -Requires a running vLLM server. Start one from the atropos directory: - - python -m example_trainer.vllm_api_server \ - --model Qwen/Qwen3-4B-Thinking-2507 \ - --port 9001 \ - --gpu-memory-utilization 0.8 \ - --max-model-len=32000 - -Tests are automatically skipped if the server is not reachable. - -Run: - pytest tests/test_agent_loop_vllm.py -v - pytest tests/test_agent_loop_vllm.py -v -k "single" -""" - -import asyncio -import json -import os -import sys -from pathlib import Path -from typing import Any, Dict -from unittest.mock import patch - -import pytest -import requests - -# Ensure repo root is importable -_repo_root = Path(__file__).resolve().parent.parent.parent -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) - -try: - from environments.agent_loop import AgentResult, HermesAgentLoop -except ImportError: - pytest.skip("atroposlib not installed", allow_module_level=True) - - -# ========================================================================= -# Configuration -# ========================================================================= - -VLLM_HOST = "localhost" -VLLM_PORT = 9001 -VLLM_BASE_URL = f"http://{VLLM_HOST}:{VLLM_PORT}" -VLLM_MODEL = "Qwen/Qwen3-4B-Thinking-2507" - - -def _vllm_is_running() -> bool: - """Check if the vLLM server is reachable.""" - try: - r = requests.get(f"{VLLM_BASE_URL}/health", timeout=3) - return r.status_code == 200 - except Exception: - return False - - -# Skip all tests in this module if vLLM is not running -pytestmark = pytest.mark.skipif( - not _vllm_is_running(), - reason=( - f"vLLM server not reachable at {VLLM_BASE_URL}. " - "Start it with: python -m example_trainer.vllm_api_server " - f"--model {VLLM_MODEL} --port {VLLM_PORT} " - "--gpu-memory-utilization 0.8 --max-model-len=32000" - ), -) - - -# ========================================================================= -# Server setup -# ========================================================================= - -def _make_server_manager(): - """Create a ServerManager pointing to the local vLLM server.""" - from atroposlib.envs.server_handling.server_manager import ( - ServerManager, - APIServerConfig, - ) - - config = APIServerConfig( - base_url=VLLM_BASE_URL, - model_name=VLLM_MODEL, - server_type="vllm", - health_check=False, - ) - sm = ServerManager([config], tool_parser="hermes") - sm.servers[0].server_healthy = True - return sm - - -def _get_tokenizer(): - """Load the tokenizer for the model.""" - from transformers import AutoTokenizer - return AutoTokenizer.from_pretrained(VLLM_MODEL) - - -# ========================================================================= -# Fake tools -# ========================================================================= - -WEATHER_TOOL = { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a city. Returns temperature and conditions.", - "parameters": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "City name, e.g. 'Tokyo'", - } - }, - "required": ["city"], - }, - }, -} - -CALC_TOOL = { - "type": "function", - "function": { - "name": "calculate", - "description": "Calculate a math expression. Returns the numeric result.", - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "Math expression, e.g. '2 + 3'", - } - }, - "required": ["expression"], - }, - }, -} - - -def _fake_tool_handler(tool_name: str, args: Dict[str, Any], **kwargs) -> str: - """Handle fake tool calls for testing.""" - if tool_name == "get_weather": - city = args.get("city", "Unknown") - return json.dumps({ - "city": city, - "temperature": 22, - "conditions": "sunny", - "humidity": 45, - }) - elif tool_name == "calculate": - expr = args.get("expression", "0") - try: - result = eval(expr, {"__builtins__": {}}, {}) - return json.dumps({"result": result}) - except Exception as e: - return json.dumps({"error": str(e)}) - return json.dumps({"error": f"Unknown tool: {tool_name}"}) - - -# ========================================================================= -# Tests -# ========================================================================= - -@pytest.mark.asyncio -async def test_vllm_single_tool_call(): - """vLLM model calls a tool, gets result, responds — full Phase 2 flow.""" - sm = _make_server_manager() - tokenizer = _get_tokenizer() - - async with sm.managed_server(tokenizer=tokenizer) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.6, - max_tokens=1000, - ) - - messages = [ - {"role": "user", "content": "What's the weather in Tokyo? Use the get_weather tool."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - assert isinstance(result, AgentResult) - assert result.turns_used >= 2, f"Expected at least 2 turns, got {result.turns_used}" - - # Verify tool call happened - tool_calls_found = False - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - if tc["function"]["name"] == "get_weather": - tool_calls_found = True - args = json.loads(tc["function"]["arguments"]) - assert "city" in args - assert tool_calls_found, "Model should have called get_weather" - - # Verify tool results in conversation - tool_results = [m for m in result.messages if m.get("role") == "tool"] - assert len(tool_results) >= 1 - - -@pytest.mark.asyncio -async def test_vllm_multi_tool_calls(): - """vLLM model calls multiple tools across turns.""" - sm = _make_server_manager() - tokenizer = _get_tokenizer() - - async with sm.managed_server(tokenizer=tokenizer) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=[WEATHER_TOOL, CALC_TOOL], - valid_tool_names={"get_weather", "calculate"}, - max_turns=10, - temperature=0.6, - max_tokens=1000, - ) - - messages = [ - {"role": "user", "content": ( - "I need two things: " - "1) What's the weather in Paris? Use get_weather. " - "2) What is 15 * 7? Use calculate." - )}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Both tools should be called - tools_called = set() - for msg in result.messages: - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - tools_called.add(tc["function"]["name"]) - - assert "get_weather" in tools_called, f"get_weather not called. Called: {tools_called}" - assert "calculate" in tools_called, f"calculate not called. Called: {tools_called}" - - -@pytest.mark.asyncio -async def test_vllm_managed_server_produces_nodes(): - """ManagedServer should produce SequenceNodes with tokens and logprobs.""" - sm = _make_server_manager() - tokenizer = _get_tokenizer() - - async with sm.managed_server(tokenizer=tokenizer) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.6, - max_tokens=1000, - ) - - messages = [ - {"role": "user", "content": "What's the weather in Berlin? Use get_weather."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Get the managed state — should have SequenceNodes - state = managed.get_state() - - assert state is not None, "ManagedServer should return state" - nodes = state.get("nodes", []) - assert len(nodes) >= 1, f"Should have at least 1 node, got {len(nodes)}" - - node = nodes[0] - assert hasattr(node, "tokens"), "Node should have tokens" - assert hasattr(node, "logprobs"), "Node should have logprobs" - assert len(node.tokens) > 0, "Tokens should not be empty" - assert len(node.logprobs) > 0, "Logprobs should not be empty" - assert len(node.tokens) == len(node.logprobs), ( - f"Tokens ({len(node.tokens)}) and logprobs ({len(node.logprobs)}) should have same length" - ) - - -@pytest.mark.asyncio -async def test_vllm_no_tools_direct_response(): - """vLLM model should respond directly when no tools are needed.""" - sm = _make_server_manager() - tokenizer = _get_tokenizer() - - async with sm.managed_server(tokenizer=tokenizer) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=[WEATHER_TOOL], - valid_tool_names={"get_weather"}, - max_turns=5, - temperature=0.6, - max_tokens=500, - ) - - messages = [ - {"role": "user", "content": "What is 2 + 2? Answer directly, no tools."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - assert result.finished_naturally, "Should finish naturally" - assert result.turns_used == 1, f"Should take 1 turn, took {result.turns_used}" - - final = result.messages[-1] - assert final["role"] == "assistant" - assert final["content"], "Should have content" - - -@pytest.mark.asyncio -async def test_vllm_thinking_content_extracted(): - """Qwen3-Thinking model should produce reasoning content.""" - sm = _make_server_manager() - tokenizer = _get_tokenizer() - - async with sm.managed_server( - tokenizer=tokenizer, - preserve_think_blocks=True, - ) as managed: - agent = HermesAgentLoop( - server=managed, - tool_schemas=[CALC_TOOL], - valid_tool_names={"calculate"}, - max_turns=5, - temperature=0.6, - max_tokens=1000, - ) - - messages = [ - {"role": "user", "content": "What is 123 * 456? Use the calculate tool."}, - ] - - with patch("environments.agent_loop.handle_function_call", side_effect=_fake_tool_handler): - result = await agent.run(messages) - - # Qwen3-Thinking should generate blocks - # Check if any content contains thinking markers - has_thinking = False - for msg in result.messages: - content = msg.get("content", "") or "" - if "" in content or "" in content: - has_thinking = True - break - - # Also check reasoning_per_turn - has_reasoning = any(r for r in result.reasoning_per_turn if r) - - # At least one of these should be true for a thinking model - assert has_thinking or has_reasoning, ( - "Qwen3-Thinking should produce blocks or reasoning content" - ) diff --git a/tests/run_agent/test_streaming_tool_call_repair.py b/tests/run_agent/test_streaming_tool_call_repair.py index dadfaec33e5..e85c0e22d18 100644 --- a/tests/run_agent/test_streaming_tool_call_repair.py +++ b/tests/run_agent/test_streaming_tool_call_repair.py @@ -23,7 +23,7 @@ class TestStreamingAssemblyRepair: These tests verify the REPAIR FUNCTION itself works correctly for the cases that arise during streaming assembly. Integration tests that - exercise the full streaming path are in test_agent_loop_tool_calling.py. + exercise the full streaming path are in run_agent.py's streaming tests. """ # -- Truncation cases (most common streaming failure) -- diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 379aac2bbcf..beae3daa65e 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -278,7 +278,7 @@ class TestLegacyToolsetMap: expected = [ "web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools", "cronjob_tools", - "rl_tools", "file_tools", "tts_tools", + "file_tools", "tts_tools", ] for name in expected: assert name in _LEGACY_TOOLSET_MAP, f"Missing legacy toolset: {name}" diff --git a/tests/tools/test_managed_server_tool_support.py b/tests/tools/test_managed_server_tool_support.py deleted file mode 100644 index 5b917f3da89..00000000000 --- a/tests/tools/test_managed_server_tool_support.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Tests for ManagedServer / tool-parser integration. - -Validates that: -1. The installed atroposlib API still matches Hermes's expectations -2. Hermes's parser registry remains compatible with ManagedServer parsing -3. HermesAgentBaseEnv wires the selected parser into ServerManager correctly - -These tests verify the contract between hermes-agent's environments/ code -and atroposlib's ManagedServer. They detect API incompatibilities early. -""" - -import inspect -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -try: - import atroposlib # noqa: F401 -except ImportError: - pytest.skip("atroposlib not installed", allow_module_level=True) - - -class TestManagedServerAPI: - """Test that ManagedServer's API matches what hermes-agent expects.""" - - def test_managed_server_init_signature(self): - """ManagedServer should accept tool_call_parser parameter.""" - from atroposlib.envs.server_handling.managed_server import ManagedServer - - sig = inspect.signature(ManagedServer.__init__) - params = list(sig.parameters.keys()) - - # Core params that must exist - assert "self" in params - assert "server" in params - assert "tokenizer" in params - assert "track_tree" in params - - # tool_call_parser — required for tool_call_support branch - # If this fails, atroposlib hasn't been updated to tool_call_support - has_tool_parser = "tool_call_parser" in params - if not has_tool_parser: - pytest.skip( - "ManagedServer does not have tool_call_parser param — " - "baseline atroposlib (pre tool_call_support branch)" - ) - - def test_server_manager_managed_server_signature(self): - """ServerManager.managed_server() should accept tool_call_parser.""" - from atroposlib.envs.server_handling.server_manager import ServerManager - - sig = inspect.signature(ServerManager.managed_server) - params = list(sig.parameters.keys()) - - assert "self" in params - assert "tokenizer" in params - - has_tool_parser = "tool_call_parser" in params - if not has_tool_parser: - pytest.skip( - "ServerManager.managed_server() does not have tool_call_parser param — " - "baseline atroposlib (pre tool_call_support branch)" - ) - - def test_managed_server_chat_template_kwargs(self): - """ManagedServer should have CHAT_TEMPLATE_KWARGS for forwarding tools/thinking.""" - from atroposlib.envs.server_handling.managed_server import ManagedServer - - if not hasattr(ManagedServer, "CHAT_TEMPLATE_KWARGS"): - pytest.skip( - "ManagedServer does not have CHAT_TEMPLATE_KWARGS — " - "baseline atroposlib (pre tool_call_support branch)" - ) - - kwargs = ManagedServer.CHAT_TEMPLATE_KWARGS - assert "tools" in kwargs, "tools must be in CHAT_TEMPLATE_KWARGS" - - def test_no_get_logprobs_method(self): - """get_logprobs should be removed in tool_call_support branch.""" - from atroposlib.envs.server_handling.managed_server import ManagedServer - - # In baseline, get_logprobs exists. In tool_call_support, it's removed. - # We just note the state — not a hard fail either way. - has_get_logprobs = hasattr(ManagedServer, "get_logprobs") - if has_get_logprobs: - pytest.skip( - "ManagedServer still has get_logprobs — baseline atroposlib" - ) - - -class TestParserCompatibility: - """Test that hermes-agent's parsers match ManagedServer's expectations.""" - - def test_parser_parse_returns_correct_format(self): - """ - ManagedServer expects parser.parse(text) -> (content, tool_calls) - where tool_calls is a list of objects with .id, .function.name, .function.arguments - """ - from environments.tool_call_parsers import get_parser - - parser = get_parser("hermes") - text = '{"name": "terminal", "arguments": {"command": "ls"}}' - content, tool_calls = parser.parse(text) - - assert tool_calls is not None - assert len(tool_calls) == 1 - - tc = tool_calls[0] - # ManagedServer accesses these attrs directly - assert hasattr(tc, "id") - assert hasattr(tc, "function") - assert hasattr(tc.function, "name") - assert hasattr(tc.function, "arguments") - - def test_parser_no_tools_returns_none(self): - """ManagedServer checks `if parsed_tool_calls:` — None should be falsy.""" - from environments.tool_call_parsers import get_parser - - parser = get_parser("hermes") - content, tool_calls = parser.parse("Just text, no tools") - assert tool_calls is None - - def test_parser_content_is_string_or_none(self): - """ManagedServer uses `parsed_content or ""` — must be str or None.""" - from environments.tool_call_parsers import get_parser - - parser = get_parser("hermes") - - # With tool calls - text = '{"name": "terminal", "arguments": {"command": "ls"}}' - content, _ = parser.parse(text) - assert content is None or isinstance(content, str) - - # Without tool calls - content2, _ = parser.parse("Just text") - assert isinstance(content2, str) - - -class TestBaseEnvCompatibility: - """Test that hermes_base_env.py's tool-parser wiring matches the current API.""" - - def test_hermes_base_env_sets_server_manager_tool_parser(self): - """Hermes wires parser selection through ServerManager.tool_parser.""" - import ast - - base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py" - source = base_env_path.read_text() - tree = ast.parse(source) - - found_assignment = False - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Attribute) and target.attr == "tool_parser": - parent = target.value - if ( - isinstance(parent, ast.Attribute) - and parent.attr == "server" - and isinstance(parent.value, ast.Name) - and parent.value.id == "self" - ): - found_assignment = True - - assert found_assignment, ( - "hermes_base_env.py should set self.server.tool_parser from config.tool_call_parser" - ) - - def test_hermes_base_env_uses_config_tool_call_parser(self): - """Verify hermes_base_env uses the config field rather than a local parser instance.""" - base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py" - source = base_env_path.read_text() - - assert 'tool_call_parser: str = Field(' in source - assert 'self.server.tool_parser = config.tool_call_parser' in source diff --git a/tests/tools/test_rl_training_tool.py b/tests/tools/test_rl_training_tool.py deleted file mode 100644 index 8b68ea8d946..00000000000 --- a/tests/tools/test_rl_training_tool.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tests for rl_training_tool.py — file handle lifecycle and cleanup. - -Verifies that _stop_training_run properly closes log file handles, -terminates processes, and handles edge cases on failure paths. -Inspired by PR #715 (0xbyt4). -""" - -from unittest.mock import MagicMock - -import pytest - -from tools.rl_training_tool import RunState, _stop_training_run - - -def _make_run_state(**overrides) -> RunState: - """Create a minimal RunState for testing.""" - defaults = { - "run_id": "test-run-001", - "environment": "test_env", - "config": {}, - } - defaults.update(overrides) - return RunState(**defaults) - - -class TestStopTrainingRunFileHandles: - """Verify that _stop_training_run closes log file handles stored as attributes.""" - - def test_closes_all_log_file_handles(self): - state = _make_run_state() - files = {} - for attr in ("api_log_file", "trainer_log_file", "env_log_file"): - fh = MagicMock() - setattr(state, attr, fh) - files[attr] = fh - - _stop_training_run(state) - - for attr, fh in files.items(): - fh.close.assert_called_once() - assert getattr(state, attr) is None - - def test_clears_file_attrs_to_none(self): - state = _make_run_state() - state.api_log_file = MagicMock() - - _stop_training_run(state) - - assert state.api_log_file is None - - def test_close_exception_does_not_propagate(self): - """If a file handle .close() raises, it must not crash.""" - state = _make_run_state() - bad_fh = MagicMock() - bad_fh.close.side_effect = OSError("already closed") - good_fh = MagicMock() - state.api_log_file = bad_fh - state.trainer_log_file = good_fh - - _stop_training_run(state) # should not raise - - bad_fh.close.assert_called_once() - good_fh.close.assert_called_once() - - def test_handles_missing_file_attrs(self): - """RunState without log file attrs should not crash.""" - state = _make_run_state() - # No log file attrs set at all — getattr(..., None) should handle it - _stop_training_run(state) # should not raise - - -class TestStopTrainingRunProcesses: - """Verify that _stop_training_run terminates processes correctly.""" - - def test_terminates_running_processes(self): - state = _make_run_state() - for attr in ("api_process", "trainer_process", "env_process"): - proc = MagicMock() - proc.poll.return_value = None # still running - setattr(state, attr, proc) - - _stop_training_run(state) - - for attr in ("api_process", "trainer_process", "env_process"): - getattr(state, attr).terminate.assert_called_once() - - def test_does_not_terminate_exited_processes(self): - state = _make_run_state() - proc = MagicMock() - proc.poll.return_value = 0 # already exited - state.api_process = proc - - _stop_training_run(state) - - proc.terminate.assert_not_called() - - def test_handles_none_processes(self): - state = _make_run_state() - # All process attrs are None by default - _stop_training_run(state) # should not raise - - def test_handles_mixed_running_and_exited_processes(self): - state = _make_run_state() - # api still running - api = MagicMock() - api.poll.return_value = None - state.api_process = api - # trainer already exited - trainer = MagicMock() - trainer.poll.return_value = 0 - state.trainer_process = trainer - # env is None - state.env_process = None - - _stop_training_run(state) - - api.terminate.assert_called_once() - trainer.terminate.assert_not_called() - - -class TestStopTrainingRunStatus: - """Verify status transitions in _stop_training_run.""" - - def test_sets_status_to_stopped_when_running(self): - state = _make_run_state(status="running") - _stop_training_run(state) - assert state.status == "stopped" - - def test_does_not_change_status_when_failed(self): - state = _make_run_state(status="failed") - _stop_training_run(state) - assert state.status == "failed" - - def test_does_not_change_status_when_pending(self): - state = _make_run_state(status="pending") - _stop_training_run(state) - assert state.status == "pending" - - def test_no_crash_with_no_processes_and_no_files(self): - state = _make_run_state() - _stop_training_run(state) # should not raise - assert state.status == "pending" diff --git a/tests/tools/test_tool_call_parsers.py b/tests/tools/test_tool_call_parsers.py deleted file mode 100644 index bdea75698a8..00000000000 --- a/tests/tools/test_tool_call_parsers.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Tests for environments/tool_call_parsers/ — client-side tool call parsers. - -These parsers extract structured tool_calls from raw model output text. -Used in Phase 2 (VLLM/generate) where the server returns raw tokens. -""" - -import json -import sys -from pathlib import Path - -import pytest - -# Ensure repo root is importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -try: - from environments.tool_call_parsers import ( - ParseResult, - ToolCallParser, - get_parser, - list_parsers, - ) -except ImportError: - pytest.skip("atroposlib not installed", allow_module_level=True) - - -# ─── Registry tests ───────────────────────────────────────────────────── - -class TestParserRegistry: - def test_list_parsers_returns_nonempty(self): - parsers = list_parsers() - assert len(parsers) > 0 - - def test_hermes_parser_registered(self): - parsers = list_parsers() - assert "hermes" in parsers - - def test_get_parser_returns_instance(self): - parser = get_parser("hermes") - assert isinstance(parser, ToolCallParser) - - def test_get_parser_unknown_raises(self): - with pytest.raises(KeyError): - get_parser("nonexistent_parser_xyz") - - def test_all_registered_parsers_instantiate(self): - """Every registered parser should be importable and instantiable.""" - for name in list_parsers(): - parser = get_parser(name) - assert isinstance(parser, ToolCallParser) - assert hasattr(parser, "parse") - - -# ─── Hermes parser tests ──────────────────────────────────────────────── - -class TestHermesParser: - @pytest.fixture - def parser(self): - return get_parser("hermes") - - def test_no_tool_call(self, parser): - text = "Hello, I can help you with that." - content, tool_calls = parser.parse(text) - assert content == text - assert tool_calls is None - - def test_single_tool_call(self, parser): - text = '{"name": "terminal", "arguments": {"command": "ls -la"}}' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "terminal" - args = json.loads(tool_calls[0].function.arguments) - assert args["command"] == "ls -la" - - def test_tool_call_with_surrounding_text(self, parser): - text = 'Let me check that for you.\n{"name": "terminal", "arguments": {"command": "pwd"}}' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "terminal" - # Content should have the surrounding text - if content is not None: - assert "check that" in content or content.strip() != "" - - def test_multiple_tool_calls(self, parser): - text = ( - '{"name": "terminal", "arguments": {"command": "ls"}}\n' - '{"name": "read_file", "arguments": {"path": "test.py"}}' - ) - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 2 - names = {tc.function.name for tc in tool_calls} - assert "terminal" in names - assert "read_file" in names - - def test_tool_call_ids_are_unique(self, parser): - text = ( - '{"name": "terminal", "arguments": {"command": "ls"}}\n' - '{"name": "terminal", "arguments": {"command": "pwd"}}' - ) - _, tool_calls = parser.parse(text) - assert tool_calls is not None - ids = [tc.id for tc in tool_calls] - assert len(ids) == len(set(ids)), "Tool call IDs must be unique" - - def test_empty_string(self, parser): - content, tool_calls = parser.parse("") - assert tool_calls is None - - def test_malformed_json_in_tool_call(self, parser): - text = 'not valid json' - content, tool_calls = parser.parse(text) - # Should either return None tool_calls or handle gracefully - # (implementation may vary — some parsers return error tool calls) - - def test_truncated_tool_call(self, parser): - """Test handling of unclosed tool_call tag (model truncated mid-generation).""" - text = '{"name": "terminal", "arguments": {"command": "ls -la"}' - content, tool_calls = parser.parse(text) - # Parser should handle truncated output gracefully - # Either parse it successfully or return None - - -# ─── Parse result contract tests (applies to ALL parsers) ─────────────── - -class TestParseResultContract: - """Ensure all parsers conform to the ParseResult contract.""" - - @pytest.fixture(params=["hermes"]) # Add more as needed - def parser(self, request): - return get_parser(request.param) - - def test_returns_tuple_of_two(self, parser): - result = parser.parse("hello world") - assert isinstance(result, tuple) - assert len(result) == 2 - - def test_no_tools_returns_none_tool_calls(self, parser): - content, tool_calls = parser.parse("Just plain text, no tools.") - assert tool_calls is None - assert content is not None - - def test_tool_calls_are_proper_objects(self, parser): - """When tool calls are found, they should be ChatCompletionMessageToolCall objects.""" - # Use hermes format since that's universal - text = '{"name": "terminal", "arguments": {"command": "echo hi"}}' - content, tool_calls = parser.parse(text) - if tool_calls is not None: - for tc in tool_calls: - assert hasattr(tc, "id") - assert hasattr(tc, "function") - assert hasattr(tc.function, "name") - assert hasattr(tc.function, "arguments") - assert tc.id is not None - assert isinstance(tc.function.name, str) - assert isinstance(tc.function.arguments, str) - - -# ─── DeepSeek V3 parser tests ─────────────────────────────────────────── - -class TestDeepSeekV3Parser: - @pytest.fixture - def parser(self): - return get_parser("deepseek_v3") - - def test_no_tool_call(self, parser): - text = "Hello, how can I help you?" - content, tool_calls = parser.parse(text) - assert content == text - assert tool_calls is None - - def test_single_tool_call(self, parser): - text = ( - '<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>get_weather\n' - '```json\n{"city": "London"}\n```<|tool▁call▁end|><|tool▁calls▁end|>' - ) - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "get_weather" - args = json.loads(tool_calls[0].function.arguments) - assert args["city"] == "London" - - def test_multiple_tool_calls(self, parser): - text = ( - '<|tool▁calls▁begin|>' - '<|tool▁call▁begin|>function<|tool▁sep|>get_weather\n' - '```json\n{"city": "London"}\n```<|tool▁call▁end|>' - '<|tool▁call▁begin|>function<|tool▁sep|>get_time\n' - '```json\n{"timezone": "UTC"}\n```<|tool▁call▁end|>' - '<|tool▁calls▁end|>' - ) - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 2, f"Expected 2 tool calls, got {len(tool_calls)}" - names = [tc.function.name for tc in tool_calls] - assert "get_weather" in names - assert "get_time" in names - - def test_tool_call_with_preceding_text(self, parser): - text = ( - 'Let me check that for you.\n' - '<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>terminal\n' - '```json\n{"command": "ls"}\n```<|tool▁call▁end|><|tool▁calls▁end|>' - ) - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - - -# ─── Mistral parser tests ─────────────────────────────────────────────── - -class TestMistralParser: - @pytest.fixture - def parser(self): - return get_parser("mistral") - - def test_no_tool_call(self, parser): - text = "Hello, how can I help you?" - content, tool_calls = parser.parse(text) - assert content == text - assert tool_calls is None - - def test_pre_v11_single_tool_call(self, parser): - text = '[TOOL_CALLS] [{"name": "func", "arguments": {"key": "val"}}]' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "func" - args = json.loads(tool_calls[0].function.arguments) - assert args["key"] == "val" - - def test_pre_v11_nested_json(self, parser): - text = '[TOOL_CALLS] [{"name": "func", "arguments": {"nested": {"deep": true}}}]' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "func" - args = json.loads(tool_calls[0].function.arguments) - assert args["nested"]["deep"] is True - - def test_v11_single_tool_call(self, parser): - text = '[TOOL_CALLS]get_weather{"city": "London"}' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "get_weather" - args = json.loads(tool_calls[0].function.arguments) - assert args["city"] == "London" - - def test_v11_multiple_tool_calls(self, parser): - text = '[TOOL_CALLS]func1{"a": 1}[TOOL_CALLS]func2{"b": 2}' - content, tool_calls = parser.parse(text) - assert tool_calls is not None - assert len(tool_calls) == 2 - names = [tc.function.name for tc in tool_calls] - assert "func1" in names - assert "func2" in names - - def test_preceding_text_preserved(self, parser): - text = 'Hello[TOOL_CALLS]func{"a": 1}' - content, tool_calls = parser.parse(text) - assert content == "Hello" - assert tool_calls is not None - assert len(tool_calls) == 1 - assert tool_calls[0].function.name == "func" - - def test_malformed_json_fallback(self, parser): - text = "[TOOL_CALLS] not valid json" - content, tool_calls = parser.parse(text) - assert tool_calls is None diff --git a/tinker-atropos b/tinker-atropos deleted file mode 160000 index 65f084ee805..00000000000 --- a/tinker-atropos +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 65f084ee8054a5d02aeac76e24ed60388511c82b diff --git a/tools/budget_config.py b/tools/budget_config.py index 577e59442ee..093188d5c75 100644 --- a/tools/budget_config.py +++ b/tools/budget_config.py @@ -1,6 +1,5 @@ """Configurable budget constants for tool result persistence. -Overridable at the RL environment level via HermesAgentEnvConfig fields. Per-tool resolution: pinned > config overrides > registry > default. """ diff --git a/tools/rl_training_tool.py b/tools/rl_training_tool.py deleted file mode 100644 index c7acb8012e1..00000000000 --- a/tools/rl_training_tool.py +++ /dev/null @@ -1,1396 +0,0 @@ -#!/usr/bin/env python3 -""" -RL Training Tools Module - -This module provides tools for running RL training through Tinker-Atropos. -Directly manages training processes without requiring a separate API server. - -Features: -- Environment discovery (AST-based scanning for BaseEnv subclasses) -- Configuration management with locked infrastructure settings -- Training run lifecycle via subprocess management -- WandB metrics monitoring - -Required environment variables: -- TINKER_API_KEY: API key for Tinker service -- WANDB_API_KEY: API key for Weights & Biases metrics - -Usage: - from tools.rl_training_tool import ( - rl_list_environments, - rl_select_environment, - rl_get_current_config, - rl_edit_config, - rl_start_training, - rl_check_status, - rl_stop_training, - rl_get_results, - ) -""" - -import ast -import asyncio -import importlib.util -import json -import os -import subprocess -import sys -import time -import uuid -import logging -from datetime import datetime -import yaml -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List, Optional - -from hermes_constants import get_hermes_home - -logger = logging.getLogger(__name__) - -# ============================================================================ -# Path Configuration -# ============================================================================ - -# Path to tinker-atropos submodule (relative to hermes-agent root) -HERMES_ROOT = Path(__file__).parent.parent -TINKER_ATROPOS_ROOT = HERMES_ROOT / "tinker-atropos" -ENVIRONMENTS_DIR = TINKER_ATROPOS_ROOT / "tinker_atropos" / "environments" -CONFIGS_DIR = TINKER_ATROPOS_ROOT / "configs" -LOGS_DIR = get_hermes_home() / "logs" / "rl_training" - -def _ensure_logs_dir(): - """Lazily create logs directory on first use (avoid side effects at import time).""" - if TINKER_ATROPOS_ROOT.exists(): - LOGS_DIR.mkdir(exist_ok=True) - -# ============================================================================ -# Locked Configuration (Infrastructure Settings) -# ============================================================================ - -# These fields cannot be changed by the model - they're tuned for our infrastructure -LOCKED_FIELDS = { - "env": { - "tokenizer_name": "Qwen/Qwen3-8B", - "rollout_server_url": "http://localhost:8000", - "use_wandb": True, - "max_token_length": 8192, - "max_num_workers": 2048, - "worker_timeout": 3600, - "total_steps": 2500, - "steps_per_eval": 25, - "max_batches_offpolicy": 3, - "inference_weight": 1.0, - "eval_limit_ratio": 0.1, - }, - "openai": [ - { - "model_name": "Qwen/Qwen3-8B", - "base_url": "http://localhost:8001/v1", - "api_key": "x", - "weight": 1.0, - "num_requests_for_eval": 256, - "timeout": 3600, - "server_type": "sglang", # Tinker uses sglang for actual training - } - ], - "tinker": { - "lora_rank": 32, - "learning_rate": 0.00004, - "max_token_trainer_length": 9000, - "checkpoint_dir": "./temp/", - "save_checkpoint_interval": 25, - }, - "slurm": False, - "testing": False, -} - -LOCKED_FIELD_NAMES = set(LOCKED_FIELDS.get("env", {}).keys()) - - -# ============================================================================ -# State Management -# ============================================================================ - -@dataclass -class EnvironmentInfo: - """Information about a discovered environment.""" - name: str - class_name: str - file_path: str - description: str = "" - config_class: str = "BaseEnvConfig" - - -@dataclass -class RunState: - """State for a training run.""" - run_id: str - environment: str - config: Dict[str, Any] - status: str = "pending" # pending, starting, running, stopping, stopped, completed, failed - error_message: str = "" - wandb_project: str = "" - wandb_run_name: str = "" - start_time: float = 0.0 - # Process handles - api_process: Optional[subprocess.Popen] = None - trainer_process: Optional[subprocess.Popen] = None - env_process: Optional[subprocess.Popen] = None - - -# Global state -_environments: List[EnvironmentInfo] = [] -_current_env: Optional[str] = None -_current_config: Dict[str, Any] = {} -_env_config_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} -_active_runs: Dict[str, RunState] = {} -_last_status_check: Dict[str, float] = {} - -# Rate limiting for status checks (30 minutes) -MIN_STATUS_CHECK_INTERVAL = 30 * 60 - - -# ============================================================================ -# Environment Discovery -# ============================================================================ - -def _scan_environments() -> List[EnvironmentInfo]: - """ - Scan the environments directory for BaseEnv subclasses using AST. - """ - environments = [] - - if not ENVIRONMENTS_DIR.exists(): - return environments - - for py_file in ENVIRONMENTS_DIR.glob("*.py"): - if py_file.name.startswith("_"): - continue - - try: - with open(py_file, "r", encoding="utf-8") as f: - tree = ast.parse(f.read()) - - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - # Check if class has BaseEnv as base - for base in node.bases: - base_name = "" - if isinstance(base, ast.Name): - base_name = base.id - elif isinstance(base, ast.Attribute): - base_name = base.attr - - if base_name == "BaseEnv": - # Extract name from class attribute if present - env_name = py_file.stem - description = "" - config_class = "BaseEnvConfig" - - for item in node.body: - if isinstance(item, ast.Assign): - for target in item.targets: - if isinstance(target, ast.Name): - if target.id == "name" and isinstance(item.value, ast.Constant): - env_name = item.value.value - elif target.id == "env_config_cls" and isinstance(item.value, ast.Name): - config_class = item.value.id - - # Get docstring - if isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant): - if isinstance(item.value.value, str) and not description: - description = item.value.value.split("\n")[0].strip() - - environments.append(EnvironmentInfo( - name=env_name, - class_name=node.name, - file_path=str(py_file), - description=description or f"Environment from {py_file.name}", - config_class=config_class, - )) - break - except Exception as e: - logger.warning("Could not parse %s: %s", py_file, e) - - return environments - - -def _get_env_config_fields(env_file_path: str) -> Dict[str, Dict[str, Any]]: - """ - Dynamically import an environment and extract its config fields. - - Uses config_init() to get the actual config class, with fallback to - directly importing BaseEnvConfig if config_init fails. - """ - try: - # Load the environment module - spec = importlib.util.spec_from_file_location("env_module", env_file_path) - module = importlib.util.module_from_spec(spec) - sys.modules["env_module"] = module - spec.loader.exec_module(module) - - # Find the BaseEnv subclass - env_class = None - for name, obj in vars(module).items(): - if isinstance(obj, type) and name != "BaseEnv": - if hasattr(obj, "config_init") and callable(getattr(obj, "config_init")): - env_class = obj - break - - if not env_class: - return {} - - # Try calling config_init to get the actual config class - config_class = None - try: - env_config, server_configs = env_class.config_init() - config_class = type(env_config) - except Exception as config_error: - # Fallback: try to import BaseEnvConfig directly from atroposlib - logger.info("config_init failed (%s), using BaseEnvConfig defaults", config_error) - try: - from atroposlib.envs.base import BaseEnvConfig - config_class = BaseEnvConfig - except ImportError: - return {} - - if not config_class: - return {} - - # Helper to make values JSON-serializable (handle enums, etc.) - def make_serializable(val): - if val is None: - return None - if hasattr(val, 'value'): # Enum - return val.value - if hasattr(val, 'name') and hasattr(val, '__class__') and 'Enum' in str(type(val)): - return val.name - return val - - # Extract fields from the Pydantic model - fields = {} - for field_name, field_info in config_class.model_fields.items(): - field_type = field_info.annotation - default = make_serializable(field_info.default) - description = field_info.description or "" - - is_locked = field_name in LOCKED_FIELD_NAMES - - # Convert type to string - type_name = getattr(field_type, "__name__", str(field_type)) - if hasattr(field_type, "__origin__"): - type_name = str(field_type) - - locked_value = LOCKED_FIELDS.get("env", {}).get(field_name, default) - current_value = make_serializable(locked_value) if is_locked else default - - fields[field_name] = { - "type": type_name, - "default": default, - "description": description, - "locked": is_locked, - "current_value": current_value, - } - - return fields - - except Exception as e: - logger.warning("Could not introspect environment config: %s", e) - return {} - - -def _initialize_environments(): - """Initialize environment list on first use.""" - global _environments - if not _environments: - _environments = _scan_environments() - - -# ============================================================================ -# Subprocess Management -# ============================================================================ - -async def _spawn_training_run(run_state: RunState, config_path: Path): - """ - Spawn the three processes needed for training: - 1. run-api (Atropos API server) - 2. launch_training.py (Tinker trainer + inference server) - 3. environment.py serve (the Atropos environment) - """ - run_id = run_state.run_id - - _ensure_logs_dir() - - # Log file paths - api_log = LOGS_DIR / f"api_{run_id}.log" - trainer_log = LOGS_DIR / f"trainer_{run_id}.log" - env_log = LOGS_DIR / f"env_{run_id}.log" - - try: - # Step 1: Start the Atropos API server (run-api) - logger.info("[%s] Starting Atropos API server (run-api)...", run_id) - - # File must stay open while the subprocess runs; we store the handle - # on run_state so _stop_training_run() can close it when done. - api_log_file = open(api_log, "w", encoding="utf-8") # closed by _stop_training_run - run_state.api_log_file = api_log_file - run_state.api_process = subprocess.Popen( - ["run-api"], - stdout=api_log_file, - stderr=subprocess.STDOUT, - cwd=str(TINKER_ATROPOS_ROOT), - ) - - # Wait for API to start - await asyncio.sleep(5) - - if run_state.api_process.poll() is not None: - run_state.status = "failed" - run_state.error_message = f"API server exited with code {run_state.api_process.returncode}. Check {api_log}" - _stop_training_run(run_state) - return - - logger.info("[%s] Atropos API server started", run_id) - - # Step 2: Start the Tinker trainer - logger.info("[%s] Starting Tinker trainer: launch_training.py --config %s", run_id, config_path) - - trainer_log_file = open(trainer_log, "w", encoding="utf-8") # closed by _stop_training_run - run_state.trainer_log_file = trainer_log_file - run_state.trainer_process = subprocess.Popen( - [sys.executable, "launch_training.py", "--config", str(config_path)], - stdout=trainer_log_file, - stderr=subprocess.STDOUT, - cwd=str(TINKER_ATROPOS_ROOT), - env={**os.environ, "TINKER_API_KEY": os.getenv("TINKER_API_KEY", "")}, - ) - - # Wait for trainer to initialize (it starts FastAPI inference server on 8001) - logger.info("[%s] Waiting 30 seconds for trainer to initialize...", run_id) - await asyncio.sleep(30) - - if run_state.trainer_process.poll() is not None: - run_state.status = "failed" - run_state.error_message = f"Trainer exited with code {run_state.trainer_process.returncode}. Check {trainer_log}" - _stop_training_run(run_state) - return - - logger.info("[%s] Trainer started, inference server on port 8001", run_id) - - # Step 3: Start the environment - logger.info("[%s] Waiting 90 more seconds before starting environment...", run_id) - await asyncio.sleep(90) - - # Find the environment file - env_info = None - for env in _environments: - if env.name == run_state.environment: - env_info = env - break - - if not env_info: - run_state.status = "failed" - run_state.error_message = f"Environment '{run_state.environment}' not found" - _stop_training_run(run_state) - return - - logger.info("[%s] Starting environment: %s serve", run_id, env_info.file_path) - - env_log_file = open(env_log, "w", encoding="utf-8") # closed by _stop_training_run - run_state.env_log_file = env_log_file - run_state.env_process = subprocess.Popen( - [sys.executable, str(env_info.file_path), "serve", "--config", str(config_path)], - stdout=env_log_file, - stderr=subprocess.STDOUT, - cwd=str(TINKER_ATROPOS_ROOT), - ) - - # Wait for environment to connect - await asyncio.sleep(10) - - if run_state.env_process.poll() is not None: - run_state.status = "failed" - run_state.error_message = f"Environment exited with code {run_state.env_process.returncode}. Check {env_log}" - _stop_training_run(run_state) - return - - run_state.status = "running" - run_state.start_time = time.time() - logger.info("[%s] Training run started successfully!", run_id) - - # Start background monitoring - asyncio.create_task(_monitor_training_run(run_state)) - - except Exception as e: - run_state.status = "failed" - run_state.error_message = str(e) - _stop_training_run(run_state) - - -async def _monitor_training_run(run_state: RunState): - """Background task to monitor a training run.""" - while run_state.status == "running": - await asyncio.sleep(30) # Check every 30 seconds - - # Check if any process has died - if run_state.env_process and run_state.env_process.poll() is not None: - exit_code = run_state.env_process.returncode - if exit_code == 0: - run_state.status = "completed" - else: - run_state.status = "failed" - run_state.error_message = f"Environment process exited with code {exit_code}" - _stop_training_run(run_state) - break - - if run_state.trainer_process and run_state.trainer_process.poll() is not None: - exit_code = run_state.trainer_process.returncode - if exit_code == 0: - run_state.status = "completed" - else: - run_state.status = "failed" - run_state.error_message = f"Trainer process exited with code {exit_code}" - _stop_training_run(run_state) - break - - if run_state.api_process and run_state.api_process.poll() is not None: - run_state.status = "failed" - run_state.error_message = "API server exited unexpectedly" - _stop_training_run(run_state) - break - - -def _stop_training_run(run_state: RunState): - """Stop all processes for a training run.""" - # Stop in reverse order: env -> trainer -> api - if run_state.env_process and run_state.env_process.poll() is None: - logger.info("[%s] Stopping environment process...", run_state.run_id) - run_state.env_process.terminate() - try: - run_state.env_process.wait(timeout=10) - except subprocess.TimeoutExpired: - run_state.env_process.kill() - - if run_state.trainer_process and run_state.trainer_process.poll() is None: - logger.info("[%s] Stopping trainer process...", run_state.run_id) - run_state.trainer_process.terminate() - try: - run_state.trainer_process.wait(timeout=10) - except subprocess.TimeoutExpired: - run_state.trainer_process.kill() - - if run_state.api_process and run_state.api_process.poll() is None: - logger.info("[%s] Stopping API server...", run_state.run_id) - run_state.api_process.terminate() - try: - run_state.api_process.wait(timeout=10) - except subprocess.TimeoutExpired: - run_state.api_process.kill() - - if run_state.status == "running": - run_state.status = "stopped" - - # Close log file handles that were opened for subprocess stdout. - for attr in ("env_log_file", "trainer_log_file", "api_log_file"): - fh = getattr(run_state, attr, None) - if fh is not None: - try: - fh.close() - except Exception: - pass - setattr(run_state, attr, None) - - -# ============================================================================ -# Environment Discovery Tools -# ============================================================================ - -async def rl_list_environments() -> str: - """ - List all available RL environments. - - Scans tinker-atropos/tinker_atropos/environments/ for Python files - containing classes that inherit from BaseEnv. - - Returns information about each environment including: - - name: Environment identifier - - class_name: Python class name - - file_path: Path to the environment file - - description: Brief description if available - - TIP: To create or modify RL environments: - 1. Use terminal/file tools to inspect existing environments - 2. Study how they load datasets, define verifiers, and structure rewards - 3. Inspect HuggingFace datasets to understand data formats - 4. Copy an existing environment as a template - - Returns: - JSON string with list of environments - """ - _initialize_environments() - - response = { - "environments": [ - { - "name": env.name, - "class_name": env.class_name, - "file_path": env.file_path, - "description": env.description, - } - for env in _environments - ], - "count": len(_environments), - "tips": [ - "Use rl_select_environment(name) to select an environment", - "Read the file_path with file tools to understand how each environment works", - "Look for load_dataset(), score_answer(), get_next_item() methods", - ] - } - - return json.dumps(response, indent=2) - - -async def rl_select_environment(name: str) -> str: - """ - Select an RL environment for training. - - This loads the environment's configuration fields into memory. - After selecting, use rl_get_current_config() to see all configurable options - and rl_edit_config() to modify specific fields. - - Args: - name: Name of the environment to select (from rl_list_environments) - - Returns: - JSON string with selection result, file path, and configurable field count - - TIP: Read the returned file_path to understand how the environment works. - """ - global _current_env, _current_config - - _initialize_environments() - - env_info = None - for env in _environments: - if env.name == name: - env_info = env - break - - if not env_info: - return json.dumps({ - "error": f"Environment '{name}' not found", - "available": [e.name for e in _environments], - }, indent=2) - - _current_env = name - - # Dynamically discover config fields - config_fields = _get_env_config_fields(env_info.file_path) - _env_config_cache[name] = config_fields - - # Initialize current config with defaults for non-locked fields - _current_config = {} - for field_name, field_info in config_fields.items(): - if not field_info.get("locked", False): - _current_config[field_name] = field_info.get("default") - - # Auto-set wandb_name to "{env_name}-DATETIME" to avoid overlaps - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - _current_config["wandb_name"] = f"{name}-{timestamp}" - - return json.dumps({ - "message": f"Selected environment: {name}", - "environment": name, - "file_path": env_info.file_path, - }, indent=2) - - -# ============================================================================ -# Configuration Tools -# ============================================================================ - -async def rl_get_current_config() -> str: - """ - Get the current environment configuration. - - Returns all configurable fields for the selected environment. - Each environment may have different configuration options. - - Fields are divided into: - - configurable_fields: Can be changed with rl_edit_config() - - locked_fields: Infrastructure settings that cannot be changed - - Returns: - JSON string with configurable and locked fields - """ - if not _current_env: - return json.dumps({ - "error": "No environment selected. Use rl_select_environment(name) first.", - }, indent=2) - - config_fields = _env_config_cache.get(_current_env, {}) - - configurable = [] - locked = [] - - for field_name, field_info in config_fields.items(): - field_data = { - "name": field_name, - "type": field_info.get("type", "unknown"), - "default": field_info.get("default"), - "description": field_info.get("description", ""), - "current_value": _current_config.get(field_name, field_info.get("default")), - } - - if field_info.get("locked", False): - field_data["locked_value"] = LOCKED_FIELDS.get("env", {}).get(field_name) - locked.append(field_data) - else: - configurable.append(field_data) - - return json.dumps({ - "environment": _current_env, - "configurable_fields": configurable, - "locked_fields": locked, - "tip": "Use rl_edit_config(field, value) to change any configurable field.", - }, indent=2) - - -async def rl_edit_config(field: str, value: Any) -> str: - """ - Update a configuration field. - - Use rl_get_current_config() first to see available fields for the - selected environment. Each environment has different options. - - Locked fields (infrastructure settings) cannot be changed. - - Args: - field: Name of the field to update (from rl_get_current_config) - value: New value for the field - - Returns: - JSON string with updated config or error message - """ - if not _current_env: - return json.dumps({ - "error": "No environment selected. Use rl_select_environment(name) first.", - }, indent=2) - - config_fields = _env_config_cache.get(_current_env, {}) - - if field not in config_fields: - return json.dumps({ - "error": f"Unknown field '{field}'", - "available_fields": list(config_fields.keys()), - }, indent=2) - - field_info = config_fields[field] - if field_info.get("locked", False): - return json.dumps({ - "error": f"Field '{field}' is locked and cannot be changed", - "locked_value": LOCKED_FIELDS.get("env", {}).get(field), - }, indent=2) - - _current_config[field] = value - - return json.dumps({ - "message": f"Updated {field} = {value}", - "field": field, - "value": value, - "config": _current_config, - }, indent=2) - - -# ============================================================================ -# Training Management Tools -# ============================================================================ - -async def rl_start_training() -> str: - """ - Start a new RL training run with the current environment and config. - - Requires an environment to be selected first using rl_select_environment(). - Use rl_edit_config() to adjust configuration before starting. - - This spawns three processes: - 1. run-api (Atropos trajectory API) - 2. launch_training.py (Tinker trainer + inference server) - 3. environment.py serve (the selected environment) - - WARNING: Training runs take hours. Use rl_check_status() to monitor - progress (recommended: check every 30 minutes at most). - - Returns: - JSON string with run_id and initial status - """ - if not _current_env: - return json.dumps({ - "error": "No environment selected. Use rl_select_environment(name) first.", - }, indent=2) - - # Check API keys - if not os.getenv("TINKER_API_KEY"): - return json.dumps({ - "error": "TINKER_API_KEY not set. Add it to ~/.hermes/.env", - }, indent=2) - - # Find environment file - env_info = None - for env in _environments: - if env.name == _current_env: - env_info = env - break - - if not env_info or not Path(env_info.file_path).exists(): - return json.dumps({ - "error": f"Environment file not found for '{_current_env}'", - }, indent=2) - - # Generate run ID - run_id = str(uuid.uuid4())[:8] - - # Create config YAML - CONFIGS_DIR.mkdir(exist_ok=True) - config_path = CONFIGS_DIR / f"run_{run_id}.yaml" - - # Start with locked config as base - import copy - run_config = copy.deepcopy(LOCKED_FIELDS) - - if "env" not in run_config: - run_config["env"] = {} - - # Apply configurable fields - for field_name, value in _current_config.items(): - if value is not None and value != "": - run_config["env"][field_name] = value - - # Set WandB settings - wandb_project = _current_config.get("wandb_project", "atropos-tinker") - if "tinker" not in run_config: - run_config["tinker"] = {} - run_config["tinker"]["wandb_project"] = wandb_project - run_config["tinker"]["wandb_run_name"] = f"{_current_env}-{run_id}" - - if "wandb_name" in _current_config and _current_config["wandb_name"]: - run_config["env"]["wandb_name"] = _current_config["wandb_name"] - - with open(config_path, "w", encoding="utf-8") as f: - yaml.dump(run_config, f, default_flow_style=False) - - # Create run state - run_state = RunState( - run_id=run_id, - environment=_current_env, - config=_current_config.copy(), - status="starting", - wandb_project=wandb_project, - wandb_run_name=f"{_current_env}-{run_id}", - ) - - _active_runs[run_id] = run_state - - # Start training in background - asyncio.create_task(_spawn_training_run(run_state, config_path)) - - return json.dumps({ - "run_id": run_id, - "status": "starting", - "environment": _current_env, - "config": _current_config, - "wandb_project": wandb_project, - "wandb_run_name": f"{_current_env}-{run_id}", - "config_path": str(config_path), - "logs": { - "api": str(LOGS_DIR / f"api_{run_id}.log"), - "trainer": str(LOGS_DIR / f"trainer_{run_id}.log"), - "env": str(LOGS_DIR / f"env_{run_id}.log"), - }, - "message": "Training starting. Use rl_check_status(run_id) to monitor (recommended: every 30 minutes).", - }, indent=2) - - -async def rl_check_status(run_id: str) -> str: - """ - Get status and metrics for a training run. - - RATE LIMITED: For long-running training, this function enforces a - minimum 30-minute interval between checks for the same run_id. - - Args: - run_id: The run ID returned by rl_start_training() - - Returns: - JSON string with run status and metrics - """ - # Check rate limiting - now = time.time() - if run_id in _last_status_check: - elapsed = now - _last_status_check[run_id] - if elapsed < MIN_STATUS_CHECK_INTERVAL: - remaining = MIN_STATUS_CHECK_INTERVAL - elapsed - return json.dumps({ - "rate_limited": True, - "run_id": run_id, - "message": f"Rate limited. Next check available in {remaining/60:.0f} minutes.", - "next_check_in_seconds": remaining, - }, indent=2) - - _last_status_check[run_id] = now - - if run_id not in _active_runs: - return json.dumps({ - "error": f"Run '{run_id}' not found", - "active_runs": list(_active_runs.keys()), - }, indent=2) - - run_state = _active_runs[run_id] - - # Check process status - processes = { - "api": run_state.api_process.poll() if run_state.api_process else None, - "trainer": run_state.trainer_process.poll() if run_state.trainer_process else None, - "env": run_state.env_process.poll() if run_state.env_process else None, - } - - running_time = time.time() - run_state.start_time if run_state.start_time else 0 - - result = { - "run_id": run_id, - "status": run_state.status, - "environment": run_state.environment, - "running_time_minutes": running_time / 60, - "processes": { - name: "running" if code is None else f"exited ({code})" - for name, code in processes.items() - }, - "wandb_project": run_state.wandb_project, - "wandb_run_name": run_state.wandb_run_name, - "logs": { - "api": str(LOGS_DIR / f"api_{run_id}.log"), - "trainer": str(LOGS_DIR / f"trainer_{run_id}.log"), - "env": str(LOGS_DIR / f"env_{run_id}.log"), - }, - } - - if run_state.error_message: - result["error"] = run_state.error_message - - # Try to get WandB metrics if available - try: - import wandb - api = wandb.Api() - runs = api.runs( - f"{os.getenv('WANDB_ENTITY', 'nousresearch')}/{run_state.wandb_project}", - filters={"display_name": run_state.wandb_run_name} - ) - if runs: - wandb_run = runs[0] - result["wandb_url"] = wandb_run.url - result["metrics"] = { - "step": wandb_run.summary.get("_step", 0), - "reward_mean": wandb_run.summary.get("train/reward_mean"), - "percent_correct": wandb_run.summary.get("train/percent_correct"), - "eval_percent_correct": wandb_run.summary.get("eval/percent_correct"), - } - except Exception as e: - result["wandb_error"] = str(e) - - return json.dumps(result, indent=2) - - -async def rl_stop_training(run_id: str) -> str: - """ - Stop a running training job. - - Args: - run_id: The run ID to stop - - Returns: - JSON string with stop confirmation - """ - if run_id not in _active_runs: - return json.dumps({ - "error": f"Run '{run_id}' not found", - "active_runs": list(_active_runs.keys()), - }, indent=2) - - run_state = _active_runs[run_id] - - if run_state.status not in {"running", "starting"}: - return json.dumps({ - "message": f"Run '{run_id}' is not running (status: {run_state.status})", - }, indent=2) - - _stop_training_run(run_state) - - return json.dumps({ - "message": f"Stopped training run '{run_id}'", - "run_id": run_id, - "status": run_state.status, - }, indent=2) - - -async def rl_get_results(run_id: str) -> str: - """ - Get final results and metrics for a training run. - - Args: - run_id: The run ID to get results for - - Returns: - JSON string with final results - """ - if run_id not in _active_runs: - return json.dumps({ - "error": f"Run '{run_id}' not found", - }, indent=2) - - run_state = _active_runs[run_id] - - result = { - "run_id": run_id, - "status": run_state.status, - "environment": run_state.environment, - "wandb_project": run_state.wandb_project, - "wandb_run_name": run_state.wandb_run_name, - } - - # Get WandB metrics - try: - import wandb - api = wandb.Api() - runs = api.runs( - f"{os.getenv('WANDB_ENTITY', 'nousresearch')}/{run_state.wandb_project}", - filters={"display_name": run_state.wandb_run_name} - ) - if runs: - wandb_run = runs[0] - result["wandb_url"] = wandb_run.url - result["final_metrics"] = dict(wandb_run.summary) - result["history"] = [dict(row) for row in wandb_run.history(samples=10)] - except Exception as e: - result["wandb_error"] = str(e) - - return json.dumps(result, indent=2) - - -async def rl_list_runs() -> str: - """ - List all training runs (active and completed). - - Returns: - JSON string with list of runs and their status - """ - runs = [] - for run_id, run_state in _active_runs.items(): - runs.append({ - "run_id": run_id, - "environment": run_state.environment, - "status": run_state.status, - "wandb_run_name": run_state.wandb_run_name, - }) - - return json.dumps({ - "runs": runs, - "count": len(runs), - }, indent=2) - - -# ============================================================================ -# Inference Testing (via Atropos `process` mode with OpenRouter) -# ============================================================================ - -# Test models at different scales for robustness testing -# These are cheap, capable models on OpenRouter for testing parsing/scoring -TEST_MODELS = [ - {"id": "qwen/qwen3-8b", "name": "Qwen3 8B", "scale": "small"}, - {"id": "z-ai/glm-4.7-flash", "name": "GLM-4.7 Flash", "scale": "medium"}, - {"id": "minimax/minimax-m2.7", "name": "MiniMax M2.7", "scale": "large"}, -] - -# Default test parameters - quick but representative -DEFAULT_NUM_STEPS = 3 # Number of steps (items) to test -DEFAULT_GROUP_SIZE = 16 # Completions per item (like training) - - -async def rl_test_inference( - num_steps: int = DEFAULT_NUM_STEPS, - group_size: int = DEFAULT_GROUP_SIZE, - models: Optional[List[str]] = None, -) -> str: - """ - Quick inference test for any environment using Atropos's `process` mode. - - Runs a few steps of inference + scoring to validate: - - Environment loads correctly - - Prompt construction works - - Inference parsing is robust (tested with multiple model scales) - - Verifier/scoring logic works - - Default: 3 steps × 16 completions = 48 total rollouts per model. - Tests 3 models = 144 total rollouts. Quick sanity check. - - Test models (varying intelligence levels for robustness): - - qwen/qwen3-8b (small) - - zhipu-ai/glm-4-flash (medium) - - minimax/minimax-m1 (large) - - Args: - num_steps: Steps to run (default: 3, max recommended for testing) - group_size: Completions per step (default: 16, like training) - models: Optional model IDs to test. If None, uses all 3 test models. - - Returns: - JSON with results per model: steps_tested, accuracy, scores - """ - if not _current_env: - return json.dumps({ - "error": "No environment selected. Use rl_select_environment(name) first.", - }, indent=2) - - api_key = os.getenv("OPENROUTER_API_KEY") - if not api_key: - return json.dumps({ - "error": "OPENROUTER_API_KEY not set. Required for inference testing.", - }, indent=2) - - # Find environment info - env_info = None - for env in _environments: - if env.name == _current_env: - env_info = env - break - - if not env_info: - return json.dumps({ - "error": f"Environment '{_current_env}' not found", - }, indent=2) - - # Determine which models to test - if models: - test_models = [m for m in TEST_MODELS if m["id"] in models] - if not test_models: - test_models = [{"id": m, "name": m, "scale": "custom"} for m in models] - else: - test_models = TEST_MODELS - - # Calculate total rollouts for logging - total_rollouts_per_model = num_steps * group_size - total_rollouts = total_rollouts_per_model * len(test_models) - - results = { - "environment": _current_env, - "environment_file": env_info.file_path, - "test_config": { - "num_steps": num_steps, - "group_size": group_size, - "rollouts_per_model": total_rollouts_per_model, - "total_rollouts": total_rollouts, - }, - "models_tested": [], - } - - # Create output directory for test results - _ensure_logs_dir() - test_output_dir = LOGS_DIR / "inference_tests" - test_output_dir.mkdir(exist_ok=True) - - for model_info in test_models: - model_id = model_info["id"] - model_safe_name = model_id.replace("/", "_") - - print(f"\n{'='*60}") - print(f"Testing with {model_info['name']} ({model_id})") - print(f"{'='*60}") - - # Output file for this test run - output_file = test_output_dir / f"test_{_current_env}_{model_safe_name}.jsonl" - - # Generate unique run ID for wandb - test_run_id = str(uuid.uuid4())[:8] - wandb_run_name = f"test_inference_RSIAgent_{_current_env}_{test_run_id}" - - # Build the process command using Atropos's built-in CLI - # This runs the environment's actual code with OpenRouter as the inference backend - # We pass our locked settings + test-specific overrides via CLI args - cmd = [ - sys.executable, env_info.file_path, "process", - # Test-specific overrides - "--env.total_steps", str(num_steps), - "--env.group_size", str(group_size), - "--env.use_wandb", "true", # Enable wandb for test tracking - "--env.wandb_name", wandb_run_name, - "--env.data_path_to_save_groups", str(output_file), - # Use locked settings from our config - "--env.tokenizer_name", LOCKED_FIELDS["env"]["tokenizer_name"], - "--env.max_token_length", str(LOCKED_FIELDS["env"]["max_token_length"]), - "--env.max_num_workers", str(LOCKED_FIELDS["env"]["max_num_workers"]), - "--env.max_batches_offpolicy", str(LOCKED_FIELDS["env"]["max_batches_offpolicy"]), - # OpenRouter config for inference testing - # IMPORTANT: Use server_type=openai for OpenRouter (not sglang) - # sglang is only for actual training with Tinker's inference server - "--openai.base_url", "https://openrouter.ai/api/v1", - "--openai.api_key", api_key, - "--openai.model_name", model_id, - "--openai.server_type", "openai", # OpenRouter is OpenAI-compatible - "--openai.health_check", "false", # OpenRouter doesn't have health endpoint - ] - - # Debug: Print the full command - cmd_str = " ".join(str(c) for c in cmd) - # Hide API key in printed output - cmd_display = cmd_str.replace(api_key, "***API_KEY***") - print(f"Command: {cmd_display}") - print(f"Working dir: {TINKER_ATROPOS_ROOT}") - print(f"WandB run: {wandb_run_name}") - print(f" {num_steps} steps × {group_size} completions = {total_rollouts_per_model} rollouts") - - model_results = { - "model": model_id, - "name": model_info["name"], - "scale": model_info["scale"], - "wandb_run": wandb_run_name, - "output_file": str(output_file), - "steps": [], - "steps_tested": 0, - "total_completions": 0, - "correct_completions": 0, - } - - try: - # Run the process command with real-time output streaming - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(TINKER_ATROPOS_ROOT), - ) - - # Stream output in real-time while collecting for logs - stdout_lines = [] - stderr_lines = [] - log_file = test_output_dir / f"test_{_current_env}_{model_safe_name}.log" - - async def read_stream(stream, lines_list, prefix=""): - """Read stream line by line and print in real-time.""" - while True: - line = await stream.readline() - if not line: - break - decoded = line.decode().rstrip() - lines_list.append(decoded) - # Print progress-related lines in real-time - if any(kw in decoded.lower() for kw in ['processing', 'group', 'step', 'progress', '%', 'completed']): - print(f" {prefix}{decoded}") - - # Read both streams concurrently with timeout - try: - await asyncio.wait_for( - asyncio.gather( - read_stream(process.stdout, stdout_lines, "📊 "), - read_stream(process.stderr, stderr_lines, "⚠️ "), - ), - timeout=600, # 10 minute timeout per model - ) - except asyncio.TimeoutError: - process.kill() - raise - - await process.wait() - - # Combine output for logging - stdout_text = "\n".join(stdout_lines) - stderr_text = "\n".join(stderr_lines) - - # Write logs to files for inspection outside CLI - with open(log_file, "w", encoding="utf-8") as f: - f.write(f"Command: {cmd_display}\n") - f.write(f"Working dir: {TINKER_ATROPOS_ROOT}\n") - f.write(f"Return code: {process.returncode}\n") - f.write(f"\n{'='*60}\n") - f.write(f"STDOUT:\n{'='*60}\n") - f.write(stdout_text or "(empty)\n") - f.write(f"\n{'='*60}\n") - f.write(f"STDERR:\n{'='*60}\n") - f.write(stderr_text or "(empty)\n") - - print(f" Log file: {log_file}") - - if process.returncode != 0: - model_results["error"] = f"Process exited with code {process.returncode}" - model_results["stderr"] = stderr_text[-1000:] - model_results["stdout"] = stdout_text[-1000:] - model_results["log_file"] = str(log_file) - print(f"\n ❌ Error: {model_results['error']}") - # Print last few lines of stderr for debugging - if stderr_lines: - print(" Last errors:") - for line in stderr_lines[-5:]: - print(f" {line}") - else: - print("\n ✅ Process completed successfully") - print(f" Output file: {output_file}") - print(f" File exists: {output_file.exists()}") - - # Parse the output JSONL file - if output_file.exists(): - # Read JSONL file (one JSON object per line = one step) - with open(output_file, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - item = json.loads(line) - scores = item.get("scores", []) - model_results["steps_tested"] += 1 - model_results["total_completions"] += len(scores) - correct = sum(1 for s in scores if s > 0) - model_results["correct_completions"] += correct - - model_results["steps"].append({ - "step": model_results["steps_tested"], - "completions": len(scores), - "correct": correct, - "scores": scores, - }) - except json.JSONDecodeError: - continue - - print(f" Completed {model_results['steps_tested']} steps") - else: - model_results["error"] = f"Output file not created: {output_file}" - - except asyncio.TimeoutError: - model_results["error"] = "Process timed out after 10 minutes" - print(" Timeout!") - except Exception as e: - model_results["error"] = str(e) - print(f" Error: {e}") - - # Calculate stats - if model_results["total_completions"] > 0: - model_results["accuracy"] = round( - model_results["correct_completions"] / model_results["total_completions"], 3 - ) - else: - model_results["accuracy"] = 0 - - if model_results["steps_tested"] > 0: - steps_with_correct = sum(1 for s in model_results["steps"] if s.get("correct", 0) > 0) - model_results["steps_with_correct"] = steps_with_correct - model_results["step_success_rate"] = round( - steps_with_correct / model_results["steps_tested"], 3 - ) - else: - model_results["steps_with_correct"] = 0 - model_results["step_success_rate"] = 0 - - print(f" Results: {model_results['correct_completions']}/{model_results['total_completions']} correct") - print(f" Accuracy: {model_results['accuracy']:.1%}") - - results["models_tested"].append(model_results) - - # Overall summary - working_models = [m for m in results["models_tested"] if m.get("steps_tested", 0) > 0] - - results["summary"] = { - "steps_requested": num_steps, - "models_tested": len(test_models), - "models_succeeded": len(working_models), - "best_model": max(working_models, key=lambda x: x.get("accuracy", 0))["model"] if working_models else None, - "avg_accuracy": round( - sum(m.get("accuracy", 0) for m in working_models) / len(working_models), 3 - ) if working_models else 0, - "environment_working": bool(working_models), - "output_directory": str(test_output_dir), - } - - return json.dumps(results, indent=2) - - -# ============================================================================ -# Requirements Check -# ============================================================================ - -def check_rl_python_version() -> bool: - """ - Check if Python version meets the minimum for RL tools. - - tinker-atropos depends on the 'tinker' package which requires Python >= 3.11. - """ - return sys.version_info >= (3, 11) - - -def check_rl_api_keys() -> bool: - """ - Check if required API keys and Python version are available. - - RL training requires: - - Python >= 3.11 (tinker package requirement) - - TINKER_API_KEY for the Tinker training API - - WANDB_API_KEY for Weights & Biases metrics - """ - if not check_rl_python_version(): - return False - tinker_key = os.getenv("TINKER_API_KEY") - wandb_key = os.getenv("WANDB_API_KEY") - return bool(tinker_key) and bool(wandb_key) - - -def get_missing_keys() -> List[str]: - """ - Get list of missing requirements for RL tools (API keys and Python version). - """ - missing = [] - if not check_rl_python_version(): - missing.append(f"Python >= 3.11 (current: {sys.version_info.major}.{sys.version_info.minor})") - if not os.getenv("TINKER_API_KEY"): - missing.append("TINKER_API_KEY") - if not os.getenv("WANDB_API_KEY"): - missing.append("WANDB_API_KEY") - return missing - - -# --------------------------------------------------------------------------- -# Schemas + Registry -# --------------------------------------------------------------------------- -from tools.registry import registry - -RL_LIST_ENVIRONMENTS_SCHEMA = {"name": "rl_list_environments", "description": "List all available RL environments. Returns environment names, paths, and descriptions. TIP: Read the file_path with file tools to understand how each environment works (verifiers, data loading, rewards).", "parameters": {"type": "object", "properties": {}, "required": []}} -RL_SELECT_ENVIRONMENT_SCHEMA = {"name": "rl_select_environment", "description": "Select an RL environment for training. Loads the environment's default configuration. After selecting, use rl_get_current_config() to see settings and rl_edit_config() to modify them.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "Name of the environment to select (from rl_list_environments)"}}, "required": ["name"]}} -RL_GET_CURRENT_CONFIG_SCHEMA = {"name": "rl_get_current_config", "description": "Get the current environment configuration. Returns only fields that can be modified: group_size, max_token_length, total_steps, steps_per_eval, use_wandb, wandb_name, max_num_workers.", "parameters": {"type": "object", "properties": {}, "required": []}} -RL_EDIT_CONFIG_SCHEMA = {"name": "rl_edit_config", "description": "Update a configuration field. Use rl_get_current_config() first to see all available fields for the selected environment. Each environment has different configurable options. Infrastructure settings (tokenizer, URLs, lora_rank, learning_rate) are locked.", "parameters": {"type": "object", "properties": {"field": {"type": "string", "description": "Name of the field to update (get available fields from rl_get_current_config)"}, "value": {"description": "New value for the field"}}, "required": ["field", "value"]}} -RL_START_TRAINING_SCHEMA = {"name": "rl_start_training", "description": "Start a new RL training run with the current environment and config. Most training parameters (lora_rank, learning_rate, etc.) are fixed. Use rl_edit_config() to set group_size, batch_size, wandb_project before starting. WARNING: Training takes hours.", "parameters": {"type": "object", "properties": {}, "required": []}} -RL_CHECK_STATUS_SCHEMA = {"name": "rl_check_status", "description": "Get status and metrics for a training run. RATE LIMITED: enforces 30-minute minimum between checks for the same run. Returns WandB metrics: step, state, reward_mean, loss, percent_correct.", "parameters": {"type": "object", "properties": {"run_id": {"type": "string", "description": "The run ID from rl_start_training()"}}, "required": ["run_id"]}} -RL_STOP_TRAINING_SCHEMA = {"name": "rl_stop_training", "description": "Stop a running training job. Use if metrics look bad, training is stagnant, or you want to try different settings.", "parameters": {"type": "object", "properties": {"run_id": {"type": "string", "description": "The run ID to stop"}}, "required": ["run_id"]}} -RL_GET_RESULTS_SCHEMA = {"name": "rl_get_results", "description": "Get final results and metrics for a completed training run. Returns final metrics and path to trained weights.", "parameters": {"type": "object", "properties": {"run_id": {"type": "string", "description": "The run ID to get results for"}}, "required": ["run_id"]}} -RL_LIST_RUNS_SCHEMA = {"name": "rl_list_runs", "description": "List all training runs (active and completed) with their status.", "parameters": {"type": "object", "properties": {}, "required": []}} -RL_TEST_INFERENCE_SCHEMA = {"name": "rl_test_inference", "description": "Quick inference test for any environment. Runs a few steps of inference + scoring using OpenRouter. Default: 3 steps x 16 completions = 48 rollouts per model, testing 3 models = 144 total. Tests environment loading, prompt construction, inference parsing, and verifier logic. Use BEFORE training to catch issues.", "parameters": {"type": "object", "properties": {"num_steps": {"type": "integer", "description": "Number of steps to run (default: 3, recommended max for testing)", "default": 3}, "group_size": {"type": "integer", "description": "Completions per step (default: 16, like training)", "default": 16}, "models": {"type": "array", "items": {"type": "string"}, "description": "Optional list of OpenRouter model IDs. Default: qwen/qwen3-8b, z-ai/glm-4.7-flash, minimax/minimax-m2.7"}}, "required": []}} - -_rl_env = ["TINKER_API_KEY", "WANDB_API_KEY"] - -registry.register(name="rl_list_environments", emoji="🧪", toolset="rl", schema=RL_LIST_ENVIRONMENTS_SCHEMA, - handler=lambda args, **kw: rl_list_environments(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_select_environment", emoji="🧪", toolset="rl", schema=RL_SELECT_ENVIRONMENT_SCHEMA, - handler=lambda args, **kw: rl_select_environment(name=args.get("name", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_get_current_config", emoji="🧪", toolset="rl", schema=RL_GET_CURRENT_CONFIG_SCHEMA, - handler=lambda args, **kw: rl_get_current_config(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_edit_config", emoji="🧪", toolset="rl", schema=RL_EDIT_CONFIG_SCHEMA, - handler=lambda args, **kw: rl_edit_config(field=args.get("field", ""), value=args.get("value")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_start_training", emoji="🧪", toolset="rl", schema=RL_START_TRAINING_SCHEMA, - handler=lambda args, **kw: rl_start_training(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_check_status", emoji="🧪", toolset="rl", schema=RL_CHECK_STATUS_SCHEMA, - handler=lambda args, **kw: rl_check_status(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_stop_training", emoji="🧪", toolset="rl", schema=RL_STOP_TRAINING_SCHEMA, - handler=lambda args, **kw: rl_stop_training(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_get_results", emoji="🧪", toolset="rl", schema=RL_GET_RESULTS_SCHEMA, - handler=lambda args, **kw: rl_get_results(run_id=args.get("run_id", "")), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_list_runs", emoji="🧪", toolset="rl", schema=RL_LIST_RUNS_SCHEMA, - handler=lambda args, **kw: rl_list_runs(), check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) -registry.register(name="rl_test_inference", emoji="🧪", toolset="rl", schema=RL_TEST_INFERENCE_SCHEMA, - handler=lambda args, **kw: rl_test_inference(num_steps=args.get("num_steps", 3), group_size=args.get("group_size", 16), models=args.get("models")), - check_fn=check_rl_api_keys, requires_env=_rl_env, is_async=True) diff --git a/toolsets.py b/toolsets.py index c664136c52a..8ec45f11a2f 100644 --- a/toolsets.py +++ b/toolsets.py @@ -169,18 +169,7 @@ TOOLSETS = { "tools": ["send_message"], "includes": [] }, - - "rl": { - "description": "RL training tools for running reinforcement learning on Tinker-Atropos", - "tools": [ - "rl_list_environments", "rl_select_environment", - "rl_get_current_config", "rl_edit_config", - "rl_start_training", "rl_check_status", - "rl_stop_training", "rl_get_results", - "rl_list_runs", "rl_test_inference" - ], - "includes": [] - }, + "file": { "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)", @@ -390,7 +379,7 @@ TOOLSETS = { # Mirrors hermes-cli so cron's "default" toolset is the same set of # core tools users see interactively — then `hermes tools` filters # them down per the platform config. _DEFAULT_OFF_TOOLSETS (moa, - # homeassistant, rl) are excluded by _get_platform_tools() unless + # homeassistant) are excluded by _get_platform_tools() unless # the user explicitly enables them. "description": "Default cron toolset - same core tools as hermes-cli; gated by `hermes tools`", "tools": _HERMES_CORE_TOOLS, diff --git a/uv.lock b/uv.lock index a519cc2b194..72cef3b0cdd 100644 --- a/uv.lock +++ b/uv.lock @@ -301,22 +301,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" }, ] -[[package]] -name = "altair" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "jsonschema", marker = "python_full_version >= '3.12'" }, - { name = "narwhals", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12' and python_full_version < '3.15'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/184a89bd5feba14ff3c41cfaf1dd8a82c05f5ceedbc92145e17042eb08a4/altair-6.0.0.tar.gz", hash = "sha256:614bf5ecbe2337347b590afb111929aa9c16c9527c4887d96c9bc7f6640756b4", size = 763834, upload-time = "2025-11-12T08:59:11.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/33/ef2f2409450ef6daa61459d5de5c08128e7d3edb773fefd0a324d1310238/altair-6.0.0-py3-none-any.whl", hash = "sha256:09ae95b53d5fe5b16987dccc785a7af8588f2dca50de1e7a156efa8a461515f8", size = 795410, upload-time = "2025-11-12T08:59:09.804Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -354,15 +338,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, ] -[[package]] -name = "antlr4-python3-runtime" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/5f/2cdf6f7aca3b20d3f316e9f505292e1f256a32089bd702034c29ebde6242/antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916", size = 117467, upload-time = "2024-08-03T19:00:12.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, -] - [[package]] name = "anyio" version = "4.12.1" @@ -436,34 +411,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] -[[package]] -name = "atroposlib" -version = "0.4.0" -source = { git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30#c20c85256e5a45ad31edf8b7276e9c5ee1995a30" } -dependencies = [ - { name = "aiofiles" }, - { name = "aiohttp" }, - { name = "datasets" }, - { name = "fastapi" }, - { name = "gymnasium" }, - { name = "hf-transfer" }, - { name = "jinja2" }, - { name = "jsonlines" }, - { name = "markdown" }, - { name = "math-verify" }, - { name = "nltk" }, - { name = "numpy" }, - { name = "openai" }, - { name = "polars" }, - { name = "pydantic-cli" }, - { name = "rich" }, - { name = "tenacity" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "wandb" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -562,15 +509,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "boto3" version = "1.42.89" @@ -599,15 +537,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, ] -[[package]] -name = "cachetools" -version = "5.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, -] - [[package]] name = "cbor2" version = "5.8.0" @@ -809,15 +738,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -827,88 +747,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - [[package]] name = "croniter" version = "6.0.0" @@ -1018,15 +856,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" }, ] -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - [[package]] name = "darabonba-core" version = "1.0.5" @@ -1040,31 +869,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" }, ] -[[package]] -name = "datasets" -version = "4.8.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dill" }, - { name = "filelock" }, - { name = "fsspec", extra = ["http"] }, - { name = "httpx" }, - { name = "huggingface-hub" }, - { name = "multiprocess" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pyarrow" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "xxhash" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/22/73e46ac7a8c25e7ef0b3bd6f10da3465021d90219a32eb0b4d2afea4c56e/datasets-4.8.4.tar.gz", hash = "sha256:a1429ed853275ce7943a01c6d2e25475b4501eb758934362106a280470df3a52", size = 604382, upload-time = "2026-03-23T14:21:17.987Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl", hash = "sha256:cdc8bee4698e549d78bf1fed6aea2eebc760b22b084f07e6fc020c6577a6ce6d", size = 526991, upload-time = "2026-03-23T14:21:15.89Z" }, -] - [[package]] name = "davey" version = "0.1.4" @@ -1290,15 +1094,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] -[[package]] -name = "dill" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, -] - [[package]] name = "dingtalk-stream" version = "0.24.3" @@ -1436,15 +1231,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/48/265c2935467ac1dbcb7c5b54cd8a2f579cbb263db6bfc0e0c8fe4bc79c02/fal_client-0.13.1-py3-none-any.whl", hash = "sha256:967a01f3a4112d485a30f8f3a0e678c6ff5b919eb9c5d480315cfc30a79fc037", size = 19265, upload-time = "2026-02-20T07:21:28.143Z" }, ] -[[package]] -name = "farama-notifications" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/2c/8384832b7a6b1fd6ba95bbdcae26e7137bb3eedc955c42fd5cdcc086cfbf/Farama-Notifications-0.0.4.tar.gz", hash = "sha256:13fceff2d14314cf80703c8266462ebf3733c7d165336eee998fc58e545efd18", size = 2131, upload-time = "2023-02-27T18:28:41.047Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/2c/ffc08c54c05cdce6fbed2aeebc46348dbe180c6d2c541c7af7ba0aa5f5f8/Farama_Notifications-0.0.4-py3-none-any.whl", hash = "sha256:14de931035a41961f7c056361dc7f980762a143d05791ef5794a751a2caf05ae", size = 2511, upload-time = "2023-02-27T18:28:39.447Z" }, -] - [[package]] name = "fastapi" version = "0.133.1" @@ -1477,58 +1263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, ] -[[package]] -name = "fastuuid" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, - { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, - { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, - { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, - { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, - { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, - { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, - { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, - { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, - { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, - { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, - { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, - { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, - { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, - { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, - { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, - { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, -] - [[package]] name = "filelock" version = "3.24.3" @@ -1576,55 +1310,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] -[[package]] -name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, - { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, - { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, - { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, - { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, - { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, - { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -1739,35 +1424,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] -[package.optional-dependencies] -http = [ - { name = "aiohttp" }, -] - -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.46" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, -] - [[package]] name = "google-api-core" version = "2.30.3" @@ -1851,53 +1507,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] -[[package]] -name = "greenlet" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, - { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, - { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, - { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, - { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, - { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, - { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, - { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, -] - [[package]] name = "grpclib" version = "0.4.9" @@ -1911,21 +1520,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, ] -[[package]] -name = "gymnasium" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "farama-notifications" }, - { name = "numpy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/59/653a9417d98ed3e29ef9734ba52c3495f6c6823b8d5c0c75369f25111708/gymnasium-1.2.3.tar.gz", hash = "sha256:2b2cb5b5fbbbdf3afb9f38ca952cc48aa6aa3e26561400d940747fda3ad42509", size = 829230, upload-time = "2025-12-18T16:51:10.234Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/d3/ea5f088e3638dbab12e5c20d6559d5b3bdaeaa1f2af74e526e6815836285/gymnasium-1.2.3-py3-none-any.whl", hash = "sha256:e6314bba8f549c7fdcc8677f7cd786b64908af6e79b57ddaa5ce1825bffb5373", size = 952113, upload-time = "2025-12-18T16:51:08.445Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -2084,13 +1678,6 @@ pty = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, ] -rl = [ - { name = "atroposlib" }, - { name = "fastapi" }, - { name = "tinker" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "wandb" }, -] slack = [ { name = "aiohttp" }, { name = "slack-bolt" }, @@ -2138,9 +1725,6 @@ web = [ { name = "fastapi" }, { name = "uvicorn", extra = ["standard"] }, ] -yc-bench = [ - { name = "yc-bench", marker = "python_full_version >= '3.12'" }, -] youtube = [ { name = "youtube-transcript-api" }, ] @@ -2157,7 +1741,6 @@ requires-dist = [ { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, - { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, { name = "croniter", specifier = "==6.0.0" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, @@ -2168,7 +1751,6 @@ requires-dist = [ { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, - { name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" }, { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, { name = "fire", specifier = "==0.7.1" }, @@ -2240,49 +1822,13 @@ requires-dist = [ { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, { name = "tenacity", specifier = "==9.1.4" }, - { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, - { name = "wandb", marker = "extra == 'rl'", specifier = "==0.25.1" }, - { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] - -[[package]] -name = "hf-transfer" -version = "0.1.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/eb/8fc64f40388c29ce8ce3b2b180a089d4d6b25b1d0d232d016704cb852104/hf_transfer-0.1.9.tar.gz", hash = "sha256:035572865dab29d17e783fbf1e84cf1cb24f3fcf8f1b17db1cfc7fdf139f02bf", size = 25201, upload-time = "2025-01-07T10:05:12.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/78/0dce00208f585fae675f40033ef9a30dedfa83665d5ac79f16beb4a0a6c2/hf_transfer-0.1.9-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:6e94e8822da79573c9b6ae4d6b2f847c59a7a06c5327d7db20751b68538dc4f6", size = 1386084, upload-time = "2025-01-07T10:04:47.874Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/3d60b1a9e9f29a2152aa66c823bf5e399ae7be3fef310ff0de86779c5d2d/hf_transfer-0.1.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ebc4ab9023414880c8b1d3c38174d1c9989eb5022d37e814fa91a3060123eb0", size = 1343558, upload-time = "2025-01-07T10:04:42.313Z" }, - { url = "https://files.pythonhosted.org/packages/fb/38/130a5ac3747f104033591bcac1c961cb1faadfdc91704f59b09c0b465ff2/hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8674026f21ed369aa2a0a4b46000aca850fc44cd2b54af33a172ce5325b4fc82", size = 3726676, upload-time = "2025-01-07T10:04:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/15/a1/f4e27c5ad17aac616ae0849e2aede5aae31db8267a948c6b3eeb9fd96446/hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a736dfbb2c84f5a2c975478ad200c0c8bfcb58a25a35db402678fb87ce17fa4", size = 3062920, upload-time = "2025-01-07T10:04:16.297Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0d/727abdfba39bc3f1132cfa4c970588c2c0bb0d82fe2d645cc10f4e2f8e0b/hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:504b8427fd785dd8546d53b9fafe6e436bd7a3adf76b9dce556507650a7b4567", size = 3578681, upload-time = "2025-01-07T10:04:29.702Z" }, - { url = "https://files.pythonhosted.org/packages/50/d0/2b213eb1ea8b1252ccaf1a6c804d0aba03fea38aae4124df6a3acb70511a/hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c7fc1b85f4d0f76e452765d7648c9f4bfd0aedb9ced2ae1ebfece2d8cfaf8e2", size = 3398837, upload-time = "2025-01-07T10:04:22.778Z" }, - { url = "https://files.pythonhosted.org/packages/8c/8a/79dbce9006e0bd6b74516f97451a7b7c64dbbb426df15d901dd438cfeee3/hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d991376f0eac70a60f0cbc95602aa708a6f7c8617f28b4945c1431d67b8e3c8", size = 3546986, upload-time = "2025-01-07T10:04:36.415Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f7/9ac239b6ee6fe0bad130325d987a93ea58c4118e50479f0786f1733b37e8/hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ac4eddcd99575ed3735ed911ddf9d1697e2bd13aa3f0ad7e3904dd4863842e", size = 4071715, upload-time = "2025-01-07T10:04:53.224Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a3/0ed697279f5eeb7a40f279bd783cf50e6d0b91f24120dcf66ef2cf8822b4/hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:57fd9880da1ee0f47250f735f791fab788f0aa1ee36afc49f761349869c8b4d9", size = 3388081, upload-time = "2025-01-07T10:04:57.818Z" }, - { url = "https://files.pythonhosted.org/packages/dc/eb/47e477bdf1d784f31c7540db6cc8c354b777e51a186897a7abda34517f36/hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:5d561f0520f493c66b016d99ceabe69c23289aa90be38dd802d2aef279f15751", size = 3658654, upload-time = "2025-01-07T10:05:03.168Z" }, - { url = "https://files.pythonhosted.org/packages/45/07/6661e43fbee09594a8a5e9bb778107d95fe38dac4c653982afe03d32bd4d/hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a5b366d34cd449fe9b20ef25941e6eef0460a2f74e7389f02e673e1f88ebd538", size = 3690551, upload-time = "2025-01-07T10:05:09.238Z" }, - { url = "https://files.pythonhosted.org/packages/81/f5/461d2e5f307e5048289b1168d5c642ae3bb2504e88dff1a38b92ed990a21/hf_transfer-0.1.9-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e66acf91df4a8b72f60223059df3003062a5ae111757187ed1a06750a30e911b", size = 1393046, upload-time = "2025-01-07T10:04:51.003Z" }, - { url = "https://files.pythonhosted.org/packages/41/ba/8d9fd9f1083525edfcb389c93738c802f3559cb749324090d7109c8bf4c2/hf_transfer-0.1.9-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:8669dbcc7a3e2e8d61d42cd24da9c50d57770bd74b445c65123291ca842a7e7a", size = 1348126, upload-time = "2025-01-07T10:04:45.712Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/cd7885bc9959421065a6fae0fe67b6c55becdeda4e69b873e52976f9a9f0/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fd0167c4407a3bc4cdd0307e65ada2294ec04f1813d8a69a5243e379b22e9d8", size = 3728604, upload-time = "2025-01-07T10:04:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2e/a072cf196edfeda3310c9a5ade0a0fdd785e6154b3ce24fc738c818da2a7/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee8b10afedcb75f71091bcc197c526a6ebf5c58bbbadb34fdeee6160f55f619f", size = 3064995, upload-time = "2025-01-07T10:04:18.663Z" }, - { url = "https://files.pythonhosted.org/packages/c2/84/aec9ef4c0fab93c1ea2b1badff38c78b4b2f86f0555b26d2051dbc920cde/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5828057e313de59300dd1abb489444bc452efe3f479d3c55b31a8f680936ba42", size = 3580908, upload-time = "2025-01-07T10:04:32.834Z" }, - { url = "https://files.pythonhosted.org/packages/29/63/b560d39651a56603d64f1a0212d0472a44cbd965db2fa62b99d99cb981bf/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc6bd19e1cc177c66bdef15ef8636ad3bde79d5a4f608c158021153b4573509d", size = 3400839, upload-time = "2025-01-07T10:04:26.122Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d8/f87ea6f42456254b48915970ed98e993110521e9263472840174d32c880d/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdca9bfb89e6f8f281890cc61a8aff2d3cecaff7e1a4d275574d96ca70098557", size = 3552664, upload-time = "2025-01-07T10:04:40.123Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/1267c39b65fc8f4e2113b36297320f102718bf5799b544a6cbe22013aa1d/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:89a23f58b7b7effbc047b8ca286f131b17728c99a9f972723323003ffd1bb916", size = 4073732, upload-time = "2025-01-07T10:04:55.624Z" }, - { url = "https://files.pythonhosted.org/packages/82/1a/9c748befbe3decf7cb415e34f8a0c3789a0a9c55910dea73d581e48c0ce5/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc7fff1345980d6c0ebb92c811d24afa4b98b3e07ed070c8e38cc91fd80478c5", size = 3390096, upload-time = "2025-01-07T10:04:59.98Z" }, - { url = "https://files.pythonhosted.org/packages/72/85/4c03da147b6b4b7cb12e074d3d44eee28604a387ed0eaf7eaaead5069c57/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a6bd16c667ebe89a069ca163060127a794fa3a3525292c900b8c8cc47985b0d", size = 3664743, upload-time = "2025-01-07T10:05:05.416Z" }, - { url = "https://files.pythonhosted.org/packages/e7/6e/e597b04f753f1b09e6893075d53a82a30c13855cbaa791402695b01e369f/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2fde99d502093ade3ab1b53f80da18480e9902aa960dab7f74fb1b9e5bc5746", size = 3695243, upload-time = "2025-01-07T10:05:11.411Z" }, - { url = "https://files.pythonhosted.org/packages/09/89/d4e234727a26b2546c8fb70a276cd924260d60135f2165bf8b9ed67bb9a4/hf_transfer-0.1.9-cp38-abi3-win32.whl", hash = "sha256:435cc3cdc8524ce57b074032b8fd76eed70a4224d2091232fa6a8cef8fd6803e", size = 1086605, upload-time = "2025-01-07T10:05:18.873Z" }, - { url = "https://files.pythonhosted.org/packages/a1/14/f1e15b851d1c2af5b0b1a82bf8eb10bda2da62d98180220ba6fd8879bb5b/hf_transfer-0.1.9-cp38-abi3-win_amd64.whl", hash = "sha256:16f208fc678911c37e11aa7b586bc66a37d02e636208f18b6bc53d29b5df40ad", size = 1160240, upload-time = "2025-01-07T10:05:14.324Z" }, -] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -2433,9 +1979,6 @@ wheels = [ ] [package.optional-dependencies] -http2 = [ - { name = "h2" }, -] socks = [ { name = "socksio" }, ] @@ -2615,27 +2158,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "jsonlines" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -2663,112 +2185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "kiwisolver" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, - { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, - { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, - { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, - { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, - { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, - { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, -] - [[package]] name = "lark-oapi" version = "1.5.3" @@ -2784,42 +2200,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, ] -[[package]] -name = "latex2sympy2-extended" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "sympy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/75/456da2da05f6380ea96e6ea804ab2c03e41fc3ed80052307fe8efe6ea20e/latex2sympy2_extended-1.11.0.tar.gz", hash = "sha256:9695657c81b50abba2636638638618db59f4663ed2a4a12d62cef74a40e28fec", size = 207023, upload-time = "2026-01-10T01:43:21.319Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/61/f75cd1fa54d8434276126034aed54dd120747de9a8fa013cdd79545ccbeb/latex2sympy2_extended-1.11.0-py3-none-any.whl", hash = "sha256:aebb77d52ce269e25028e4bea89ddb14d242ba36bcf7b636496fb5fd9728d234", size = 209050, upload-time = "2026-01-10T01:43:19.458Z" }, -] - -[[package]] -name = "litellm" -version = "1.81.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp", marker = "python_full_version >= '3.12'" }, - { name = "click", marker = "python_full_version >= '3.12'" }, - { name = "fastuuid", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "importlib-metadata", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "jsonschema", marker = "python_full_version >= '3.12'" }, - { name = "openai", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, - { name = "tiktoken", marker = "python_full_version >= '3.12'" }, - { name = "tokenizers", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/0c/62a0fdc5adae6d205338f9239175aa6a93818e58b75cf000a9c7214a3d9f/litellm-1.81.15.tar.gz", hash = "sha256:a8a6277a53280762051c5818ebc76dd5f036368b9426c6f21795ae7f1ac6ebdc", size = 16597039, upload-time = "2026-02-24T06:52:50.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/fd/da11826dda0d332e360b9ead6c0c992d612ecb85b00df494823843cfcda3/litellm-1.81.15-py3-none-any.whl", hash = "sha256:2fa253658702509ce09fe0e172e5a47baaadf697fb0f784c7fd4ff665ae76ae1", size = 14682123, upload-time = "2026-02-24T06:52:48.084Z" }, -] - [[package]] name = "markdown" version = "3.10.2" @@ -2924,82 +2304,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/70/bb89f807a6a6704bdc4d6f850d5d32954f6c1965e3248e31455defdf2f30/marshmallow-4.2.2-py3-none-any.whl", hash = "sha256:084a9466111b7ec7183ca3a65aed758739af919fedc5ebdab60fb39d6b4dc121", size = 48454, upload-time = "2026-02-04T15:47:02.013Z" }, ] -[[package]] -name = "math-verify" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "latex2sympy2-extended" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4f/12/b8d13b581e110ac2f724a2351a8361a70fa36d057eb945d6379e8747c256/math_verify-0.9.0.tar.gz", hash = "sha256:45ac6c61344ba056b9e99a660a4bc8d044ed408f730aed68c60435aa5eec4645", size = 60329, upload-time = "2026-01-10T01:48:33.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/76/6b4969bccc842b6567f7e6ee015684b9428a9b7fcbdf479e73716f43597f/math_verify-0.9.0-py3-none-any.whl", hash = "sha256:3703e7c4885354027fa84409d762a596a2906d1fd4deb78361876bd905a76194", size = 29967, upload-time = "2026-01-10T01:48:31.674Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy", marker = "python_full_version >= '3.12'" }, - { name = "cycler", marker = "python_full_version >= '3.12'" }, - { name = "fonttools", marker = "python_full_version >= '3.12'" }, - { name = "kiwisolver", marker = "python_full_version >= '3.12'" }, - { name = "numpy", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pillow", marker = "python_full_version >= '3.12'" }, - { name = "pyparsing", marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, -] - [[package]] name = "mautrix" version = "0.21.0" @@ -3260,35 +2564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "multiprocess" -version = "0.70.19" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dill" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743, upload-time = "2026-01-19T06:47:24.562Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738, upload-time = "2026-01-19T06:47:26.636Z" }, - { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741, upload-time = "2026-01-19T06:47:27.985Z" }, - { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, - { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414, upload-time = "2026-01-19T06:47:35.915Z" }, - { url = "https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f", size = 160318, upload-time = "2026-01-19T06:47:37.497Z" }, - { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, -] - -[[package]] -name = "narwhals" -version = "2.18.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/96/45218c2fdec4c9f22178f905086e85ef1a6d63862dcc3cd68eb60f1867f5/narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b", size = 620578, upload-time = "2026-03-24T15:11:25.456Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, -] - [[package]] name = "nest-asyncio" version = "1.6.0" @@ -3298,21 +2573,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "nltk" -version = "3.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, -] - [[package]] name = "numpy" version = "2.4.3" @@ -3651,60 +2911,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, - { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, - { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, -] - [[package]] name = "parallel-web" version = "0.4.2" @@ -3722,115 +2928,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" }, ] -[[package]] -name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, -] - -[[package]] -name = "plotly" -version = "6.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "narwhals", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -3840,34 +2937,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "polars" -version = "1.39.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "polars-runtime-32" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, -] - -[[package]] -name = "polars-runtime-32" -version = "1.39.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, - { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, - { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, - { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -4043,56 +3112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] -[[package]] -name = "pyarrow" -version = "23.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -4168,18 +3187,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] -[[package]] -name = "pydantic-cli" -version = "10.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/45/b383f86c77e9f38360f66253a223f127a74a58aa46e22e52011093f83b3a/pydantic_cli-10.0.0.tar.gz", hash = "sha256:1439d1db73664177c838ca1b90ae8eca19c65ce3b119a79a7b6c6f07cb79874a", size = 34984, upload-time = "2025-10-16T07:00:45.091Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/41/5262fca75b48906b03bd1e156b99330699b59a198b220051128a23917e9a/pydantic_cli-10.0.0-py3-none-any.whl", hash = "sha256:e3778aed1e412c9962812af6a11d92ba514df6266bd60835f843b6332dae6eed", size = 43076, upload-time = "2025-10-16T07:00:43.705Z" }, -] - [[package]] name = "pydantic-core" version = "2.41.5" @@ -4291,19 +3298,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] -[[package]] -name = "pydeck" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "numpy", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -4616,110 +3610,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] -[[package]] -name = "regex" -version = "2026.2.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, - { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, - { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, - { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, - { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4a/eae4e88b1317fb2ff57794915e0099198f51e760f6280b320adfa0ad396d/regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790", size = 266013, upload-time = "2026-02-19T19:00:47.274Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/ba89eb8fae79705e07ad1bd69e568f776159d2a8093c9dbc5303ee618298/regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013", size = 277906, upload-time = "2026-02-19T19:00:49.011Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1a/042d8f04b28e318df92df69d8becb0f42221eb3dd4fe5e976522f4337c76/regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4", size = 270463, upload-time = "2026-02-19T19:00:50.988Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, - { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, - { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, - { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, - { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, - { url = "https://files.pythonhosted.org/packages/b6/22/bc3b58ebddbfd6ca5633e71fd41829ee931963aad1ebeec55aad0c23044e/regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b", size = 266381, upload-time = "2026-02-19T19:01:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4a/6ff550b63e67603ee60e69dc6bd2d5694e85046a558f663b2434bdaeb285/regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a", size = 277274, upload-time = "2026-02-19T19:01:19.826Z" }, - { url = "https://files.pythonhosted.org/packages/cc/29/9ec48b679b1e87e7bc8517dff45351eab38f74fbbda1fbcf0e9e6d4e8174/regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b", size = 270509, upload-time = "2026-02-19T19:01:22.075Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, - { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, - { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, - { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, - { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, - { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, - { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, - { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, - { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, - { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, - { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, - { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, - { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, - { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, - { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, - { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, - { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, - { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, - { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, - { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, - { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, - { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, - { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, - { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, - { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, -] - [[package]] name = "requests" version = "2.33.0" @@ -4978,41 +3868,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] -[[package]] -name = "safetensors" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, -] - -[[package]] -name = "sentry-sdk" -version = "2.56.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, -] - [[package]] name = "setuptools" version = "82.0.1" @@ -5070,15 +3925,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -5113,59 +3959,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.48" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "(python_full_version >= '3.12' and platform_machine == 'AMD64') or (python_full_version >= '3.12' and platform_machine == 'WIN32') or (python_full_version >= '3.12' and platform_machine == 'aarch64') or (python_full_version >= '3.12' and platform_machine == 'amd64') or (python_full_version >= '3.12' and platform_machine == 'ppc64le') or (python_full_version >= '3.12' and platform_machine == 'win32') or (python_full_version >= '3.12' and platform_machine == 'x86_64')" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, - { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, - { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, - { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, - { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, -] - [[package]] name = "sse-starlette" version = "3.3.2" @@ -5192,35 +3985,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] -[[package]] -name = "streamlit" -version = "1.55.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altair", marker = "python_full_version >= '3.12'" }, - { name = "blinker", marker = "python_full_version >= '3.12'" }, - { name = "cachetools", marker = "python_full_version >= '3.12'" }, - { name = "click", marker = "python_full_version >= '3.12'" }, - { name = "gitpython", marker = "python_full_version >= '3.12'" }, - { name = "numpy", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pandas", marker = "python_full_version >= '3.12'" }, - { name = "pillow", marker = "python_full_version >= '3.12'" }, - { name = "protobuf", marker = "python_full_version >= '3.12'" }, - { name = "pyarrow", marker = "python_full_version >= '3.12'" }, - { name = "pydeck", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "tenacity", marker = "python_full_version >= '3.12'" }, - { name = "toml", marker = "python_full_version >= '3.12'" }, - { name = "tornado", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "watchdog", marker = "python_full_version >= '3.12' and sys_platform != 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/8e/f2b8b4fa8ba65aae251170c54f8ce198fb588fc348301c2b624f8c63efac/streamlit-1.55.0.tar.gz", hash = "sha256:015e512bbd02d000f4047e51118dc086b70e7d9c46b4a11a33c2509731379626", size = 8612008, upload-time = "2026-03-03T22:26:02.149Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/e6/412c1e1f200ca8c32ecf10201839183e261ad61ced3ede34a66f6d4be3cf/streamlit-1.55.0-py3-none-any.whl", hash = "sha256:1e4a16449c6131696180f4ddb40ea8c51834e89c2a43e1b0362bc9b1cfd9b415", size = 9075714, upload-time = "2026-03-03T22:25:59.126Z" }, -] - [[package]] name = "sympy" version = "1.14.0" @@ -5272,77 +4036,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, -] - -[[package]] -name = "tinker" -version = "0.18.0" -source = { git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b#30517b667f18a3dfb7ef33fb56cf686d5820ba2b" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "distro" }, - { name = "httpx", extra = ["http2"] }, - { name = "numpy" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "sniffio" }, - { name = "transformers" }, - { name = "typing-extensions" }, -] - [[package]] name = "tokenizers" version = "0.22.2" @@ -5407,26 +4100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] -[[package]] -name = "transformers" -version = "5.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" }, -] - [[package]] name = "ty" version = "0.0.21" @@ -5660,53 +4333,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/3a/0137d5b157845e1d41a70130d8dce8ba15d8712f34619693cda04ecb8f02/vercel_workers-0.0.16-py3-none-any.whl", hash = "sha256:542be839e46e236a68cc308695ccc3c970d76de72c978d7f416cc6ce09688896", size = 50141, upload-time = "2026-04-13T21:23:28.652Z" }, ] -[[package]] -name = "wandb" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "gitpython" }, - { name = "packaging" }, - { name = "platformdirs" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sentry-sdk" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/bb/eb579bf9abac70934a014a9d4e45346aab307994f3021d201bebe5fa25ec/wandb-0.25.1.tar.gz", hash = "sha256:b2a95cd777ecbe7499599a43158834983448a0048329bc7210ef46ca18d21994", size = 43983308, upload-time = "2026-03-10T23:51:44.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/d8/873553b6818499d1b1de314067d528b892897baf0dc81fedc0e845abc2dd/wandb-0.25.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:9bb0679a3e2dcd96db9d9b6d3e17d046241d8d122974b24facb85cc93309a8c9", size = 23615900, upload-time = "2026-03-10T23:51:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/71/ea/b131f319aaa5d0bf7572b6bfcff3dd89e1cf92b17eee443bbab71d12d74c/wandb-0.25.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:0fb13ed18914027523e7b4fc20380c520e0d10da0ee452f924a13f84509fbe12", size = 25576144, upload-time = "2026-03-10T23:51:11.527Z" }, - { url = "https://files.pythonhosted.org/packages/70/5f/81508581f0bb77b0495665c1c78e77606a48e66e855ca71ba7c8ae29efa4/wandb-0.25.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:cc4521eb5223429ddab5e8eee9b42fdf4caabdf0bc4e0e809042720e5fbef0ed", size = 23070425, upload-time = "2026-03-10T23:51:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c7/445155ef010e2e35d190797d7c36ff441e062a5b566a6da4778e22233395/wandb-0.25.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e73b4c55b947edae349232d5845204d30fac88e18eb4ad1d4b96bf7cf898405a", size = 25628142, upload-time = "2026-03-10T23:51:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/d5/63/f5c55ee00cf481ef1ccd3c385a0585ad52e7840d08419d4f82ddbeeea959/wandb-0.25.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:22b84065aa398e1624d2e5ad79e08bc4d2af41a6db61697b03b3aaba332977c6", size = 23123172, upload-time = "2026-03-10T23:51:23.418Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/19eb7974c0e9253bcbaee655222c0f0e1a52e63e9479ee711b4208f8ac31/wandb-0.25.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:005c4c6b5126ef8f4b4110e5372d950918b00637d6dc4b615ad17445f9739478", size = 25714479, upload-time = "2026-03-10T23:51:27.421Z" }, - { url = "https://files.pythonhosted.org/packages/11/19/466c1d03323a4a0ed7d4036a59b18d6b6f67cb5032e444205927e226b18d/wandb-0.25.1-py3-none-win32.whl", hash = "sha256:8f2d04f16b88d65bfba9d79fb945f6c64e2686215469a841936e0972be8ec6a5", size = 24967338, upload-time = "2026-03-10T23:51:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/89/22/680d34c1587f3a979c701b66d71aa7c42b4ef2fdf0774f67034e618e834e/wandb-0.25.1-py3-none-win_amd64.whl", hash = "sha256:62db5166de14456156d7a85953a58733a631228e6d4248a753605f75f75fb845", size = 24967343, upload-time = "2026-03-10T23:51:36.026Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e8/76836b75d401ff5912aaf513176e64557ceaec4c4946bfd38a698ff84d48/wandb-0.25.1-py3-none-win_arm64.whl", hash = "sha256:cc7c34b70cf4b7be4d395541e82e325fd9d2be978d62c9ec01f1a7141523b6bb", size = 22080774, upload-time = "2026-03-10T23:51:40.196Z" }, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "watchfiles" version = "1.1.1" @@ -5904,109 +4530,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] -[[package]] -name = "xxhash" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, - { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, - { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, - { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, - { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, - { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, - { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, - { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, - { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, - { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, - { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, - { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, - { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, - { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, -] - [[package]] name = "yarl" version = "1.22.0" @@ -6117,21 +4640,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] -[[package]] -name = "yc-bench" -version = "0.1.0" -source = { git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c#bfb0c88062450f46341bd9a5298903fc2e952a5c" } -dependencies = [ - { name = "litellm", marker = "python_full_version >= '3.12'" }, - { name = "matplotlib", marker = "python_full_version >= '3.12'" }, - { name = "plotly", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, - { name = "sqlalchemy", marker = "python_full_version >= '3.12'" }, - { name = "streamlit", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, -] - [[package]] name = "youtube-transcript-api" version = "1.2.4" diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index af2b0a2fd4b..b5e2add8993 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -127,7 +127,6 @@ hermes-agent/ ├── cron/ # Scheduler (jobs.py, scheduler.py) ├── plugins/memory/ # Memory provider plugins ├── plugins/context_engine/ # Context engine plugins -├── environments/ # RL training environments (Atropos) ├── skills/ # Bundled skills (always available) ├── optional-skills/ # Official optional skills (install explicitly) ├── website/ # Docusaurus documentation site @@ -185,7 +184,6 @@ If you are new to the codebase: 8. **[Gateway Internals](./gateway-internals.md)** — messaging platform gateway 9. **[Context Compression & Prompt Caching](./context-compression-and-caching.md)** — compression and caching 10. **[ACP Internals](./acp-internals.md)** — IDE integration -11. **[Environments, Benchmarks & Data Generation](./environments.md)** — RL training ## Major Subsystems @@ -247,11 +245,11 @@ Exposes Hermes as an editor-native agent over stdio/JSON-RPC for VS Code, Zed, a → [ACP Internals](./acp-internals.md) -### RL / Environments / Trajectories +### Trajectories -Full environment framework for evaluation and RL training. Integrates with Atropos, supports multiple tool-call parsers, and generates ShareGPT-format trajectories. +Generates ShareGPT-format trajectories from agent sessions for training data generation. -→ [Environments, Benchmarks & Data Generation](./environments.md), [Trajectories & Training Format](./trajectory-format.md) +→ [Trajectories & Training Format](./trajectory-format.md) ## Design Principles diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 6e00e367330..b3bf9799d71 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -50,9 +50,6 @@ export VIRTUAL_ENV="$(pwd)/venv" # Install with all extras (messaging, cron, CLI menus, dev tools) uv pip install -e ".[all,dev]" -# tinker-atropos is a git submodule — needs `git submodule update --init` first -# if you didn't clone with `--recurse-submodules` -uv pip install -e "./tinker-atropos" # Optional: browser tools npm install diff --git a/website/docs/developer-guide/environments.md b/website/docs/developer-guide/environments.md deleted file mode 100644 index 0a5aa00ffff..00000000000 --- a/website/docs/developer-guide/environments.md +++ /dev/null @@ -1,520 +0,0 @@ ---- -sidebar_position: 5 -title: "Environments, Benchmarks & Data Generation" -description: "Building RL training environments, running evaluation benchmarks, and generating SFT data with the Hermes-Agent Atropos integration" ---- - -# Environments, Benchmarks & Data Generation - -Hermes Agent includes a full environment framework that connects its tool-calling capabilities to the [Atropos](https://github.com/NousResearch/atropos) RL training framework. This enables three workflows: - -1. **RL Training** — Train language models on multi-turn agentic tasks with GRPO -2. **Benchmarks** — Evaluate models on standardised agentic benchmarks -3. **Data Generation** — Generate SFT training data from agent rollouts - -All three share the same core: an **environment** class that defines tasks, runs an agent loop, and scores the output. - -:::info Repo environments vs RL training tools -The Python environment framework documented here lives under the repo's `environments/` directory and is the implementation-level API for Hermes/Atropos integration. This is separate from the user-facing `rl_*` tools, which operate as an orchestration surface for remote RL training workflows. -::: - -:::tip Quick Links -- **Want to run benchmarks?** Jump to [Available Benchmarks](#available-benchmarks) -- **Want to train with RL?** See [RL Training Tools](/user-guide/features/rl-training) for the agent-driven interface, or [Running Environments](#running-environments) for manual execution -- **Want to create a new environment?** See [Creating Environments](#creating-environments) -::: - -## Architecture - -The environment system is built on a three-layer inheritance chain: - -```mermaid -classDiagram - class BaseEnv { - Server management - Worker scheduling - Wandb logging - CLI: serve / process / evaluate - } - - class HermesAgentBaseEnv { - Terminal backend configuration - Tool resolution - Agent loop engine - ToolContext access - } - - class TerminalTestEnv { - Stack testing - } - - class HermesSweEnv { - SWE training - } - - class TerminalBench2EvalEnv { - Benchmark evaluation - } - - class TBLiteEvalEnv { - Fast benchmark - } - - class YCBenchEvalEnv { - Long-horizon benchmark - } - - BaseEnv <|-- HermesAgentBaseEnv - HermesAgentBaseEnv <|-- TerminalTestEnv - HermesAgentBaseEnv <|-- HermesSweEnv - HermesAgentBaseEnv <|-- TerminalBench2EvalEnv - TerminalBench2EvalEnv <|-- TBLiteEvalEnv - TerminalBench2EvalEnv <|-- YCBenchEvalEnv -``` - -### BaseEnv (Atropos) - -The foundation from `atroposlib`. Provides: -- **Server management** — connects to OpenAI-compatible APIs (VLLM, SGLang, OpenRouter) -- **Worker scheduling** — parallel rollout coordination -- **Wandb integration** — metrics logging and rollout visualisation -- **CLI interface** — three subcommands: `serve`, `process`, `evaluate` -- **Eval logging** — `evaluate_log()` saves results to JSON + JSONL - -### HermesAgentBaseEnv - -The hermes-agent layer (`environments/hermes_base_env.py`). Adds: -- **Terminal backend configuration** — sets `TERMINAL_ENV` for sandboxed execution (local, Docker, Modal, Daytona, SSH, Singularity) -- **Tool resolution** — `_resolve_tools_for_group()` calls hermes-agent's `get_tool_definitions()` to get the right tool schemas based on enabled/disabled toolsets -- **Agent loop integration** — `collect_trajectory()` runs `HermesAgentLoop` and scores the result -- **Two-phase operation** — Phase 1 (OpenAI server) for eval/SFT, Phase 2 (VLLM ManagedServer) for full RL with logprobs -- **Async safety patches** — monkey-patches Modal backend to work inside Atropos's event loop - -### Concrete Environments - -Your environment inherits from `HermesAgentBaseEnv` and implements five methods: - -| Method | Purpose | -|--------|---------| -| `setup()` | Load dataset, initialise state | -| `get_next_item()` | Return the next item for rollout | -| `format_prompt(item)` | Convert an item into the user message | -| `compute_reward(item, result, ctx)` | Score the rollout (0.0–1.0) | -| `evaluate()` | Periodic evaluation logic | - -## Core Components - -### Agent Loop - -`HermesAgentLoop` (`environments/agent_loop.py`) is the reusable multi-turn agent engine. It runs the same tool-calling pattern as hermes-agent's main loop: - -1. Send messages + tool schemas to the API via `server.chat_completion()` -2. If the response contains `tool_calls`, dispatch each via `handle_function_call()` -3. Append tool results to the conversation, go back to step 1 -4. If no `tool_calls`, the agent is done - -Tool calls execute in a thread pool (`ThreadPoolExecutor(128)`) so that async backends (Modal, Docker) don't deadlock inside Atropos's event loop. - -Returns an `AgentResult`: - -```python -@dataclass -class AgentResult: - messages: List[Dict[str, Any]] # Full conversation history - turns_used: int # Number of LLM calls made - finished_naturally: bool # True if model stopped on its own - reasoning_per_turn: List[Optional[str]] # Extracted reasoning content - tool_errors: List[ToolError] # Errors encountered during tool dispatch - managed_state: Optional[Dict] # VLLM ManagedServer state (Phase 2) -``` - -### Tool Context - -`ToolContext` (`environments/tool_context.py`) gives reward functions direct access to the **same sandbox** the model used during its rollout. The `task_id` scoping means all state (files, processes, browser tabs) is preserved. - -```python -async def compute_reward(self, item, result, ctx: ToolContext): - # Run tests in the model's terminal sandbox - test = ctx.terminal("pytest -v") - if test["exit_code"] == 0: - return 1.0 - - # Check if a file was created - content = ctx.read_file("/workspace/solution.py") - if content.get("content"): - return 0.5 - - # Download files for local verification - ctx.download_file("/remote/output.bin", "/local/output.bin") - return 0.0 -``` - -Available methods: - -| Category | Methods | -|----------|---------| -| **Terminal** | `terminal(command, timeout)` | -| **Files** | `read_file(path)`, `write_file(path, content)`, `search(query, path)` | -| **Transfers** | `upload_file()`, `upload_dir()`, `download_file()`, `download_dir()` | -| **Web** | `web_search(query)`, `web_extract(urls)` | -| **Browser** | `browser_navigate(url)`, `browser_snapshot()` | -| **Generic** | `call_tool(name, args)` — escape hatch for any hermes-agent tool | -| **Cleanup** | `cleanup()` — release all resources | - -### Tool Call Parsers - -For **Phase 2** (VLLM ManagedServer), the server returns raw text without structured tool calls. Client-side parsers in `environments/tool_call_parsers/` extract `tool_calls` from raw output: - -```python -from environments.tool_call_parsers import get_parser - -parser = get_parser("hermes") # or "mistral", "llama3_json", "qwen", "deepseek_v3", etc. -content, tool_calls = parser.parse(raw_model_output) -``` - -Available parsers: `hermes`, `mistral`, `llama3_json`, `llama4_json`, `qwen`, `qwen3_coder`, `deepseek_v3`, `deepseek_v3_1` (alias `deepseek_v31`), `kimi_k2`, `longcat`, `glm45`, `glm47`. - -In Phase 1 (OpenAI server type), parsers are not needed — the server handles tool call parsing natively. - -## Available Benchmarks - -### TerminalBench2 - -**89 challenging terminal tasks** with per-task Docker sandbox environments. - -| | | -|---|---| -| **What it tests** | Single-task coding/sysadmin ability | -| **Scoring** | Binary pass/fail (test suite verification) | -| **Sandbox** | Modal cloud sandboxes (per-task Docker images) | -| **Tools** | `terminal` + `file` | -| **Tasks** | 89 tasks across multiple categories | -| **Cost** | ~$50–200 for full eval (parallel execution) | -| **Time** | ~2–4 hours | - -```bash -python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ - --config environments/benchmarks/terminalbench_2/default.yaml - -# Run specific tasks -python environments/benchmarks/terminalbench_2/terminalbench2_env.py evaluate \ - --config environments/benchmarks/terminalbench_2/default.yaml \ - --env.task_filter fix-git,git-multibranch -``` - -Dataset: [NousResearch/terminal-bench-2](https://huggingface.co/datasets/NousResearch/terminal-bench-2) on HuggingFace. - -### TBLite (OpenThoughts Terminal Bench Lite) - -**100 difficulty-calibrated tasks** — a faster proxy for TerminalBench2. - -| | | -|---|---| -| **What it tests** | Same as TB2 (coding/sysadmin), calibrated difficulty tiers | -| **Scoring** | Binary pass/fail | -| **Sandbox** | Modal cloud sandboxes | -| **Tools** | `terminal` + `file` | -| **Tasks** | 100 tasks: Easy (40), Medium (26), Hard (26), Extreme (8) | -| **Correlation** | r=0.911 with full TB2 | -| **Speed** | 2.6–8× faster than TB2 | - -```bash -python environments/benchmarks/tblite/tblite_env.py evaluate \ - --config environments/benchmarks/tblite/default.yaml -``` - -TBLite is a thin subclass of TerminalBench2 — only the dataset and timeouts differ. Created by the OpenThoughts Agent team (Snorkel AI + Bespoke Labs). Dataset: [NousResearch/openthoughts-tblite](https://huggingface.co/datasets/NousResearch/openthoughts-tblite). - -### YC-Bench - -**Long-horizon strategic benchmark** — the agent plays CEO of an AI startup. - -| | | -|---|---| -| **What it tests** | Multi-turn strategic coherence over hundreds of turns | -| **Scoring** | Composite: `0.5 × survival + 0.5 × normalised_funds` | -| **Sandbox** | Local terminal (no Modal needed) | -| **Tools** | `terminal` only | -| **Runs** | 9 default (3 presets × 3 seeds), sequential | -| **Cost** | ~$50–200 for full eval | -| **Time** | ~3–6 hours | - -```bash -# Install yc-bench (optional dependency) -pip install "hermes-agent[yc-bench]" - -# Run evaluation -bash environments/benchmarks/yc_bench/run_eval.sh - -# Or directly -python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ - --config environments/benchmarks/yc_bench/default.yaml - -# Quick single-preset test -python environments/benchmarks/yc_bench/yc_bench_env.py evaluate \ - --config environments/benchmarks/yc_bench/default.yaml \ - --env.presets '["fast_test"]' --env.seeds '[1]' -``` - -YC-Bench uses [collinear-ai/yc-bench](https://github.com/collinear-ai/yc-bench) — a deterministic simulation with 4 skill domains (research, inference, data_environment, training), prestige system, employee management, and financial pressure. Unlike TB2's per-task binary scoring, YC-Bench measures whether an agent can maintain coherent strategy over hundreds of compounding decisions. - -## Training Environments - -### TerminalTestEnv - -A minimal self-contained environment with inline tasks (no external dataset). Used for **validating the full stack** end-to-end. Each task asks the model to create a file at a known path; the verifier checks the content. - -```bash -# Process mode (saves rollouts to JSONL, no training server needed) -python environments/terminal_test_env/terminal_test_env.py process \ - --env.data_path_to_save_groups terminal_test_output.jsonl - -# Serve mode (connects to Atropos API for RL training) -python environments/terminal_test_env/terminal_test_env.py serve -``` - -### HermesSweEnv - -SWE-bench style training environment. The model gets a coding task, uses terminal + file + web tools to solve it, and the reward function runs tests in the same Modal sandbox. - -```bash -python environments/hermes_swe_env/hermes_swe_env.py serve \ - --openai.model_name YourModel \ - --env.dataset_name bigcode/humanevalpack \ - --env.terminal_backend modal -``` - -## Running Environments - -Every environment is a standalone Python script with three CLI subcommands: - -### `evaluate` — Run a benchmark - -For eval-only environments (benchmarks). Runs all items, computes metrics, logs to wandb. - -```bash -python environments/benchmarks/tblite/tblite_env.py evaluate \ - --config environments/benchmarks/tblite/default.yaml \ - --openai.model_name anthropic/claude-sonnet-4.6 -``` - -No training server or `run-api` needed. The environment handles everything. - -### `process` — Generate SFT data - -Runs rollouts and saves scored trajectories to JSONL. Useful for generating training data without a full RL loop. - -```bash -python environments/terminal_test_env/terminal_test_env.py process \ - --env.data_path_to_save_groups output.jsonl \ - --openai.model_name anthropic/claude-sonnet-4.6 -``` - -Output format: each line is a scored trajectory with the full conversation history, reward, and metadata. - -### `serve` — Connect to Atropos for RL training - -Connects the environment to a running Atropos API server (`run-api`). Used during live RL training. - -```bash -# Terminal 1: Start the Atropos API -run-api - -# Terminal 2: Start the environment -python environments/hermes_swe_env/hermes_swe_env.py serve \ - --openai.model_name YourModel -``` - -The environment receives items from Atropos, runs agent rollouts, computes rewards, and sends scored trajectories back for training. - -## Two-Phase Operation - -### Phase 1: OpenAI Server (Eval / SFT) - -Uses `server.chat_completion()` with `tools=` parameter. The server (VLLM, SGLang, OpenRouter, OpenAI) handles tool call parsing natively. Returns `ChatCompletion` objects with structured `tool_calls`. - -- **Use for**: evaluation, SFT data generation, benchmarks, testing -- **Placeholder tokens** are created for the Atropos pipeline (since real token IDs aren't available from the OpenAI API) - -### Phase 2: VLLM ManagedServer (Full RL) - -Uses ManagedServer for exact token IDs + logprobs via `/generate`. A client-side [tool call parser](#tool-call-parsers) reconstructs structured `tool_calls` from raw output. - -- **Use for**: full RL training with GRPO/PPO -- **Real tokens**, masks, and logprobs flow through the pipeline -- Set `tool_call_parser` in config to match your model's format (e.g., `"hermes"`, `"qwen"`, `"mistral"`) - -## Creating Environments - -### Training Environment - -```python -from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig -from atroposlib.envs.server_handling.server_manager import APIServerConfig - -class MyEnvConfig(HermesAgentEnvConfig): - my_custom_field: str = "default_value" - -class MyEnv(HermesAgentBaseEnv): - name = "my-env" - env_config_cls = MyEnvConfig - - @classmethod - def config_init(cls): - env_config = MyEnvConfig( - enabled_toolsets=["terminal", "file"], - terminal_backend="modal", - max_agent_turns=30, - ) - server_configs = [APIServerConfig( - base_url="https://openrouter.ai/api/v1", - model_name="anthropic/claude-sonnet-4.6", - server_type="openai", - )] - return env_config, server_configs - - async def setup(self): - from datasets import load_dataset - self.dataset = list(load_dataset("my-dataset", split="train")) - self.iter = 0 - - async def get_next_item(self): - item = self.dataset[self.iter % len(self.dataset)] - self.iter += 1 - return item - - def format_prompt(self, item): - return item["instruction"] - - async def compute_reward(self, item, result, ctx): - # ctx gives full tool access to the rollout's sandbox - test = ctx.terminal("pytest -v") - return 1.0 if test["exit_code"] == 0 else 0.0 - - async def evaluate(self, *args, **kwargs): - # Periodic evaluation during training - pass - -if __name__ == "__main__": - MyEnv.cli() -``` - -### Eval-Only Benchmark - -For benchmarks, follow the pattern used by TerminalBench2, TBLite, and YC-Bench: - -1. **Create under** `environments/benchmarks/your-benchmark/` -2. **Set eval-only config**: `eval_handling=STOP_TRAIN`, `steps_per_eval=1`, `total_steps=1` -3. **Stub training methods**: `collect_trajectories()` returns `(None, [])`, `score()` returns `None` -4. **Implement** `rollout_and_score_eval(eval_item)` — the per-item agent loop + scoring -5. **Implement** `evaluate()` — orchestrates all runs, computes aggregate metrics -6. **Add streaming JSONL** for crash-safe result persistence -7. **Add cleanup**: `KeyboardInterrupt` handling, `cleanup_all_environments()`, `_tool_executor.shutdown()` -8. **Run with** `evaluate` subcommand - -See `environments/benchmarks/yc_bench/yc_bench_env.py` for a clean, well-documented reference implementation. - -## Configuration Reference - -### HermesAgentEnvConfig Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `enabled_toolsets` | `List[str]` | `None` (all) | Which hermes toolsets to enable | -| `disabled_toolsets` | `List[str]` | `None` | Toolsets to filter out | -| `distribution` | `str` | `None` | Probabilistic toolset distribution name | -| `max_agent_turns` | `int` | `30` | Max LLM calls per rollout | -| `agent_temperature` | `float` | `1.0` | Sampling temperature | -| `system_prompt` | `str` | `None` | System message for the agent | -| `terminal_backend` | `str` | `"local"` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity` | -| `terminal_timeout` | `int` | `120` | Seconds per terminal command | -| `terminal_lifetime` | `int` | `3600` | Max sandbox lifetime | -| `dataset_name` | `str` | `None` | HuggingFace dataset identifier | -| `tool_pool_size` | `int` | `128` | Thread pool size for tool execution | -| `tool_call_parser` | `str` | `"hermes"` | Parser for Phase 2 raw output | -| `extra_body` | `Dict` | `None` | Extra params for OpenAI API (e.g., OpenRouter provider prefs) | -| `eval_handling` | `Enum` | `STOP_TRAIN` | `STOP_TRAIN`, `LIMIT_TRAIN`, `NONE` | - -### YAML Configuration - -Environments can be configured via YAML files passed with `--config`: - -```yaml -env: - enabled_toolsets: ["terminal", "file"] - max_agent_turns: 60 - max_token_length: 32000 - agent_temperature: 0.8 - terminal_backend: "modal" - terminal_timeout: 300 - dataset_name: "NousResearch/terminal-bench-2" - tokenizer_name: "NousResearch/Hermes-3-Llama-3.1-8B" - use_wandb: true - wandb_name: "my-benchmark" - -openai: - base_url: "https://openrouter.ai/api/v1" - model_name: "anthropic/claude-sonnet-4.6" - server_type: "openai" - health_check: false -``` - -YAML values override `config_init()` defaults. CLI arguments override YAML values: - -```bash -python my_env.py evaluate \ - --config my_config.yaml \ - --openai.model_name anthropic/claude-opus-4.6 # overrides YAML -``` - -## Prerequisites - -### For all environments - -- Python >= 3.11 -- `atroposlib`: `pip install git+https://github.com/NousResearch/atropos.git` -- An LLM API key (OpenRouter, OpenAI, or self-hosted VLLM/SGLang) - -### For Modal-sandboxed benchmarks (TB2, TBLite) - -- [Modal](https://modal.com) account and CLI: `pip install "hermes-agent[modal]"` -- `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` environment variables - -### For YC-Bench - -- `pip install "hermes-agent[yc-bench]"` (installs the yc-bench CLI + SQLAlchemy) -- No Modal needed — runs with local terminal backend - -### For RL training - -- `TINKER_API_KEY` — API key for the [Tinker](https://tinker.computer) training service -- `WANDB_API_KEY` — for Weights & Biases metrics tracking -- The `tinker-atropos` submodule (at `tinker-atropos/` in the repo) - -See [RL Training](/user-guide/features/rl-training) for the agent-driven RL workflow. - -## Directory Structure - -``` -environments/ -├── hermes_base_env.py # Abstract base class (HermesAgentBaseEnv) -├── agent_loop.py # Multi-turn agent engine (HermesAgentLoop) -├── tool_context.py # Per-rollout tool access for reward functions -├── patches.py # Async-safety patches for Modal backend -│ -├── tool_call_parsers/ # Phase 2 client-side parsers -│ ├── hermes_parser.py # Hermes/ChatML format -│ ├── mistral_parser.py # Mistral [TOOL_CALLS] format -│ ├── llama_parser.py # Llama 3 JSON tool calling -│ ├── qwen_parser.py # Qwen format -│ ├── deepseek_v3_parser.py # DeepSeek V3 format -│ └── ... # + kimi_k2, longcat, glm45/47, etc. -│ -├── terminal_test_env/ # Stack validation (inline tasks) -├── hermes_swe_env/ # SWE-bench training environment -│ -└── benchmarks/ # Evaluation benchmarks - ├── terminalbench_2/ # 89 terminal tasks, Modal sandboxes - ├── tblite/ # 100 calibrated tasks (fast TB2 proxy) - └── yc_bench/ # Long-horizon strategic benchmark -``` diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 55df5a7f640..aa2a426db99 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -123,13 +123,11 @@ If you installed manually (not via the quick installer): cd /path/to/hermes-agent export VIRTUAL_ENV="$(pwd)/venv" -# Pull latest code and submodules +# Pull latest code git pull origin main -git submodule update --init --recursive # Reinstall (picks up new dependencies) uv pip install -e ".[all]" -uv pip install -e "./tinker-atropos" # Check for new config options hermes config check diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index 21235a12ba1..d80a61abd8c 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -97,5 +97,4 @@ See the [Messaging Gateway overview](/docs/user-guide/messaging) for the platfor ## Training & Evaluation -- **[RL Training](/docs/user-guide/features/rl-training)** — Generate trajectory data from agent sessions for reinforcement learning and model fine-tuning. Supports Atropos environments with customizable reward functions. - **[Batch Processing](/docs/user-guide/features/batch-processing)** — Run the agent across hundreds of prompts in parallel, generating structured ShareGPT-format trajectory data for training data generation or evaluation. diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index b53ab15ed84..af9e07814d7 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -1355,7 +1355,6 @@ You can switch between providers at any time with `hermes model` — no restart | Premium TTS voices | [ElevenLabs](https://elevenlabs.io/) | `ELEVENLABS_API_KEY` | | OpenAI TTS + voice transcription | [OpenAI](https://platform.openai.com/api-keys) | `VOICE_TOOLS_OPENAI_KEY` | | Mistral TTS + voice transcription | [Mistral](https://console.mistral.ai/) | `MISTRAL_API_KEY` | -| RL Training | [Tinker](https://tinker-console.thinkingmachines.ai/) + [WandB](https://wandb.ai/) | `TINKER_API_KEY`, `WANDB_API_KEY` | | Cross-session user modeling | [Honcho](https://honcho.dev/) | `HONCHO_API_KEY` | | Semantic long-term memory | [Supermemory](https://supermemory.ai) | `SUPERMEMORY_API_KEY` | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 4b581877849..93107fba147 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -148,8 +148,6 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `HONCHO_BASE_URL` | Base URL for self-hosted Honcho instances (default: Honcho cloud). No API key required for local instances | | `HINDSIGHT_TIMEOUT` | Timeout in seconds for Hindsight memory-provider API calls (default: `60`). Bump this if your Hindsight instance is slow to respond during `/sync` or `on_session_switch` and you're seeing timeouts in `errors.log`. | | `SUPERMEMORY_API_KEY` | Semantic long-term memory with profile recall and session ingest ([supermemory.ai](https://supermemory.ai)) | -| `TINKER_API_KEY` | RL training ([tinker-console.thinkingmachines.ai](https://tinker-console.thinkingmachines.ai/)) | -| `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) | | `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) | | `VERCEL_TOKEN` | Vercel Sandbox access token ([vercel.com](https://vercel.com/)) | | `VERCEL_PROJECT_ID` | Vercel project ID (required with `VERCEL_TOKEN`) | diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 40f9c5539c8..8c4c2f36432 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -120,7 +120,6 @@ hermes skills uninstall | [**faiss**](/docs/user-guide/skills/optional/mlops/mlops-faiss) | Facebook's library for efficient similarity search and clustering of dense vectors. Supports billions of vectors, GPU acceleration, and various index types (Flat, IVF, HNSW). Use for fast k-NN search, large-scale vector retrieval, or whe... | | [**optimizing-attention-flash**](/docs/user-guide/skills/optional/mlops/mlops-flash-attention) | Optimizes transformer attention with Flash Attention for 2-4x speedup and 10-20x memory reduction. Use when training/running transformers with long sequences (>512 tokens), encountering GPU memory issues with attention, or need faster in... | | [**guidance**](/docs/user-guide/skills/optional/mlops/mlops-guidance) | Control LLM output with regex and grammars, guarantee valid JSON/XML/code generation, enforce structured formats, and build multi-step workflows with Guidance - Microsoft Research's constrained generation framework | -| [**hermes-atropos-environments**](/docs/user-guide/skills/optional/mlops/mlops-hermes-atropos-environments) | Build, test, and debug Hermes Agent RL environments for Atropos training. Covers the HermesAgentBaseEnv interface, reward functions, agent loop integration, evaluation with tools, wandb logging, and the three CLI modes (serve/process/eva... | | [**huggingface-tokenizers**](/docs/user-guide/skills/optional/mlops/mlops-huggingface-tokenizers) | Fast tokenizers optimized for research and production. Rust-based implementation tokenizes 1GB in <20 seconds. Supports BPE, WordPiece, and Unigram algorithms. Train custom vocabularies, track alignments, handle padding/truncation. Integ... | | [**instructor**](/docs/user-guide/skills/optional/mlops/mlops-instructor) | Extract structured data from LLM responses with Pydantic validation, retry failed extractions automatically, parse complex JSON with type safety, and stream partial results with Instructor - battle-tested structured output library | | [**lambda-labs-gpu-cloud**](/docs/user-guide/skills/optional/mlops/mlops-lambda-labs) | Reserved and on-demand GPU cloud instances for ML training and inference. Use when you need dedicated GPU instances with simple SSH access, persistent filesystems, or high-performance multi-node clusters for large-scale training. | diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 5d0100de79d..03930264f8c 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -148,21 +148,6 @@ Registered only when the agent is spawned by the kanban dispatcher (`HERMES_KANB |------|-------------|----------------------| | `mixture_of_agents` | Route a hard problem through multiple frontier LLMs collaboratively. Makes 5 API calls (4 reference models + 1 aggregator) with maximum reasoning effort — use sparingly for genuinely difficult problems. Best for: complex math, advanced alg… | OPENROUTER_API_KEY | -## `rl` toolset - -| Tool | Description | Requires environment | -|------|-------------|----------------------| -| `rl_check_status` | Get status and metrics for a training run. RATE LIMITED: enforces 30-minute minimum between checks for the same run. Returns WandB metrics: step, state, reward_mean, loss, percent_correct. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_edit_config` | Update a configuration field. Use rl_get_current_config() first to see all available fields for the selected environment. Each environment has different configurable options. Infrastructure settings (tokenizer, URLs, lora_rank, learning_ra… | TINKER_API_KEY, WANDB_API_KEY | -| `rl_get_current_config` | Get the current environment configuration. Returns only fields that can be modified: group_size, max_token_length, total_steps, steps_per_eval, use_wandb, wandb_name, max_num_workers. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_get_results` | Get final results and metrics for a completed training run. Returns final metrics and path to trained weights. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_list_environments` | List all available RL environments. Returns environment names, paths, and descriptions. TIP: Read the file_path with file tools to understand how each environment works (verifiers, data loading, rewards). | TINKER_API_KEY, WANDB_API_KEY | -| `rl_list_runs` | List all training runs (active and completed) with their status. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_select_environment` | Select an RL environment for training. Loads the environment's default configuration. After selecting, use rl_get_current_config() to see settings and rl_edit_config() to modify them. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_start_training` | Start a new RL training run with the current environment and config. Most training parameters (lora_rank, learning_rate, etc.) are fixed. Use rl_edit_config() to set group_size, batch_size, wandb_project before starting. WARNING: Training… | TINKER_API_KEY, WANDB_API_KEY | -| `rl_stop_training` | Stop a running training job. Use if metrics look bad, training is stagnant, or you want to try different settings. | TINKER_API_KEY, WANDB_API_KEY | -| `rl_test_inference` | Quick inference test for any environment. Runs a few steps of inference + scoring using OpenRouter. Default: 3 steps x 16 completions = 48 rollouts per model, testing 3 models = 144 total. Tests environment loading, prompt construction, in… | TINKER_API_KEY, WANDB_API_KEY | - ## `session_search` toolset | Tool | Description | Requires environment | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index ce11d86cb41..5bf1f14260e 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -45,7 +45,7 @@ Or in-session: ``` /tools list /tools disable browser -/tools enable rl +/tools enable homeassistant ``` ## Core Toolsets @@ -71,7 +71,6 @@ Or in-session: | `memory` | `memory` | Persistent cross-session memory management. | | `messaging` | `send_message` | Send messages to other platforms (Telegram, Discord, etc.) from within a session. | | `moa` | `mixture_of_agents` | Multi-model consensus via Mixture of Agents. | -| `rl` | `rl_check_status`, `rl_edit_config`, `rl_get_current_config`, `rl_get_results`, `rl_list_environments`, `rl_list_runs`, `rl_select_environment`, `rl_start_training`, `rl_stop_training`, `rl_test_inference` | RL training environment management (Atropos). | | `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. | | `search` | `web_search` | Web search only (without extract). | | `session_search` | `session_search` | Search past conversation sessions. | diff --git a/website/docs/user-guide/features/rl-training.md b/website/docs/user-guide/features/rl-training.md deleted file mode 100644 index 81fc6539b37..00000000000 --- a/website/docs/user-guide/features/rl-training.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -sidebar_position: 13 -title: "RL Training" -description: "Reinforcement learning on agent behaviors with Tinker-Atropos — environment discovery, training, and evaluation" ---- - -# RL Training - -Hermes Agent includes an integrated RL (Reinforcement Learning) training pipeline built on **Tinker-Atropos**. This enables training language models on environment-specific tasks using GRPO (Group Relative Policy Optimization) with LoRA adapters, orchestrated entirely through the agent's tool interface. - -## Overview - -The RL training system consists of three components: - -1. **[Atropos](https://github.com/NousResearch/atropos)** — A trajectory API server that coordinates environment interactions, manages rollout groups, and computes advantages -2. **[Tinker](https://thinkingmachines.ai/tinker/)** — A training service that handles model weights, LoRA training, sampling/inference, and optimizer steps -3. **Environments** — Python classes that define tasks, scoring, and reward functions (e.g., GSM8K math problems) - -The agent can discover environments, configure training parameters, launch training runs, and monitor metrics — all through a set of `rl_*` tools. - -## Requirements - -RL training requires: - -- **Python >= 3.11** (Tinker package requirement) -- **TINKER_API_KEY** — API key for the Tinker training service -- **WANDB_API_KEY** — API key for [Weights & Biases](https://wandb.ai/) metrics tracking -- The `tinker-atropos` submodule (at `tinker-atropos/` relative to the Hermes root) - -```bash -# Set up API keys -hermes config set TINKER_API_KEY your-tinker-key -hermes config set WANDB_API_KEY your-wandb-key -``` - -When both keys are present and Python >= 3.11 is available, the `rl` toolset is automatically enabled. - -## Available Tools - -| Tool | Description | -|------|-------------| -| `rl_list_environments` | Discover available RL environments | -| `rl_select_environment` | Select an environment and load its config | -| `rl_get_current_config` | View configurable and locked fields | -| `rl_edit_config` | Modify configurable training parameters | -| `rl_start_training` | Launch a training run (spawns 3 processes) | -| `rl_check_status` | Monitor training progress and WandB metrics | -| `rl_stop_training` | Stop a running training job | -| `rl_get_results` | Get final metrics and model weights path | -| `rl_list_runs` | List all active and completed runs | -| `rl_test_inference` | Quick inference test using OpenRouter | - -## Workflow - -### 1. Discover Environments - -``` -List the available RL environments -``` - -The agent calls `rl_list_environments()` which scans `tinker-atropos/tinker_atropos/environments/` using AST parsing to find Python classes inheriting from `BaseEnv`. Each environment defines: - -- **Dataset loading** — where training data comes from (e.g., HuggingFace datasets) -- **Prompt construction** — how to format items for the model -- **Scoring/verification** — how to evaluate model outputs and assign rewards - -### 2. Select and Configure - -``` -Select the GSM8K environment and show me the configuration -``` - -The agent calls `rl_select_environment("gsm8k_tinker")`, then `rl_get_current_config()` to see all parameters. - -Configuration fields are divided into two categories: - -**Configurable fields** (can be modified): -- `group_size` — Number of completions per item (default: 16) -- `batch_size` — Training batch size (default: 128) -- `wandb_name` — WandB run name (auto-set to `{env}-{timestamp}`) -- Other environment-specific parameters - -**Locked fields** (infrastructure settings, cannot be changed): -- `tokenizer_name` — Model tokenizer (e.g., `Qwen/Qwen3-8B`) -- `rollout_server_url` — Atropos API URL (`http://localhost:8000`) -- `max_token_length` — Maximum token length (8192) -- `max_num_workers` — Maximum parallel workers (2048) -- `total_steps` — Total training steps (2500) -- `lora_rank` — LoRA adapter rank (32) -- `learning_rate` — Learning rate (4e-5) -- `max_token_trainer_length` — Max tokens for trainer (9000) - -### 3. Start Training - -``` -Start the training run -``` - -The agent calls `rl_start_training()` which: - -1. Generates a YAML config file merging locked settings with configurable overrides -2. Creates a unique run ID -3. Spawns three processes: - - **Atropos API server** (`run-api`) — trajectory coordination - - **Tinker trainer** (`launch_training.py`) — LoRA training + FastAPI inference server on port 8001 - - **Environment** (`environment.py serve`) — the selected environment connecting to Atropos - -The processes start with staggered delays (5s for API, 30s for trainer, 90s more for environment) to ensure proper initialization order. - -### 4. Monitor Progress - -``` -Check the status of training run abc12345 -``` - -The agent calls `rl_check_status(run_id)` which reports: - -- Process status (running/exited for each of the 3 processes) -- Running time -- WandB metrics (step, reward mean, percent correct, eval accuracy) -- Log file locations for debugging - -:::note Rate Limiting -Status checks are rate-limited to once every **30 minutes** per run ID. This prevents excessive polling during long-running training jobs that take hours. -::: - -### 5. Stop or Get Results - -``` -Stop the training run -# or -Get the final results for run abc12345 -``` - -`rl_stop_training()` terminates all three processes in reverse order (environment → trainer → API). `rl_get_results()` retrieves final WandB metrics and training history. - -## Inference Testing - -Before committing to a full training run, you can test if an environment works correctly using `rl_test_inference`. This runs a few steps of inference and scoring using OpenRouter — no Tinker API needed, just an `OPENROUTER_API_KEY`. - -``` -Test the selected environment with inference -``` - -Default configuration: -- **3 steps × 16 completions = 48 rollouts per model** -- Tests 3 models at different scales for robustness: - - `qwen/qwen3-8b` (small) - - `z-ai/glm-4.7-flash` (medium) - - `minimax/minimax-m2.7` (large) -- Total: ~144 rollouts - -This validates: -- Environment loads correctly -- Prompt construction works -- Inference response parsing is robust across model scales -- Verifier/scoring logic produces valid rewards - -## Tinker API Integration - -The trainer uses the [Tinker](https://tinker.computer) API for model training operations: - -- **ServiceClient** — Creates training and sampling clients -- **Training client** — Handles forward-backward passes with importance sampling loss, optimizer steps (Adam), and weight checkpointing -- **Sampling client** — Provides inference using the latest trained weights - -The training loop: -1. Fetches a batch of rollouts from Atropos (prompt + completions + scores) -2. Converts to Tinker Datum objects with padded logprobs and advantages -3. Runs forward-backward pass with importance sampling loss -4. Takes an optimizer step (Adam: lr=4e-5, β1=0.9, β2=0.95) -5. Saves weights and creates a new sampling client for next-step inference -6. Logs metrics to WandB - -## Architecture Diagram - -```mermaid -flowchart LR - api["Atropos API
run-api
port 8000"] - env["Environment
BaseEnv implementation"] - infer["OpenAI / sglang
inference API
port 8001"] - trainer["Tinker Trainer
LoRA training + FastAPI"] - - env <--> api - env --> infer - api -->|"batches: tokens, scores, logprobs"| trainer - trainer -->|"serves inference"| infer -``` - -## Creating Custom Environments - -To create a new RL environment: - -1. Create a Python file in `tinker-atropos/tinker_atropos/environments/` -2. Define a class that inherits from `BaseEnv` -3. Implement the required methods: - - `load_dataset()` — Load your training data - - `get_next_item()` — Provide the next item to the model - - `score_answer()` — Score model outputs and assign rewards - - `collect_trajectories()` — Collect and return trajectories -4. Optionally define a custom config class inheriting from `BaseEnvConfig` - -Study the existing `gsm8k_tinker.py` as a template. The agent can help you create new environments — it can read existing environment files, inspect HuggingFace datasets, and write new environment code. - -## WandB Metrics - -Training runs log to Weights & Biases with these key metrics: - -| Metric | Description | -|--------|-------------| -| `train/loss` | Training loss (importance sampling) | -| `train/learning_rate` | Current learning rate | -| `reward/mean` | Mean reward across groups | -| `logprobs/mean` | Mean reference logprobs | -| `logprobs/mean_training` | Mean training logprobs | -| `logprobs/diff` | Logprob drift (reference - training) | -| `advantages/mean` | Mean advantage values | -| `advantages/std` | Advantage standard deviation | - -## Log Files - -Each training run generates log files in `~/.hermes/logs/rl_training/`: - -``` -logs/ -├── api_{run_id}.log # Atropos API server logs -├── trainer_{run_id}.log # Tinker trainer logs -├── env_{run_id}.log # Environment process logs -└── inference_tests/ # Inference test results - ├── test_{env}_{model}.jsonl - └── test_{env}_{model}.log -``` - -These are invaluable for debugging when training fails or produces unexpected results. diff --git a/website/docs/user-guide/skills/optional/mlops/mlops-hermes-atropos-environments.md b/website/docs/user-guide/skills/optional/mlops/mlops-hermes-atropos-environments.md deleted file mode 100644 index 7cce92a7e0e..00000000000 --- a/website/docs/user-guide/skills/optional/mlops/mlops-hermes-atropos-environments.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -title: "Hermes Atropos Environments — Build, test, and debug Hermes Agent RL environments for Atropos training" -sidebar_label: "Hermes Atropos Environments" -description: "Build, test, and debug Hermes Agent RL environments for Atropos training" ---- - -{/* 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. */} - -# Hermes Atropos Environments - -Build, test, and debug Hermes Agent RL environments for Atropos training. Covers the HermesAgentBaseEnv interface, reward functions, agent loop integration, evaluation with tools, wandb logging, and the three CLI modes (serve/process/evaluate). Use when creating, reviewing, or fixing RL environments in the hermes-agent repo. - -## Skill metadata - -| | | -|---|---| -| Source | Optional — install with `hermes skills install official/mlops/hermes-atropos-environments` | -| Path | `optional-skills/mlops/hermes-atropos-environments` | -| Version | `1.1.0` | -| Author | Hermes Agent | -| License | MIT | -| Platforms | linux, macos, windows | -| Tags | `atropos`, `rl`, `environments`, `training`, `reinforcement-learning`, `reward-functions` | -| Related skills | [`axolotl`](/docs/user-guide/skills/optional/mlops/mlops-training-axolotl), [`fine-tuning-with-trl`](/docs/user-guide/skills/optional/mlops/mlops-training-trl-fine-tuning), `lm-evaluation-harness` | - -## 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. -::: - -# Hermes Agent Atropos Environments - -Guide for building RL environments in the hermes-agent repo that integrate with the Atropos training framework. - -## Architecture Overview - - -``` -Atropos BaseEnv (atroposlib/envs/base.py) - └── HermesAgentBaseEnv (environments/hermes_base_env.py) - ├── Handles agent loop orchestration - ├── Handles tool resolution per group - ├── Handles ToolContext for reward verification - └── YOUR ENVIRONMENT (environments/your_env.py) - Only implements: setup, get_next_item, format_prompt, - compute_reward, evaluate, wandb_log -``` - - -Hermes environments are special because they run a **multi-turn agent loop with tool calling** — not just single-turn completions. The base env handles the loop; you implement the task and scoring. - -## File Locations - -| File | Purpose | -|------|---------| -| `environments/hermes_base_env.py` | Base class with agent loop + tool resolution | -| `environments/agent_loop.py` | `HermesAgentLoop` + `AgentResult` dataclass | -| `environments/tool_context.py` | `ToolContext` for reward verification | -| `environments/tool_call_parsers.py` | Phase 2 tool call parsers (hermes, mistral, etc.) | -| `environments/your_env.py` | Your environment implementation | - -## Inference Setup — Ask the User First - -**IMPORTANT:** Before running any test, evaluation, or data generation command, always ask the user how they want to handle inference. Do NOT assume OpenRouter or any specific endpoint. Present these options: - -1. **OpenRouter** — Ask which model they want to use (e.g., `anthropic/claude-sonnet-4.5`, `google/gemini-2.5-pro`, `meta-llama/llama-3.3-70b-instruct`, etc.). Requires `OPENROUTER_API_KEY` in environment. -2. **Self-hosted VLLM endpoint** — Ask for their base URL (e.g., `http://localhost:8000/v1`) and model name. Set `--openai.server_type vllm`. -3. **Other OpenAI-compatible API** — Ask for the base URL, model name, and any required API key. Set `--openai.server_type openai` and `--openai.health_check false`. -4. **Local Atropos training server** — For `serve` mode with a live training loop. Default `http://localhost:8000/v1`. - -Once the user tells you their setup, use those values in all CLI commands for that session. Example prompts: - -> "Before I run this, how would you like to handle inference? -> 1. OpenRouter (I'll need your preferred model, e.g. claude-sonnet-4.5) -> 2. A self-hosted VLLM endpoint (give me the URL and model name) -> 3. Another OpenAI-compatible API (give me the URL, model, and any auth details) -> 4. Local Atropos training server (serve mode)" - -### Key flags by provider: - -| Provider | `--openai.server_type` | `--openai.health_check` | `--openai.api_key` | -|----------|----------------------|------------------------|-------------------| -| OpenRouter | `openai` | `false` | `$OPENROUTER_API_KEY` | -| VLLM (self-hosted) | `vllm` | (default) | (not needed) | -| Other OpenAI-compatible | `openai` | `false` | As needed | -| Local Atropos | (default) | (default) | (not needed) | - -## Required Methods - -### 1. `setup()` — Load dataset and initialize state - -```python -async def setup(self) -> None: - """Called once at startup. Load datasets, initialize state.""" - # Try HuggingFace first, fallback to built-in samples - try: - from datasets import load_dataset - ds = load_dataset("your/dataset", split="test") - self._items = [...] - except Exception: - self._items = BUILTIN_SAMPLES - - # Always split into train/eval - random.shuffle(self._items) - eval_size = max(20, int(len(self._items) * 0.1)) - self._eval_items = self._items[:eval_size] - self._items = self._items[eval_size:] -``` - -### 2. `get_next_item()` — Return next training item - -```python -async def get_next_item(self) -> dict: - """Return next item, cycling through dataset.""" - item = self._items[self._index % len(self._items)] - self._index += 1 - return item -``` - -### 3. `format_prompt(item)` — Convert item to user message - -```python -def format_prompt(self, item: dict) -> str: - """Convert a dataset item into the user-facing prompt.""" - return f"Research this question: {item['question']}" -``` - -### 4. `compute_reward(item, result, ctx)` — Score the rollout - -**CRITICAL**: `result` is an `AgentResult`, NOT a dict. It has these attributes: -- `result.messages` — List of message dicts (OpenAI format) -- `result.turns_used` — Number of LLM calls made -- `result.finished_naturally` — True if model stopped voluntarily -- `result.tool_errors` — List of ToolError objects - -**AgentResult does NOT have**: `final_response`, `tool_calls`, `tools_used`. -You must extract these from `result.messages`: - -```python -async def compute_reward(self, item, result: AgentResult, ctx: ToolContext) -> float: - # Extract final response (last assistant message with content) - final_response = "" - tools_used = [] - for msg in reversed(result.messages): - if msg.get("role") == "assistant" and msg.get("content") and not final_response: - final_response = msg["content"] - if msg.get("role") == "assistant" and msg.get("tool_calls"): - for tc in msg["tool_calls"]: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - name = fn.get("name", "") - if name: - tools_used.append(name) - - # Score using LLM judge, heuristic, or ToolContext verification - correctness = await self._llm_judge(item, final_response) - return correctness -``` - -`ctx` (ToolContext) gives you terminal/file access to the agent's sandbox for verification: -```python -# Run tests in the agent's sandbox -result = ctx.terminal("pytest /workspace/test.py") -return 1.0 if result["exit_code"] == 0 else 0.0 -``` - -### 5. `evaluate()` — Periodic evaluation with full agent loop - -**MUST use the full agent loop with tools**, not single-turn chat_completion. -The whole point of hermes-agent environments is agentic evaluation: - -```python -async def evaluate(self, *args, **kwargs) -> None: - import time, uuid - from environments.agent_loop import HermesAgentLoop - from environments.tool_context import ToolContext - - start_time = time.time() - tools, valid_names = self._resolve_tools_for_group() - samples = [] - - for item in self._eval_items[:self.config.eval_size]: - task_id = str(uuid.uuid4()) - messages = [] - if self.config.system_prompt: - messages.append({"role": "system", "content": self.config.system_prompt}) - messages.append({"role": "user", "content": self.format_prompt(item)}) - - agent = HermesAgentLoop( - server=self.server, - tool_schemas=tools, - valid_tool_names=valid_names, - max_turns=self.config.max_agent_turns, - task_id=task_id, - temperature=0.0, # Deterministic for eval - max_tokens=self.config.max_token_length, - extra_body=self.config.extra_body, - ) - result = await agent.run(messages) - - ctx = ToolContext(task_id) - try: - reward = await self.compute_reward(item, result, ctx) - finally: - ctx.cleanup() - - samples.append({"prompt": ..., "response": ..., "reward": reward}) - - eval_metrics = {"eval/mean_reward": ...} - await self.evaluate_log(metrics=eval_metrics, samples=samples, - start_time=start_time, end_time=time.time()) -``` - -### 6. `wandb_log()` — Custom metrics logging - -Always call `super().wandb_log()` at the end: - -```python -async def wandb_log(self, wandb_metrics=None): - if wandb_metrics is None: - wandb_metrics = {} - if self._reward_buffer: - n = len(self._reward_buffer) - wandb_metrics["train/mean_reward"] = sum(self._reward_buffer) / n - self._reward_buffer.clear() - await super().wandb_log(wandb_metrics) # MUST call super -``` - -**Pitfall**: `compute_reward` appends to metric buffers. During eval, this pollutes training metrics. Roll back buffer entries added during eval. - -## Config Class - -Always create a custom config subclass with Pydantic Field descriptors. Key inherited fields you can tune: `enabled_toolsets`, `max_agent_turns`, `agent_temperature`, `system_prompt`, `terminal_backend`, `group_size`, `steps_per_eval`, `total_steps`. - -## config_init() — Default Configuration - -Classmethod returning `(YourEnvConfig, [APIServerConfig(...)])`. Set server_type to "openai" for OpenRouter/external APIs. Load API key from environment variable. - -## Three CLI Modes - -```bash -# SERVE — Full training loop (connects to Atropos API server) -python environments/my_env.py serve --openai.base_url http://localhost:8000/v1 - -# PROCESS — Offline data generation (saves JSONL) -python environments/my_env.py process --env.total_steps 10 --env.group_size 1 \ - --env.use_wandb false --env.data_path_to_save_groups output.jsonl \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type --openai.health_check false - -# EVALUATE — Standalone eval (runs setup + evaluate only) -python environments/my_env.py evaluate --env.eval_size 20 \ - --env.data_dir_to_save_evals /tmp/eval_results \ - --openai.base_url "" \ - --openai.model_name "" \ - --openai.server_type --openai.health_check false -``` - -Config priority: CLI args > YAML file > config_init() defaults. - -## Common Pitfalls - -1. **AgentResult has .messages, not .final_response** — Extract the final response by iterating reversed(result.messages) looking for the last assistant message with content. - -2. **evaluate() must use HermesAgentLoop, not chat_completion** — Single-turn chat_completion has no tools. The whole point of hermes-agent benchmarks is agentic evaluation with tool use. - -3. **Don't call _llm_judge twice** — If compute_reward already calls it, extract the score from the buffer instead of calling judge separately in evaluate(). - -4. **Eval pollutes training buffers** — compute_reward appends to metric buffers. During eval, roll back buffer entries to keep training metrics clean. - -5. **Always set health_check=false for OpenRouter** — OpenRouter has no /health endpoint. - -6. **Set data_dir_to_save_evals in evaluate mode** — Without it, results aren't saved. - -7. **default_toolsets class variable vs enabled_toolsets config** — The class variable is a hint; the config field is what actually controls tool resolution. - -8. **Tool call parsing in messages** — Tool calls are dicts with `{"function": {"name": ..., "arguments": ...}}`. Always check `isinstance(tc, dict)`. - -9. **ToolContext.cleanup()** — Always call in a finally block to release sandbox resources. - -10. **server_type must be "openai" for external APIs** — Without it, Atropos assumes a local VLLM server. - -11. **Always ask the user for their inference setup** — Never hardcode or assume a specific provider/model. See the "Inference Setup" section above. - -## Reward Function Patterns - -### LLM Judge (for open-ended tasks) -Use `self.server.chat_completion()` with a scoring prompt. Parse JSON response for score float. Always include a heuristic fallback (keyword overlap) for when the judge call fails. - -### Binary Verification (for code/terminal tasks) -Use `ctx.terminal("pytest test.py -q")` to run tests in the agent's sandbox. Return 1.0 for pass, 0.0 for fail. - -### Multi-Signal (combine multiple indicators) -Weight correctness (0.6) + tool usage (0.2) + efficiency (0.2) + optional bonuses. Clamp to [0, 1]. - -## Testing Your Environment - -1. **Import test**: `python -c "from environments.my_env import MyEnv; print('OK')"` -2. **Ask the user for inference setup** (see "Inference Setup" section above) -3. **Process mode** (1 item): Verify JSONL output has valid tokens, masks, scores -4. **Evaluate mode**: Verify full agent loop runs with tools, metrics logged correctly -5. **Check reward range**: Scores should be in [0, 1], not all identical - -## Minimum Implementation Checklist - -```python -class MyEnv(HermesAgentBaseEnv): - name = "my-env" - env_config_cls = MyEnvConfig - - @classmethod - def config_init(cls): ... # Default server + env config - async def setup(self): ... # Load dataset + train/eval split - async def get_next_item(self): ... # Cycle through training items - def format_prompt(self, item): ... # Item → user message string - async def compute_reward(self, item, result, ctx): ... # Score rollout - async def evaluate(self, *args, **kwargs): ... # Full agent loop eval - async def wandb_log(self, metrics=None): ... # Custom metrics + super() - -if __name__ == "__main__": - MyEnv.cli() -``` diff --git a/website/sidebars.ts b/website/sidebars.ts index 37557df8d11..a2977c87eef 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -103,7 +103,6 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Advanced', items: [ - 'user-guide/features/rl-training', 'user-guide/features/spotify', ], }, @@ -238,7 +237,6 @@ const sidebars: SidebarsConfig = { 'developer-guide/tools-runtime', 'developer-guide/acp-internals', 'developer-guide/cron-internals', - 'developer-guide/environments', 'developer-guide/trajectory-format', ], }, From c8c6ce17315c0f8512cec6f0bc8120141acdf830 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 22:05:39 -0700 Subject: [PATCH 159/214] feat(acp-registry): switch to uvx distribution, drop npm launcher The ACP Registry schema supports uvx as a first-class distribution method alongside npx and binary. Pointing the registry directly at the existing hermes-agent PyPI release removes: - the @nousresearch npm scope (we don't own it) - a separate npm publish step on every weekly release - 90 lines of Node launcher + tests in packages/hermes-agent-acp/ The Zed registry now installs Hermes via: uvx --from 'hermes-agent[acp]==' hermes-acp This is the same command the npm launcher was shelling out to anyway, so end-user behavior is unchanged. Registry CI validates the PyPI URL + version-pin exact match automatically. Changes: - acp_registry/agent.json: distribution.npx -> distribution.uvx - delete packages/hermes-agent-acp/ entirely - scripts/release.py: drop npm-launcher bump paths, keep manifest lockstep - tests/acp/test_registry_manifest.py: assert uvx shape + version pin - tests/scripts/test_release_acp_registry.py: rewrite for uvx-only shape - docs (user-guide + dev-guide): drop all npm-launcher references - delete docs/plans/acp-registry-zed-integration.md (stale, npm-shaped) Validated against agentclientprotocol/registry agent.schema.json via jsonschema. hermes-agent==0.13.0 is already live on PyPI. --- acp_registry/agent.json | 5 +- docs/plans/acp-registry-zed-integration.md | 97 ------------------- packages/hermes-agent-acp/README.md | 26 ----- .../hermes-agent-acp/bin/hermes-agent-acp.js | 66 ------------- packages/hermes-agent-acp/package.json | 24 ----- .../hermes-agent-acp/test/launcher.test.js | 23 ----- scripts/release.py | 37 ++----- tests/acp/test_registry_manifest.py | 34 +++---- tests/scripts/test_release_acp_registry.py | 90 +++++------------ website/docs/developer-guide/acp-internals.md | 2 +- website/docs/user-guide/features/acp.md | 12 +-- 11 files changed, 56 insertions(+), 360 deletions(-) delete mode 100644 docs/plans/acp-registry-zed-integration.md delete mode 100644 packages/hermes-agent-acp/README.md delete mode 100755 packages/hermes-agent-acp/bin/hermes-agent-acp.js delete mode 100644 packages/hermes-agent-acp/package.json delete mode 100644 packages/hermes-agent-acp/test/launcher.test.js diff --git a/acp_registry/agent.json b/acp_registry/agent.json index f6d9d7a574e..b94a48e089f 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -8,8 +8,9 @@ "authors": ["Nous Research"], "license": "MIT", "distribution": { - "npx": { - "package": "@nousresearch/hermes-agent-acp@0.13.0" + "uvx": { + "package": "hermes-agent[acp]==0.13.0", + "args": ["hermes-acp"] } } } diff --git a/docs/plans/acp-registry-zed-integration.md b/docs/plans/acp-registry-zed-integration.md deleted file mode 100644 index 05358f7afed..00000000000 --- a/docs/plans/acp-registry-zed-integration.md +++ /dev/null @@ -1,97 +0,0 @@ -# Hermes Agent ACP Registry + Zed Integration Implementation Plan - -> For Hermes: Use subagent-driven-development skill to implement this plan task-by-task. - -Goal: Make Hermes Agent installable from Zed's official ACP Registry, so users can add Hermes from Zed's agent panel without manual custom `agent_servers` settings. - -Architecture: Use the official `agentclientprotocol/registry` flow instead of the deprecated Zed Agent Server Extension path. Ship a registry-compatible launcher distribution, advertise valid ACP auth methods during every handshake, validate against official registry schema and auth CI, then submit a registry PR for `hermes-agent`. - -Tech Stack: Hermes Agent Python package, ACP adapter (`hermes acp` / `hermes-acp`), npm launcher package, official ACP Registry JSON schema, Zed external agent UI. - ---- - -## Compliance constraints - -- Zed v0.221.x+ prefers the ACP Registry for external agents; do not use Zed Agent Server Extensions for distribution. -- Registry repo layout is top-level `hermes-agent/agent.json` and `hermes-agent/icon.svg`, not `agents/hermes-agent/`. -- Registry metadata must use the official schema: `id`, `name`, `version`, `description`, `distribution`, optional `repository`, `website`, `authors`, `license`. -- Distribution must be exactly one supported type unless intentionally adding another: `binary`, `npx`, or `uvx`. -- Hermes must advertise at least one valid `authMethods` entry on a clean first-run handshake. No-provider/no-auth is not compliant. -- Terminal Auth must be explicit and deterministic: `id: hermes-setup`, `type: terminal`, `args: ["--setup"]`. -- `icon.svg` must be 16x16, square, monochrome, and use only `currentColor` / `none` for fill/stroke; no gradients, hardcoded colors, or `url(#...)` paints. -- ACP server mode must reserve stdout for JSON-RPC only. Diagnostics/logs go to stderr. `--version`, `--check`, and `--setup` are not server mode and may print normally. -- Published npm package must exist and be runnable before the upstream registry PR references it. - ---- - -## Tasks - -1. Verify/implement ACP auth methods. - - Always return terminal setup auth from `initialize()`. - - Return configured provider auth in addition when provider credentials are resolvable. - - Add tests for provider auth, terminal fallback auth, and authenticate behavior before/after provider setup. - -2. Add non-interactive ACP commands. - - `hermes acp --version` - - `hermes acp --check` - - `hermes acp --setup` - - Same behavior through `hermes-acp`. - -3. Build npm launcher package. - - Package: `@nousresearch/hermes-agent-acp@`. - - Command: `uvx --from 'hermes-agent[acp]==' hermes-acp ...args`. - - Fallback: `uv tool run --from ...` when only `uv` exists. - - Forward all args, including `--setup`, `--version`, and `--check`. - - Preserve stdio in server mode. - - Print actionable stderr error when `uv`/`uvx` is missing. - -4. Replace local registry metadata. - - Convert `acp_registry/agent.json` from old command-style local format to official registry schema. - - Replace `acp_registry/icon.svg` with compliant 16x16 currentColor icon. - - Add tests rejecting old fields (`schema_version`, `display_name`, `distribution.type`, `distribution.command`) and unknown distribution keys. - -5. Update docs. - - Zed docs show official ACP Registry install first: Add Agent / `zed: acp registry` -> search Hermes Agent -> install. - - Manual `agent_servers` JSON remains only as local-development fallback. - - Docs include `uv` prerequisite and `hermes acp --check` troubleshooting. - - Developer internals mention npm launcher and terminal setup auth. - -6. Validate locally. - - `python -m pytest tests/acp/test_auth.py tests/acp/test_server.py tests/acp/test_entry.py tests/acp/test_registry_manifest.py -q` - - `(cd packages/hermes-agent-acp && npm test)` - - `(cd packages/hermes-agent-acp && npm pack --dry-run)` - - `hermes acp --version` - - `hermes acp --check` - -7. Validate against official registry tooling before PR. - - In a clone/fork of `agentclientprotocol/registry`, copy files into top-level `hermes-agent/`. - - Run official dry-run build, e.g. `uv run --with jsonschema .github/workflows/build_registry.py --dry-run`. - - Run official auth check if available, e.g. `.github/workflows/scripts/run-registry-docker.sh python3 .github/workflows/verify_agents.py --auth-check`. - - Fix any schema/auth issues before submitting. - -8. Publish and submit. - - Publish `@nousresearch/hermes-agent-acp@`. - - Verify published package: - - `npx @nousresearch/hermes-agent-acp@ --version` - - `npx @nousresearch/hermes-agent-acp@ --check` - - ACP initialize/authMethods smoke test through the published package. - - Open PR to `agentclientprotocol/registry` adding `hermes-agent/agent.json` and `hermes-agent/icon.svg`. - -9. End-to-end Zed verification. - - Install Hermes Agent through Zed's ACP Registry. - - Start a Hermes thread. - - Verify workspace cwd, file tools, terminal tools, tool rendering, and approval prompts. - ---- - -## Acceptance criteria - -- Hermes appears in Zed's official ACP Registry UI. -- Install starts Hermes without custom Zed settings. -- Registry CI passes schema and auth validation. -- ACP stdout remains JSON-RPC only; all logs go to stderr. -- `authMethods` are present and valid on clean first run. -- Terminal Auth can launch Hermes provider/model setup with `--setup`. -- Zed workspace cwd is honored by Hermes file and terminal tools. -- Docs describe registry install first and manual custom config second. -- Package/release automation prevents registry entries from pointing at unpublished versions. diff --git a/packages/hermes-agent-acp/README.md b/packages/hermes-agent-acp/README.md deleted file mode 100644 index b3e9eea0afa..00000000000 --- a/packages/hermes-agent-acp/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# @nousresearch/hermes-agent-acp - -ACP launcher for Hermes Agent. - -This package is intended for clients such as Zed that install agents through the official ACP Registry. It launches the Python Hermes ACP server with: - -```bash -uvx --from 'hermes-agent[acp]==0.13.0' hermes-acp -``` - -## Requirements - -- Node.js 18+ -- `uv` or `uvx` on PATH -- Hermes provider credentials configured with `hermes model`, or through Hermes' normal `~/.hermes/.env` / `~/.hermes/config.yaml` setup - -## Commands - -```bash -npx @nousresearch/hermes-agent-acp@0.13.0 --version -npx @nousresearch/hermes-agent-acp@0.13.0 --check -npx @nousresearch/hermes-agent-acp@0.13.0 --setup -npx @nousresearch/hermes-agent-acp@0.13.0 -``` - -Normal no-argument mode reserves stdout for ACP JSON-RPC traffic. Diagnostics are emitted on stderr by Hermes. diff --git a/packages/hermes-agent-acp/bin/hermes-agent-acp.js b/packages/hermes-agent-acp/bin/hermes-agent-acp.js deleted file mode 100755 index b9d571d3550..00000000000 --- a/packages/hermes-agent-acp/bin/hermes-agent-acp.js +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const { spawn, spawnSync } = require('node:child_process'); - -const HERMES_AGENT_VERSION = '0.13.0'; -const HERMES_SPEC = `hermes-agent[acp]==${HERMES_AGENT_VERSION}`; - -function commandExists(command) { - const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); - return !result.error && result.status === 0; -} - -function buildCommand(argv, exists = commandExists) { - if (exists('uvx')) { - return { - command: 'uvx', - args: ['--from', HERMES_SPEC, 'hermes-acp', ...argv], - }; - } - - if (exists('uv')) { - return { - command: 'uv', - args: ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', ...argv], - }; - } - - return null; -} - -function main() { - const argv = process.argv.slice(2); - const command = buildCommand(argv); - - if (!command) { - console.error('Hermes Agent ACP requires uv or uvx to launch the Python package.'); - console.error('Install uv from https://docs.astral.sh/uv/getting-started/installation/'); - console.error('Then retry this agent from Zed.'); - process.exit(127); - } - - const child = spawn(command.command, command.args, { - stdio: 'inherit', - env: process.env, - }); - - child.on('error', (error) => { - console.error(`Failed to start Hermes Agent ACP: ${error.message}`); - process.exit(1); - }); - - child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); - }); -} - -if (require.main === module) { - main(); -} - -module.exports = { buildCommand, HERMES_AGENT_VERSION, HERMES_SPEC }; diff --git a/packages/hermes-agent-acp/package.json b/packages/hermes-agent-acp/package.json deleted file mode 100644 index 224bb275b77..00000000000 --- a/packages/hermes-agent-acp/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@nousresearch/hermes-agent-acp", - "version": "0.13.0", - "description": "ACP launcher for Hermes Agent", - "bin": { - "hermes-agent-acp": "bin/hermes-agent-acp.js" - }, - "files": [ - "bin/", - "README.md" - ], - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/NousResearch/hermes-agent.git", - "directory": "packages/hermes-agent-acp" - }, - "engines": { - "node": ">=18" - }, - "scripts": { - "test": "node --test" - } -} diff --git a/packages/hermes-agent-acp/test/launcher.test.js b/packages/hermes-agent-acp/test/launcher.test.js deleted file mode 100644 index 7a338305e56..00000000000 --- a/packages/hermes-agent-acp/test/launcher.test.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { buildCommand, HERMES_SPEC } = require('../bin/hermes-agent-acp.js'); - -test('uses uvx when available and forwards args', () => { - const command = buildCommand(['--version'], (name) => name === 'uvx'); - - assert.equal(command.command, 'uvx'); - assert.deepEqual(command.args, ['--from', HERMES_SPEC, 'hermes-acp', '--version']); -}); - -test('falls back to uv tool run and forwards setup args', () => { - const command = buildCommand(['--setup'], (name) => name === 'uv'); - - assert.equal(command.command, 'uv'); - assert.deepEqual(command.args, ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', '--setup']); -}); - -test('returns null when neither uvx nor uv is available', () => { - assert.equal(buildCommand([], () => false), null); -}); diff --git a/scripts/release.py b/scripts/release.py index 17a8dffd31e..621ebddec95 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -34,12 +34,10 @@ REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" -# ACP Registry assets that must stay version-locked with pyproject.toml. -# tests/acp/test_registry_manifest.py enforces this lockstep, so the release -# bump touches all four files atomically. +# ACP Registry manifest must stay version-locked with pyproject.toml. +# tests/acp/test_registry_manifest.py enforces this lockstep so the release +# bump touches both files atomically. ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" -ACP_NPM_PACKAGE_JSON = REPO_ROOT / "packages" / "hermes-agent-acp" / "package.json" -ACP_NPM_LAUNCHER = REPO_ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js" # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping @@ -1168,38 +1166,23 @@ def update_version_files(semver: str, calver_date: str): def _update_acp_registry_versions(semver: str) -> None: - """Bump the ACP Registry manifest, npm package, and launcher in lockstep. + """Bump the ACP Registry manifest's version + uvx package pin in lockstep + with pyproject. - Skips silently if any of the files are missing — the ACP Registry assets - landed mid-cycle and older release branches may not have them. + Skips silently if the manifest is missing — older release branches predate + the ACP Registry assets. """ if ACP_REGISTRY_MANIFEST.exists(): manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8")) manifest["version"] = semver - npx = manifest.get("distribution", {}).get("npx", {}) - if "package" in npx: - npx["package"] = f"@nousresearch/hermes-agent-acp@{semver}" + uvx = manifest.get("distribution", {}).get("uvx", {}) + if "package" in uvx: + uvx["package"] = f"hermes-agent[acp]=={semver}" # Preserve trailing newline + 2-space indent the file already uses. ACP_REGISTRY_MANIFEST.write_text( json.dumps(manifest, indent=2) + "\n", encoding="utf-8" ) - if ACP_NPM_PACKAGE_JSON.exists(): - package = json.loads(ACP_NPM_PACKAGE_JSON.read_text(encoding="utf-8")) - package["version"] = semver - ACP_NPM_PACKAGE_JSON.write_text( - json.dumps(package, indent=2) + "\n", encoding="utf-8" - ) - - if ACP_NPM_LAUNCHER.exists(): - launcher = ACP_NPM_LAUNCHER.read_text(encoding="utf-8") - launcher = re.sub( - r"const HERMES_AGENT_VERSION\s*=\s*'[^']+';", - f"const HERMES_AGENT_VERSION = '{semver}';", - launcher, - ) - ACP_NPM_LAUNCHER.write_text(launcher, encoding="utf-8") - def build_release_artifacts(semver: str) -> list[Path]: """Build sdist/wheel artifacts for the current release. diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py index 134cb5415ae..633b4a8494c 100644 --- a/tests/acp/test_registry_manifest.py +++ b/tests/acp/test_registry_manifest.py @@ -39,36 +39,30 @@ def test_agent_json_matches_official_registry_required_fields(): assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS -def test_agent_json_uses_npx_distribution_without_local_command_fields(): +def test_agent_json_uses_uvx_distribution_without_local_command_fields(): data = _manifest() - assert set(data["distribution"]) == {"npx"} - assert set(data["distribution"]["npx"]) == {"package"} - assert data["distribution"]["npx"]["package"] == ( - f"@nousresearch/hermes-agent-acp@{data['version']}" - ) + assert set(data["distribution"]) == {"uvx"} + uvx = data["distribution"]["uvx"] + # Schema allows {package, args, env}; we use {package, args}. + assert set(uvx) <= {"package", "args", "env"} + assert "package" in uvx + assert uvx["package"] == f"hermes-agent[acp]=={data['version']}" + assert uvx["args"] == ["hermes-acp"] + # Old command-shape fields must not leak back in. assert "type" not in data["distribution"] assert "command" not in data["distribution"] - assert "args" not in data["distribution"] def test_agent_json_version_matches_pyproject(): assert _manifest()["version"] == _pyproject_version() -def test_npm_launcher_versions_match_pyproject_and_manifest(): - version = _pyproject_version() - package = json.loads( - (ROOT / "packages" / "hermes-agent-acp" / "package.json").read_text(encoding="utf-8") - ) - launcher = (ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js").read_text( - encoding="utf-8" - ) - - assert package["version"] == version - assert f"const HERMES_AGENT_VERSION = '{version}';" in launcher - assert _manifest()["distribution"]["npx"]["package"] == ( - f"@nousresearch/hermes-agent-acp@{version}" +def test_agent_json_pins_uvx_package_to_pyproject_version(): + """The registry CI rejects ``@latest`` and floating pins; the manifest must + always reference the exact PyPI version listed in pyproject.toml.""" + assert _manifest()["distribution"]["uvx"]["package"] == ( + f"hermes-agent[acp]=={_pyproject_version()}" ) diff --git a/tests/scripts/test_release_acp_registry.py b/tests/scripts/test_release_acp_registry.py index a2e71bd0b19..4d20cda25bd 100644 --- a/tests/scripts/test_release_acp_registry.py +++ b/tests/scripts/test_release_acp_registry.py @@ -1,11 +1,11 @@ """Tests for the ACP Registry version-lockstep bump in scripts/release.py. -The official ACP Registry manifest, the @nousresearch/hermes-agent-acp npm -package, and the npm launcher's HERMES_AGENT_VERSION constant must all match -``pyproject.toml`` exactly — ``tests/acp/test_registry_manifest.py`` enforces -this at lint time. The release script is the single place that bumps them in -lockstep with pyproject; if that bump ever silently breaks, weekly releases -fail the manifest test until someone hand-edits four files. +The official ACP Registry manifest must match ``pyproject.toml`` exactly — +``tests/acp/test_registry_manifest.py`` enforces this at lint time, and the +upstream registry CI rejects ``@latest`` / floating pins. The release script +is the single place that bumps the manifest in lockstep with pyproject; if +that bump ever silently breaks, weekly releases fail the manifest test +until someone hand-edits the JSON. """ from __future__ import annotations @@ -25,26 +25,14 @@ def _load_release_module(monkeypatch, tmp_root: Path): module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - # Repoint every REPO_ROOT-derived path at our temp tree. monkeypatch.setattr(module, "REPO_ROOT", tmp_root) monkeypatch.setattr( module, "ACP_REGISTRY_MANIFEST", tmp_root / "acp_registry" / "agent.json" ) - monkeypatch.setattr( - module, - "ACP_NPM_PACKAGE_JSON", - tmp_root / "packages" / "hermes-agent-acp" / "package.json", - ) - monkeypatch.setattr( - module, - "ACP_NPM_LAUNCHER", - tmp_root / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js", - ) return module -def _write_fixture(root: Path, version: str) -> None: - """Write the three ACP-registry files we expect release.py to bump.""" +def _write_manifest(root: Path, version: str) -> None: manifest_dir = root / "acp_registry" manifest_dir.mkdir(parents=True) (manifest_dir / "agent.json").write_text( @@ -55,7 +43,10 @@ def _write_fixture(root: Path, version: str) -> None: "version": version, "description": "test", "distribution": { - "npx": {"package": f"@nousresearch/hermes-agent-acp@{version}"} + "uvx": { + "package": f"hermes-agent[acp]=={version}", + "args": ["hermes-acp"], + } }, }, indent=2, @@ -64,29 +55,9 @@ def _write_fixture(root: Path, version: str) -> None: encoding="utf-8", ) - package_dir = root / "packages" / "hermes-agent-acp" - (package_dir / "bin").mkdir(parents=True) - (package_dir / "package.json").write_text( - json.dumps( - { - "name": "@nousresearch/hermes-agent-acp", - "version": version, - "bin": {"hermes-agent-acp": "bin/hermes-agent-acp.js"}, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - (package_dir / "bin" / "hermes-agent-acp.js").write_text( - f"const HERMES_AGENT_VERSION = '{version}';\n" - f"const HERMES_SPEC = `hermes-agent[acp]==${{HERMES_AGENT_VERSION}}`;\n", - encoding="utf-8", - ) - -def test_update_acp_registry_versions_bumps_all_three_files(monkeypatch, tmp_path): - _write_fixture(tmp_path, "0.13.0") +def test_update_acp_registry_versions_bumps_manifest_and_pin(monkeypatch, tmp_path): + _write_manifest(tmp_path, "0.13.0") module = _load_release_module(monkeypatch, tmp_path) module._update_acp_registry_versions("0.14.0") @@ -95,41 +66,27 @@ def test_update_acp_registry_versions_bumps_all_three_files(monkeypatch, tmp_pat (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") ) assert manifest["version"] == "0.14.0" - assert ( - manifest["distribution"]["npx"]["package"] - == "@nousresearch/hermes-agent-acp@0.14.0" - ) - - package = json.loads( - ( - tmp_path / "packages" / "hermes-agent-acp" / "package.json" - ).read_text(encoding="utf-8") - ) - assert package["version"] == "0.14.0" - - launcher = ( - tmp_path / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js" - ).read_text(encoding="utf-8") - assert "const HERMES_AGENT_VERSION = '0.14.0';" in launcher - assert "0.13.0" not in launcher + assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0" + # args stay untouched so we don't accidentally rewrite them. + assert manifest["distribution"]["uvx"]["args"] == ["hermes-acp"] -def test_update_acp_registry_versions_is_silent_when_files_missing( +def test_update_acp_registry_versions_is_silent_when_manifest_missing( monkeypatch, tmp_path ): - """Older release branches predate the ACP Registry assets — must no-op.""" + """Older release branches predate the ACP Registry asset — must no-op.""" module = _load_release_module(monkeypatch, tmp_path) # No fixture written; function should not raise. module._update_acp_registry_versions("0.14.0") -def test_update_version_files_bumps_acp_assets_alongside_pyproject( +def test_update_version_files_bumps_manifest_alongside_pyproject( monkeypatch, tmp_path ): """End-to-end: update_version_files() is the function release.py actually - calls, so it must drive the ACP bump too.""" - _write_fixture(tmp_path, "0.13.0") + calls, so it must drive the manifest bump too.""" + _write_manifest(tmp_path, "0.13.0") (tmp_path / "pyproject.toml").write_text( '[project]\nname = "hermes-agent"\nversion = "0.13.0"\n', encoding="utf-8" ) @@ -153,7 +110,4 @@ def test_update_version_files_bumps_acp_assets_alongside_pyproject( (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") ) assert manifest["version"] == "0.14.0" - assert ( - manifest["distribution"]["npx"]["package"] - == "@nousresearch/hermes-agent-acp@0.14.0" - ) + assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0" diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md index f688869033d..89ae398b6af 100644 --- a/website/docs/developer-guide/acp-internals.md +++ b/website/docs/developer-guide/acp-internals.md @@ -31,7 +31,7 @@ hermes acp / hermes-acp / python -m acp_adapter -> acp.run_agent(agent, use_unstable_protocol=True) ``` -The Zed ACP Registry path launches the same adapter through `npx @nousresearch/hermes-agent-acp@`, which delegates to `uvx --from 'hermes-agent[acp]==' hermes-acp`. +The Zed ACP Registry path launches the same adapter through `uvx --from 'hermes-agent[acp]==' hermes-acp`, pointed at the `hermes-agent` PyPI release. Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr. diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index b55664191c3..92a755c9ada 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -45,13 +45,13 @@ This installs the `agent-client-protocol` dependency and enables: - `hermes-acp` - `python -m acp_adapter` -For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses the npm launcher package `@nousresearch/hermes-agent-acp`, which runs: +For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses a `uvx` distribution that runs: ```bash uvx --from 'hermes-agent[acp]==' hermes-acp ``` -Make sure `uv` or `uvx` is available on `PATH` before using the registry install path. +Make sure `uv` is available on `PATH` before using the registry install path. ## Launching the ACP server @@ -150,13 +150,13 @@ acp_registry/icon.svg The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`. -The registry entry uses an `npx` distribution: +The registry entry uses a `uvx` distribution that points directly at the `hermes-agent` PyPI release: ```text -npx @nousresearch/hermes-agent-acp@ +uvx --from 'hermes-agent[acp]==' hermes-acp ``` -The launcher then runs `hermes-acp` from the matching Python package version. +The registry CI verifies that the pinned version exists on PyPI, so the manifest's `version` and uvx `package` pin must always match `pyproject.toml`. `scripts/release.py` keeps them in lockstep automatically. ## Configuration and credentials @@ -207,7 +207,7 @@ Check: - For manual/local development, verify the custom `agent_servers` command points to `hermes acp`. - Hermes is installed and on your PATH. - The ACP extra is installed (`pip install -e '.[acp]'`). -- `uv` or `uvx` is installed if launching from the official Zed registry entry. +- `uv` is installed if launching from the official Zed registry entry. ### ACP starts but immediately errors From bcca5ed34d31abfd469d139e14bd962c916ff64f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 22:30:12 -0700 Subject: [PATCH 160/214] fix(deps): pin brotlicffi so aiohttp can decode Discord's Brotli attachments Discord's CDN serves attachments with Content-Encoding: br. aiohttp's compression_utils tries 'import brotlicffi as brotli' first and falls back to google's Brotli, but Brotli<1.2.0's Decompressor.process() is 1-arg while aiohttp calls it with 2 args (data, max_length). Result: every .txt/.md/.doc uploaded to a Discord-gateway session fails to decode at att.read() with 'Can not decode content-encoding: br' / 'TypeError: process() takes exactly 1 argument (2 given)', the agent never sees the bytes, and falls back to filesystem guessing. Pin brotlicffi==1.2.0.1 in both surfaces: - tools/lazy_deps.py 'platform.discord' tuple: Discord users on the lazy-install path get it on first discord.py import. - pyproject.toml [messaging] extra: users who explicitly install hermes-agent[messaging] (skipping the lazy path) get it eagerly. brotlicffi wins aiohttp's import race regardless of what else is installed (try brotlicffi / except: import brotli), so existing setups that already pulled google's Brotli transitively don't change behavior beyond the bug fix. ~1.5 MB wheel, manylinux/macOS/Windows coverage. E2E verified: round-trip decode of Brotli-compressed payload via aiohttp.compression_utils.brotli succeeds with brotlicffi pinned; same test against Brotli==1.1.0 alone reproduces the reported TypeError. Credit to @Korkyzer for the original diagnosis and fix shape in #15744; the lazy-deps gating layer was added on top to keep brotlicffi out of the install path for users who don't run a Discord gateway. Fixes #12511. Closes #15744. Co-authored-by: Korky --- pyproject.toml | 2 +- scripts/release.py | 1 + tools/lazy_deps.py | 7 ++++++- uv.lock | 27 +++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 982dc01be17..20fecac228e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ daytona = ["daytona==0.155.0"] vercel = ["vercel==0.5.7"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] -messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "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", "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"] diff --git a/scripts/release.py b/scripts/release.py index 621ebddec95..d3118bc128e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -69,6 +69,7 @@ AUTHOR_MAP = { "piyushvp1@gmail.com": "thelumiereguy", "421774554@qq.com": "wuli666", "harish.kukreja@gmail.com": "counterposition", + "korkyzer@gmail.com": "Korkyzer", "1046611633@qq.com": "zhengyn0001", "1095245867@qq.com": "littlewwwhite", "db@project-aeon.com": "db-aeon", diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 09347e8281c..258a09ef667 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -116,7 +116,12 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { # ─── Messaging platforms (lazy-installable on demand) ────────────────── "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), - "platform.discord": ("discord.py[voice]==2.7.1",), + # brotlicffi gives aiohttp a working 2-arg Decompressor.process() for + # Discord CDN's Brotli-encoded attachments. Without it, aiohttp falls + # back to google's `Brotli` package (1-arg API), and any .txt/.md/.doc + # uploaded to the Discord gateway fails to decode at att.read() with + # "Can not decode content-encoding: br" — see #12511 / #15744. + "platform.discord": ("discord.py[voice]==2.7.1", "brotlicffi==1.2.0.1"), "platform.slack": ( "slack-bolt==1.27.0", "slack-sdk==3.40.1", diff --git a/uv.lock b/uv.lock index 72cef3b0cdd..2508637a081 100644 --- a/uv.lock +++ b/uv.lock @@ -537,6 +537,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, ] +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, +] + [[package]] name = "cbor2" version = "5.8.0" @@ -1662,6 +1687,7 @@ mcp = [ ] messaging = [ { name = "aiohttp" }, + { name = "brotlicffi" }, { name = "discord-py", extra = ["voice"] }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "qrcode" }, @@ -1742,6 +1768,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, + { name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" }, { name = "croniter", specifier = "==6.0.0" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, From f8745f59c2738025a02ca161307f4dcbfd0eb34a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 14 May 2026 22:39:59 -0700 Subject: [PATCH 161/214] fix(cli): kill resize scrollback duplication + light-mode visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two long-standing prompt_toolkit bugs in the base hermes CLI: 1. Resize duplication. Column-shrink resize used to push 40+ rows of duplicate chrome (status bar, input rules) into terminal scrollback every resize. Same wall as pt issues #29 (open since 2014), #1675, #1933 — aider/xonsh/ipython all use alt-screen to dodge it. Root cause (verified by reading prompt_toolkit/renderer.py): _output_screen_diff (renderer.py L232-242) deliberately moves the cursor to the bottom of the canvas after every paint 'to make sure the terminal scrolls up'. In non-fullscreen mode this scrolls chrome content into terminal scrollback on every render — not just on resize. Fix: monkey-patch prompt_toolkit.renderer._output_screen_diff to bypass the reserve-vertical-space cursor move. When pt's logic checks 'if current_height > previous_screen.height', we inflate the previous screen height so the branch falls through. ~30-line wrapper, no fork of pt, no alt-screen, no DECSTBM scroll region. Verified empirically in real Terminal.app: 10 resizes (mixed shrinks/widens 1300→500→1400) during streaming produced ZERO scrollback delta, full agent response preserved, status bar pinned at bottom, no visible duplicates. pt is pinned to ==3.0.52 so the private-function patch is safe; future pt bumps will need to re-verify the signature matches. 2. Light-mode terminal visibility. Hardcoded skin colors (#FFF8DC cornsilk, #FFD700 gold, #B8860B dark goldenrod) are tuned for dark Terminal.app — invisible on light/cream backgrounds. Port ui-tui/src/theme.ts detectLightMode() to Python so the base CLI adapts. Detection priority: HERMES_LIGHT/HERMES_TUI_LIGHT env → HERMES_TUI_THEME=light|dark → HERMES_TUI_BACKGROUND=#RRGGBB → COLORFGBG env (xterm/Konsole/urxvt) → OSC 11 query (\x1b]11;?\x1b\\) with 100ms timeout → default dark. OSC 11 is tty-gated so gateway/cron/batch/subagent code paths don't pay the timeout cost. When light mode is detected, dark-mode colors auto-remap to readable equivalents (#FFF8DC → #1A1A1A, #FFD700 → #9A6B00, etc). Hooked at three points: - _hex_to_ansi() — auto-remaps any color emitted via the ANSI helper - _build_tui_style_dict() — rewrites pt style strings (chrome bg/fg) - SkinConfig.get_color() — wrapped at module load so Rich Panel borders/body text get the remap too Status-bar foreground colors (#C0C0C0, #888888, etc.) are explicitly skipped because they're paired with a dark navy bg — remapping them would make them invisible in dark mode. 3. Other visibility fixes: [thinking] reasoning preview now uses ANSI dim+italic (\x1b[2;3m) instead of #B8860B so it inherits terminal default fg color. Input/prompt area defaults to terminal default fg (was #FFF8DC cornsilk → invisible on cream). Co-authored-by: Brooklyn Nicholson --- cli.py | 406 +++++++++++++++++++++++++++++++++++--- hermes_cli/skin_engine.py | 14 +- 2 files changed, 391 insertions(+), 29 deletions(-) diff --git a/cli.py b/cli.py index af179c86c13..dbbcf877006 100644 --- a/cli.py +++ b/cli.py @@ -1242,7 +1242,13 @@ _STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: - """Convert a hex color like '#268bd2' to a true-color ANSI escape.""" + """Convert a hex color like '#268bd2' to a true-color ANSI escape. + + Auto-remaps known dark-mode-tuned colors to readable light-mode + equivalents when running on a light terminal (see + _maybe_remap_for_light_mode + _LIGHT_MODE_REMAP). + """ + hex_color = _maybe_remap_for_light_mode(hex_color) try: r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) @@ -1253,6 +1259,250 @@ def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m" +# ──────────────────────────────────────────────────────────────────────── +# Light/dark terminal mode detection. +# +# Mirrors ui-tui/src/theme.ts detectLightMode(). Used to decide whether +# to remap "near-white" skin colors (e.g. #FFF8DC banner_text, #B8860B +# banner_dim) to darker equivalents that are readable on a light +# Terminal.app / iTerm2 background. +# +# Detection priority: +# 1. HERMES_LIGHT / HERMES_TUI_LIGHT env (true/false) — explicit override +# 2. HERMES_TUI_THEME=light|dark — explicit theme +# 3. HERMES_TUI_BACKGROUND=#RRGGBB — explicit bg hint +# 4. COLORFGBG env (set by xterm/Konsole/urxvt) — bg slot 7/15 = light +# 5. OSC 11 query (\x1b]11;?\x1b\\) — ask the terminal directly +# 6. Default: assume dark (matches the legacy Hermes assumption) +# +# Cached after first call so we don't query the terminal repeatedly. +_LIGHT_MODE_CACHE: bool | None = None +_TRUE_RE = re.compile(r"^(1|true|on|yes|y)$") +_FALSE_RE = re.compile(r"^(0|false|off|no|n)$") +_LIGHT_DEFAULT_TERM_PROGRAMS = frozenset() # Apple_Terminal doesn't reliably indicate; require explicit + + +def _luminance_from_hex(hex_str: str) -> float | None: + s = (hex_str or "").strip().lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6 or not all(c in "0123456789abcdefABCDEF" for c in s): + return None + try: + r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + except ValueError: + return None + # Rec.709 luma + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0 + + +def _query_osc11_background() -> str | None: + """Ask the terminal for its background color via OSC 11. + + Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ + within a few ms. We wait up to 100ms total before giving up. + Returns "#RRGGBB" or None on timeout / non-tty. + """ + if not sys.stdin.isatty() or not sys.stdout.isatty(): + return None + try: + import termios + import tty + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + except Exception: + return None + try: + try: + tty.setcbreak(fd) + except Exception: + return None + try: + sys.stdout.write("\x1b]11;?\x1b\\") + sys.stdout.flush() + except Exception: + return None + # Read up to ~50ms for the response + import select + deadline = time.monotonic() + 0.1 + buf = b"" + while time.monotonic() < deadline: + r, _, _ = select.select([fd], [], [], deadline - time.monotonic()) + if not r: + continue + try: + chunk = os.read(fd, 64) + except OSError: + break + if not chunk: + break + buf += chunk + if b"\x1b\\" in buf or b"\x07" in buf: + break + # Parse: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ + m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf) + if not m: + return None + # Each component is 1-4 hex digits — normalize to 8-bit + def norm(h: bytes) -> int: + v = int(h, 16) + # Scale to 0-255 based on hex length + bits = len(h) * 4 + return (v * 255) // ((1 << bits) - 1) if bits else 0 + r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3)) + return f"#{r:02X}{g:02X}{b:02X}" + finally: + try: + termios.tcsetattr(fd, termios.TCSANOW, old) + except Exception: + pass + + +def _detect_light_mode() -> bool: + global _LIGHT_MODE_CACHE + if _LIGHT_MODE_CACHE is not None: + return _LIGHT_MODE_CACHE + result = False + try: + # 1. Explicit env override + for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"): + v = (os.environ.get(var) or "").strip().lower() + if _TRUE_RE.match(v): + result = True + _LIGHT_MODE_CACHE = result + return result + if _FALSE_RE.match(v): + _LIGHT_MODE_CACHE = result + return result + # 2. Theme hint + theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower() + if theme == "light": + result = True + _LIGHT_MODE_CACHE = result + return result + if theme == "dark": + _LIGHT_MODE_CACHE = result + return result + # 3. Explicit bg hex + bg_hint = os.environ.get("HERMES_TUI_BACKGROUND") or "" + bg_lum = _luminance_from_hex(bg_hint) + if bg_lum is not None: + result = bg_lum >= 0.5 + _LIGHT_MODE_CACHE = result + return result + # 4. COLORFGBG (xterm/Konsole/urxvt) + cfgbg = (os.environ.get("COLORFGBG") or "").strip() + if cfgbg: + last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg + if last.isdigit(): + bg = int(last) + if bg in (7, 15): + result = True + _LIGHT_MODE_CACHE = result + return result + if 0 <= bg < 16: + _LIGHT_MODE_CACHE = result + return result + # 5. OSC 11 query (best-effort, only when stdin/stdout are TTY) + bg_color = _query_osc11_background() + if bg_color: + lum = _luminance_from_hex(bg_color) + if lum is not None: + result = lum >= 0.5 + _LIGHT_MODE_CACHE = result + return result + # 6. TERM_PROGRAM allow-list (currently empty) + tp = (os.environ.get("TERM_PROGRAM") or "").strip() + if tp in _LIGHT_DEFAULT_TERM_PROGRAMS: + result = True + except Exception: + result = False + _LIGHT_MODE_CACHE = result + return result + + +# Light-mode equivalents of skin colors that are unreadable on cream +# Terminal.app backgrounds. Used by _SkinAwareAnsi to remap colors +# at resolution time when light mode is detected. +# +# IMPORTANT: only remap colors that are used as STANDALONE foregrounds +# on the terminal's background. Don't remap colors that are paired +# with a dark bg (e.g. status bar text on bg:#1a1a2e) — those would +# become invisible the OTHER direction (dark gray on dark navy). +_LIGHT_MODE_REMAP: dict[str, str] = { + # Original (dark-mode) -> Light-mode replacement (darker, readable) + "#FFF8DC": "#1A1A1A", # cornsilk -> near-black + "#FFD700": "#9A6B00", # gold -> dark goldenrod (readable on cream) + "#FFBF00": "#8A5A00", # amber -> dark amber + "#B8860B": "#5C4500", # dark goldenrod -> deeper brown (more contrast) + "#DAA520": "#6B4F00", # goldenrod -> dark olive + "#F1E6CF": "#1A1A1A", # cream -> near-black + "#c9d1d9": "#24292F", # github-light fg + "#EAF7FF": "#0F1B26", # ice + "#F5F5F5": "#1A1A1A", + "#FFF0D4": "#1A1A1A", + "#CD7F32": "#8A4F1A", # bronze -> darker bronze + "#FFEFB5": "#3A2A00", + # NOTE: skipping #C0C0C0/#888888/#555555/#8B8682 — those are + # status-bar foregrounds paired with dark navy bg, where dark + # remap values would become invisible. +} + + +def _maybe_remap_for_light_mode(hex_color: str) -> str: + """If we're in light mode, remap a dark-mode-tuned color to a + higher-contrast equivalent. No-op in dark mode.""" + if not _detect_light_mode(): + return hex_color + if not hex_color or not hex_color.startswith("#"): + return hex_color + # Case-insensitive lookup + upper = hex_color.upper() + if upper in _LIGHT_MODE_REMAP_UPPER: + return _LIGHT_MODE_REMAP_UPPER[upper] + return hex_color + + +# Pre-uppercased lookup table for case-insensitive remapping +_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()} + + +def _install_skin_light_mode_hook() -> None: + """Wrap SkinConfig.get_color at import time so EVERY skin color read goes + through the light-mode remap. Idempotent.""" + try: + from hermes_cli.skin_engine import SkinConfig # type: ignore[import] + except Exception: + return + if getattr(SkinConfig, "_hermes_light_mode_hook_installed", False): + return + _orig_get_color = SkinConfig.get_color + + def _wrapped_get_color(self, key, fallback=""): + value = _orig_get_color(self, key, fallback) + try: + return _maybe_remap_for_light_mode(value) + except Exception: + return value + + SkinConfig.get_color = _wrapped_get_color # type: ignore[method-assign] + SkinConfig._hermes_light_mode_hook_installed = True # type: ignore[attr-defined] + + +_install_skin_light_mode_hook() + + +# Prime the light-mode detection cache early (at module load) when +# we're running interactively so OSC 11 happens before pt grabs the +# tty. Skip for non-tty contexts (subagents, gateway, tests). +try: + if sys.stdin.isatty() and sys.stdout.isatty(): + _detect_light_mode() +except Exception: + pass + + + class _SkinAwareAnsi: """Lazy ANSI escape that resolves from the skin engine on first use. @@ -1290,7 +1540,12 @@ class _SkinAwareAnsi: _ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True) -_DIM = _SkinAwareAnsi("banner_dim", "#B8860B") +# Use ANSI dim+italic attributes (\x1b[2;3m) instead of a hardcoded +# hex color so dim/thinking text inherits the terminal's default +# foreground color and stays readable in both light and dark +# Terminal.app modes. Hardcoded skin colors like #B8860B +# (dark goldenrod) become invisible against light cream backgrounds. +_DIM = "\x1b[2;3m" def _accent_hex() -> str: @@ -7947,8 +8202,8 @@ class HermesCLI: from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") - _resp_color = _skin.get_color("response_border", "#CD7F32") - _resp_text = _skin.get_color("banner_text", "#FFF8DC") + _resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32")) + _resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC")) except Exception: label = "⚕ Hermes" _resp_color = "#CD7F32" @@ -8549,7 +8804,8 @@ class HermesCLI: set_active_skin(new_skin) _ACCENT.reset() # Re-resolve ANSI color for the new skin - _DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin + # _DIM is now a fixed dim+italic ANSI escape (terminal-default fg) + # so it doesn't need re-resolving on skin switch. if save_config_value("display.skin", new_skin): print(f" Skin set to: {new_skin} (saved)") else: @@ -10928,12 +11184,12 @@ class HermesCLI: from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "⚕ Hermes") - _resp_color = _skin.get_color("response_border", "#CD7F32") - _resp_text = _skin.get_color("banner_text", "#FFF8DC") + _resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32")) + _resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC")) except Exception: label = "⚕ Hermes" - _resp_color = "#CD7F32" - _resp_text = "#FFF8DC" + _resp_color = _maybe_remap_for_light_mode("#CD7F32") + _resp_text = _maybe_remap_for_light_mode("#FFF8DC") is_error_response = result and (result.get("failed") or result.get("partial")) already_streamed = self._stream_started and self._stream_box_opened and not is_error_response @@ -11172,13 +11428,48 @@ class HermesCLI: return "".join(text for _, text in self._get_tui_prompt_fragments()) def _build_tui_style_dict(self) -> dict[str, str]: - """Layer the active skin's prompt_toolkit colors over the base TUI style.""" + """Layer the active skin's prompt_toolkit colors over the base TUI style. + + Also rewrites any hex-color tokens in the resulting style strings + to their light-mode equivalents (via _LIGHT_MODE_REMAP) when the + terminal is detected as light. This makes the chrome readable + on cream Terminal.app backgrounds without per-skin overrides. + """ style_dict = dict(getattr(self, "_tui_style_base", {}) or {}) try: from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides style_dict.update(get_prompt_toolkit_style_overrides()) except Exception: pass + # Light-mode remap on the style strings. Each value is a pt + # style string like "bg:#1a1a2e #C0C0C0 bold" — split on space, + # rewrite any "#XXX" tokens (including "bg:#XXX") through the + # light-mode remap, rejoin. + # + # CRITICAL: skip the remap entirely when a style string already + # specifies its own bg (e.g. status-bar / completion-menu styles + # with `bg:#1a1a2e ...`). Those colors were tuned for that + # specific dark bg and remapping the FG to a dark equivalent + # would produce dark-on-dark (invisible). The terminal's BG + # mode is irrelevant — what matters is the bg the style itself + # paints. + try: + if _detect_light_mode(): + def _remap_value(v: str) -> str: + if not v: + return v + tokens = v.split() + has_explicit_bg = any(t.startswith("bg:") for t in tokens) + if has_explicit_bg: + # The style paints its own bg — leave its fg alone. + return v + return " ".join( + _maybe_remap_for_light_mode(t) if t.startswith("#") else t + for t in tokens + ) + style_dict = {k: _remap_value(v or "") for k, v in style_dict.items()} + except Exception: + pass return style_dict def _apply_tui_skin_style(self) -> bool: @@ -11264,6 +11555,13 @@ class HermesCLI: def run(self): """Run the interactive CLI loop with persistent input at bottom.""" + # Detect light/dark terminal mode now (before pt grabs the tty). + # Caches the result so subsequent _hex_to_ansi / style calls + # don't risk re-querying mid-render. + try: + _detect_light_mode() + except Exception: + pass # Push the entire TUI to the bottom of the terminal so the banner, # responses, and prompt all appear pinned to the bottom — empty # space stays above, not below. This prints enough blank lines to @@ -13027,11 +13325,16 @@ class HermesCLI: # Style for the application self._tui_style_base = { - 'input-area': '#FFF8DC', - 'placeholder': '#555555 italic', - 'prompt': '#FFF8DC', + # Input area / prompt: empty style strings inherit the + # terminal's default foreground/background, so the typed + # text is readable in both light and dark Terminal.app + # color schemes. (Hardcoding a near-white #FFF8DC made + # input invisible on light backgrounds.) + 'input-area': '', + 'placeholder': '#888888 italic', + 'prompt': '', 'prompt-working': '#888888 italic', - 'hint': '#555555 italic', + 'hint': '#888888 italic', 'status-bar': 'bg:#1a1a2e #C0C0C0', 'status-bar-strong': 'bg:#1a1a2e #FFD700 bold', 'status-bar-dim': 'bg:#1a1a2e #8B8682', @@ -13090,19 +13393,70 @@ class HermesCLI: self._app = app # Store reference for clarify_callback # ── Fix ghost status-bar lines on terminal resize ────────────── - # When the terminal shrinks (e.g. un-maximize), the emulator reflows - # the previously-rendered full-width rows (status bar, input rules) - # into multiple narrower rows. prompt_toolkit's _on_resize handler - # only cursor_up()s by the stored layout height, missing the extra - # rows created by reflow — leaving ghost duplicates visible. + # Resize handling: monkey-patch prompt_toolkit's _output_screen_diff + # to suppress the deliberate "reserve vertical space" scroll-up. # - # It's not just column-shrink: widening, row-shrinking, and - # multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch) - # all produce the same class of drift, where the renderer's tracked - # _cursor_pos.y no longer matches terminal reality. The only reliable - # recovery is a full screen-clear (\x1b[2J\x1b[H) before the next - # redraw, so we force one on every resize rather than trying to - # compute the exact drift. + # Background: prompt_toolkit's renderer (renderer.py L232-242) + # explicitly moves the cursor to the bottom of the canvas after + # painting "to make sure the terminal scrolls up, even when the + # lower lines of the canvas just contain whitespace". In + # non-fullscreen mode this scrolls chrome content (status bar, + # input rules) into terminal scrollback on every render. When + # the terminal column-shrinks, the emulator reflows the previously + # rendered full-width rows into multiple narrower rows that get + # pushed up — leaving ghost duplicates AND polluting scrollback. + # Same issue as pt #29 (open since 2014), #1675, #1933. + # + # Surgical fix: wrap _output_screen_diff so that when its internal + # `if current_height > previous_screen.height` branch fires (the + # one that does the bottom-cursor-move), we make it fall through + # by inflating previous_screen.height first. + try: + import prompt_toolkit.renderer as _pt_renderer + from prompt_toolkit.renderer import _output_screen_diff as _orig_osd + + if not getattr(_pt_renderer, "_hermes_osd_patched", False): + def _patched_output_screen_diff( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ): + """Wraps pt's _output_screen_diff to suppress the + reserve-vertical-space scroll (renderer.py L232-242). + + Strategy: ONLY when previous_screen is non-None and + its current height is genuinely smaller than the new + screen's height, inflate it to match. This prevents + the bottom-cursor-move at L242 without changing any + other code path's behavior. + + Critical: do NOT replace a None previous_screen with + a fresh Screen() — that would skip the proper + reset_attributes()+erase_down() at L178-185 which + fires when previous_screen is None (first-paint / + width-change). Without that reset, ANSI styles + leak between renders. + """ + try: + if previous_screen is not None and hasattr(previous_screen, "height"): + if previous_screen.height < screen.height: + previous_screen.height = screen.height + except Exception: + pass + + return _orig_osd( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ) + + _pt_renderer._output_screen_diff = _patched_output_screen_diff + _pt_renderer._hermes_osd_patched = True + except Exception: + pass + _original_on_resize = app._on_resize def _resize_clear_ghosts(): diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index f4d894c1e7a..0946eae9168 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -849,10 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: except Exception: return {} - prompt = skin.get_color("prompt", "#FFF8DC") + # Input/prompt: leave unset by default so the typed text inherits + # the terminal's foreground color (readable in both light and dark + # color schemes). Skins can opt into a colored prompt by setting + # `prompt` explicitly in their YAML. + prompt = skin.get_color("prompt", "") input_rule = skin.get_color("input_rule", "#CD7F32") title = skin.get_color("banner_title", "#FFD700") - text = skin.get_color("banner_text", prompt) + text = skin.get_color("banner_text", "#FFF8DC") dim = skin.get_color("banner_dim", "#555555") label = skin.get_color("ui_label", title) warn = skin.get_color("ui_warn", "#FF8C00") @@ -872,7 +876,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg) return { - "input-area": prompt, + # Typed input always uses terminal default fg/bg so it's + # readable in both light and dark Terminal.app modes. The + # skin's `prompt` color (if any) only styles the prompt symbol, + # NOT the user's typed text. + "input-area": "", "placeholder": f"{dim} italic", "prompt": prompt, "prompt-working": f"{dim} italic", From cbd1f8e4bea66af2b219304a7911020f32968177 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 22:41:33 -0700 Subject: [PATCH 162/214] test(cli): cover light-mode detection + SkinConfig.get_color remap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 16 unit tests covering the light/dark terminal detection path introduced in the previous commit: - Env override priority (HERMES_LIGHT, HERMES_TUI_LIGHT, HERMES_TUI_THEME, HERMES_TUI_BACKGROUND, COLORFGBG) - Detection cache stickiness - _maybe_remap_for_light_mode() no-op in dark mode - Known dark-mode color remap (#FFF8DC -> #1A1A1A etc) - Case-insensitive lookup - Unknown color passthrough - Status-bar paired colors (#C0C0C0, #888888, #555555, #8B8682) are intentionally NOT remapped — regression guard for the patch-11 fix, since remapping them would produce dark-on-dark on the status bar's navy bg - SkinConfig.get_color() wrapper is installed and idempotent - SkinConfig.get_color() does remap in light mode and passes through in dark mode We don't try to fake an OSC 11 reply — that path is exercised end-to-end in real Terminal.app; the env-override path covers the algorithmic logic. --- tests/cli/test_cli_light_mode.py | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/cli/test_cli_light_mode.py diff --git a/tests/cli/test_cli_light_mode.py b/tests/cli/test_cli_light_mode.py new file mode 100644 index 00000000000..bc5ca5128e0 --- /dev/null +++ b/tests/cli/test_cli_light_mode.py @@ -0,0 +1,154 @@ +"""Tests for the light-mode terminal detection + color remap in cli.py. + +Covers the env-override path and the SkinConfig.get_color() wrapper that +the resize / light-mode salvage installs at module import time. We don't +try to fake an OSC 11 reply — the env-override branch short-circuits +before the terminal query, which is the path most users hit. +""" + +from __future__ import annotations + +import importlib + +import pytest + + +@pytest.fixture +def cli_mod(monkeypatch): + """Import cli with the light-mode cache cleared each test.""" + import cli as _cli + + # The module-level _install_skin_light_mode_hook() and import-time + # _detect_light_mode() prime ran once at first import. We just reset + # the detection cache so the per-test env override takes effect. + monkeypatch.setattr(_cli, "_LIGHT_MODE_CACHE", None) + return _cli + + +class TestLightModeDetection: + def test_hermes_light_env_true_forces_light(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + assert cli_mod._detect_light_mode() is True + + def test_hermes_light_env_false_forces_dark(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "0") + # Also blank out other signals so nothing else flips it light. + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False) + monkeypatch.delenv("COLORFGBG", raising=False) + assert cli_mod._detect_light_mode() is False + + def test_theme_hint_light(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.setenv("HERMES_TUI_THEME", "light") + assert cli_mod._detect_light_mode() is True + + def test_background_hex_hint_light(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#FFFFFF") + assert cli_mod._detect_light_mode() is True + + def test_background_hex_hint_dark(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#1a1a2e") + monkeypatch.delenv("COLORFGBG", raising=False) + assert cli_mod._detect_light_mode() is False + + def test_colorfgbg_light_bg_slot(self, cli_mod, monkeypatch): + monkeypatch.delenv("HERMES_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False) + monkeypatch.delenv("HERMES_TUI_THEME", raising=False) + monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False) + monkeypatch.setenv("COLORFGBG", "0;15") # bg slot 15 = light + assert cli_mod._detect_light_mode() is True + + def test_cache_is_sticky(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + assert cli_mod._detect_light_mode() is True + # Even if the env flips, the cached result wins until reset. + monkeypatch.setenv("HERMES_LIGHT", "0") + assert cli_mod._detect_light_mode() is True + + +class TestLightModeRemap: + def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "0") + # Cache is None from the fixture; first call sticks at False. + assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#FFF8DC" + + def test_remap_known_dark_color(self, cli_mod, monkeypatch): + monkeypatch.setenv("HERMES_LIGHT", "1") + # Force the detect cache to True for this test. + cli_mod._LIGHT_MODE_CACHE = True + assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#1A1A1A" + assert cli_mod._maybe_remap_for_light_mode("#FFD700") == "#9A6B00" + + def test_remap_case_insensitive(self, cli_mod, monkeypatch): + cli_mod._LIGHT_MODE_CACHE = True + # Lowercase input should still remap. + assert cli_mod._maybe_remap_for_light_mode("#fff8dc") == "#1A1A1A" + + def test_remap_unknown_color_passthrough(self, cli_mod, monkeypatch): + cli_mod._LIGHT_MODE_CACHE = True + # A color not in the remap table is returned unchanged. + assert cli_mod._maybe_remap_for_light_mode("#ABCDEF") == "#ABCDEF" + + def test_remap_skips_statusbar_paired_colors(self, cli_mod, monkeypatch): + """Colors that live on a dark bg (status bar fg) MUST NOT be + remapped — otherwise they go dark-on-dark and disappear. + + Regression guard for the patch-11 fix (intentional table omission). + """ + cli_mod._LIGHT_MODE_CACHE = True + for fg in ("#C0C0C0", "#888888", "#555555", "#8B8682"): + assert cli_mod._maybe_remap_for_light_mode(fg) == fg, ( + f"{fg} is a status-bar fg paired with dark bg; remapping it " + "would produce dark-on-dark" + ) + + +class TestSkinConfigHook: + """The salvage wraps SkinConfig.get_color at module import time so + every skin color read goes through the light-mode remap. Verify + the hook installed and functions correctly. + """ + + def test_hook_installed(self, cli_mod): + from hermes_cli.skin_engine import SkinConfig + + assert getattr(SkinConfig, "_hermes_light_mode_hook_installed", False) is True + + def test_hook_is_idempotent(self, cli_mod): + # Calling the installer twice must not double-wrap (the marker + # attribute is the guard). + from hermes_cli.skin_engine import SkinConfig + + before = SkinConfig.get_color + cli_mod._install_skin_light_mode_hook() + after = SkinConfig.get_color + assert before is after + + def test_skin_color_remaps_through_wrapper_in_light_mode(self, cli_mod, monkeypatch): + from hermes_cli.skin_engine import SkinConfig + + cli_mod._LIGHT_MODE_CACHE = True + skin = SkinConfig( + name="test", + colors={"banner_text": "#FFF8DC", "response_border": "#FFD700"}, + ) + # The wrapper kicks in at get_color, not at construction time. + assert skin.get_color("banner_text") == "#1A1A1A" + assert skin.get_color("response_border") == "#9A6B00" + + def test_skin_color_passthrough_in_dark_mode(self, cli_mod, monkeypatch): + from hermes_cli.skin_engine import SkinConfig + + cli_mod._LIGHT_MODE_CACHE = False + skin = SkinConfig(name="test", colors={"banner_text": "#FFF8DC"}) + assert skin.get_color("banner_text") == "#FFF8DC" From 965ae7fa97e62e0f318eaf9a132f083e87cadf59 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 23:30:16 -0700 Subject: [PATCH 163/214] revert(cli): drop scrollback box width clamp (#25975), restore full-width borders (#26163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #25975 (salvaging #24403) clamped decorative scrollback Panels and streaming box rules to `max(32, min(width, 56))` as a defense against terminal-emulator reflow when columns shrink. On any modern wide terminal this made the response/reasoning borders look stubby — 56 cols inside a 200-col viewport. #26137 (salvaging #25981, by @OutThisLife) landed a more fundamental fix: prompt_toolkit's `_output_screen_diff` is monkey-patched so its reserve-vertical-space cursor move no longer pushes chrome into scrollback at all. With that in place, the clamp is no longer load-bearing for the chrome-into-scrollback class of bugs — the remaining risk is purely cosmetic reflow of *already stamped* Panel borders during an aggressive column shrink, which we now accept as a tradeoff for restoring proper full-width rendering. Changes: - `_scrollback_box_width()` returns `max(32, width)` (just the floor, no upper cap). All 10 call sites stay valid. - Updated `test_scrollback_box_width_caps_to_resize_safe_value` to the new `test_scrollback_box_width_returns_viewport_width` asserting full-width passthrough above the 32-col floor. Floor of 32 is kept so `'─' * (w - 2)` math stays positive on tiny terminals. Refs #18449 #19280 #22976 (the original reflow class) and #25975 (the clamp this reverts). --- cli.py | 24 +++++++++++++----------- tests/cli/test_cli_status_bar.py | 25 ++++++++++++++++--------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index dbbcf877006..527269aef7a 100644 --- a/cli.py +++ b/cli.py @@ -3235,25 +3235,27 @@ class HermesCLI: @staticmethod def _scrollback_box_width(width: Optional[int] = None) -> int: - """Return a resize-safe width for printed scrollback box rules. + """Return the full viewport 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). + Previously this clamped to ``max(32, min(width, 56))`` as a defense + against terminal-emulator reflow on column-shrink (#25975, salvaging + #24403). That clamp made response/reasoning borders look stubby on + any modern wide terminal. We now trust the prompt_toolkit + ``_output_screen_diff`` monkey-patch landed in #26137 (salvaging + #25981) to keep chrome out of scrollback in the first place, and + accept that an aggressive column-shrink may visually reflow already + printed Panel borders — that's a cosmetic artifact of stamped + scrollback history, not a live-render bug. - 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. + A small floor (32 cols) is kept so the box still renders on tiny + terminals without negative ``'─' * (w - 2)`` math. """ 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)) + return max(32, int(width or 80)) def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: """Return the visible height for the top/bottom input separator rules.""" diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 445626fac9b..47bd68aa25d 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -349,20 +349,27 @@ class TestCLIStatusBar: 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.""" + def test_scrollback_box_width_returns_viewport_width(self): + """Decorative scrollback boxes use the full viewport width. + + The previous clamp (max 56 cols) was reverted in favour of the + prompt_toolkit ``_output_screen_diff`` monkey-patch landed in + #26137, which keeps chrome out of scrollback at the source. + We accept that an aggressive column-shrink may visually reflow + already printed Panel borders — that's a cosmetic artifact of + stamped scrollback history, not a live-render bug. + """ from cli import HermesCLI - # Floor at 32 — narrow terminals still get something usable. + # Floor at 32 — narrow terminals still get something usable + # (avoids negative ``'─' * (w - 2)`` math). 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. + # Above the floor, return the actual viewport width — no cap. assert HermesCLI._scrollback_box_width(48) == 48 + assert HermesCLI._scrollback_box_width(80) == 80 + assert HermesCLI._scrollback_box_width(120) == 120 + assert HermesCLI._scrollback_box_width(200) == 200 def test_agent_spacer_reclaimed_on_narrow_terminals(self): cli_obj = _make_cli() From f9ad7400e30517159712a77e6a4bc2f3a390b2db Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 14 May 2026 23:43:13 -0700 Subject: [PATCH 164/214] =?UTF-8?q?fix(goals):=20raise=20judge=20max=5Ftok?= =?UTF-8?q?ens=20200=20=E2=86=92=204096,=20make=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freeform /goal judge was capped at max_tokens=200, which reliably truncated the JSON verdict on reasoning-heavy models (deepseek-v4-pro, qwq, etc.) — the model burns tokens on hidden reasoning before emitting visible content, and the first /goal turn's prompt is larger than later turns, blowing past 200. Symptom: agent.log shows `judge reply was not JSON: '{"done": true, "reason": "The agent successfully'` followed by repeated `judge returned empty response` lines, then the goal pauses with a misleading 'judge model isn't returning the required JSON verdict' message. Diagnosed live by @helix4u — empirically verified that raising the budget on an unmodified worktree makes the failures go away on the exact configs users were hitting on Nous Plus subscription paths. Changes: - DEFAULT_JUDGE_MAX_TOKENS = 4096 (up from 200) - New auxiliary.goal_judge.max_tokens config knob for tuning in specifically constrained setups - _goal_judge_max_tokens() resolves the value with fail-open semantics (non-int / non-positive / load failure → default). load_config() is mtime-cached so per-turn lookup is cheap. Scoped narrowly to the verified root cause — does not introduce a submit_verdict tool-call schema (see #26162 / #23671 for that direction; they can land separately if we want them). Tests: tests/hermes_cli/test_goals.py + tests/cli/test_cli_goal_interrupt.py + tests/gateway/test_goal_verdict_send.py — 62/62 passing. E2E verified: config override honored (8192), missing/garbage/zero values fall back to 4096, no-auxiliary-section falls back to 4096. Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com> Credits: - @helix4u (Gille) — diagnosed the max_tokens=200 truncation via live testing on an unmodified worktree, drafted the original fix shape in #26162. - @AhmetArif0 — flagged the freeform judge fragility in #23671 from the tool-call angle. - @0xharryriddle (HarryRiddle.eth) — reported the issue from a Nous Plus subscription setup in #23876 with full debug reports. Closes #23876 Supersedes #26162, #23671, #23881 --- hermes_cli/goals.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 1542b9a7a38..62ee00547c1 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -45,6 +45,16 @@ logger = logging.getLogger(__name__) DEFAULT_MAX_TURNS = 20 DEFAULT_JUDGE_TIMEOUT = 30.0 +# Judge output budget. The freeform judge returns a one-line JSON verdict, but +# reasoning models (deepseek-v4, qwq, etc.) burn tokens on hidden reasoning +# before emitting the visible JSON — and the first /goal turn's prompt is +# larger than later turns, which pushes total reply length past tight caps. +# 200 tokens (the original default) reliably truncated the JSON on reasoning +# models, leaving '{"done": true, "reason": "The agent successfully' and +# triggering the auto-pause. 4096 covers reasoning + verdict on every model +# we've live-tested; override via auxiliary.goal_judge.max_tokens for +# specifically constrained setups. +DEFAULT_JUDGE_MAX_TOKENS = 4096 # Cap how much of the last response + recent messages we send to the judge. _JUDGE_RESPONSE_SNIPPET_CHARS = 4000 # After this many consecutive judge *parse* failures (empty output / non-JSON), @@ -282,6 +292,30 @@ def _truncate(text: str, limit: int) -> str: _JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL) +def _goal_judge_max_tokens() -> int: + """Resolve auxiliary.goal_judge.max_tokens, falling back to the default. + + ``load_config()`` is cached on the config file's (mtime, size), so calling + this once per judge turn is cheap. A non-positive or non-int value falls + back to the default rather than crashing the goal loop. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() + value = ( + (cfg.get("auxiliary") or {}) + .get("goal_judge", {}) + .get("max_tokens", DEFAULT_JUDGE_MAX_TOKENS) + ) + value = int(value) + if value > 0: + return value + except Exception: + pass + return DEFAULT_JUDGE_MAX_TOKENS + + def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: """Parse the judge's reply. Fail-open to ``(False, "", parse_failed)``. @@ -404,7 +438,7 @@ def judge_goal( {"role": "user", "content": prompt}, ], temperature=0, - max_tokens=200, + max_tokens=_goal_judge_max_tokens(), timeout=timeout, extra_body=get_auxiliary_extra_body() or None, ) From 6bdad1f3b2e31d38673146da362ca5dd4ddbb456 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 15 May 2026 13:21:48 +0530 Subject: [PATCH 165/214] ci: add PyPI publish workflow (salvaged from #25901) (#26148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(pypi): add publish workflow for automated PyPI releases Triggered by CalVer tag pushes from scripts/release.py (v20* pattern). Three jobs: build (uv build) → publish (OIDC trusted publishing) → sign (Sigstore + attach to existing GitHub Release). - workflow_dispatch as manual escape hatch - skip-existing for safe re-runs - Graceful skip when GitHub Release not found (sign job) - Top-level permissions: contents: read (CodeQL compliant) Requires one-time setup: PyPI trusted publisher + GitHub pypi environment. Co-authored-by: dmahan93 <44207705+dmahan93@users.noreply.github.com> * fix(release): address review findings - Stage acp_registry/agent.json in version bump commit (was silently left unstaged) - Add missing return when no previous tags found without --first-release - Fix get_pr_number return type annotation (str -> str | None) - Prefer uv build over python -m build (matches CI workflow), with fallback - Use unit separator (%x1f) in git log format to handle | in author names - Add explicit encoding='utf-8' to .release_notes.md write Workflow hardening: - Gracefully skip signing when GitHub Release not found (env var gate instead of exit 1, so PyPI publish still shows green) * fix(ci): harden PyPI workflow — SHA-pin actions, guard workflow_dispatch, explicit build flags - Pin all actions to commit SHAs (supply-chain hardening for id-token:write) - workflow_dispatch now requires confirm_tag input + checks out that tag - Both uv build paths explicitly pass --sdist --wheel --------- Co-authored-by: dmahan93 <44207705+dmahan93@users.noreply.github.com> --- .github/workflows/upload_to_pypi.yml | 137 +++++++++++++++++++++++++++ scripts/release.py | 36 ++++--- 2 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/upload_to_pypi.yml diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml new file mode 100644 index 00000000000..4e2fe4748d3 --- /dev/null +++ b/.github/workflows/upload_to_pypi.yml @@ -0,0 +1,137 @@ +name: Publish to PyPI + +# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15) +# Can also be triggered manually from the Actions tab as an escape hatch. +on: + push: + tags: + - 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc. + workflow_dispatch: + inputs: + confirm_tag: + description: 'Tag to publish (e.g. v2026.5.15). Must already exist.' + required: true + type: string + +# Restrict default token to read-only; each job escalates as needed. +permissions: + contents: read + +# Prevent overlapping publishes (e.g. two same-day tags pushed quickly). +concurrency: + group: pypi-publish + cancel-in-progress: false + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + # On workflow_dispatch, check out the confirmed tag. + ref: ${{ inputs.confirm_tag || github.ref }} + fetch-tags: true + + - name: Validate tag exists + if: github.event_name == 'workflow_dispatch' + run: | + if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then + echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo" + exit 1 + fi + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + + - name: Build wheel and sdist + run: uv build --sdist --wheel + + - name: Upload distribution artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-package-distributions + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/hermes-agent + permissions: + id-token: write # OIDC trusted publishing + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + skip-existing: true + + sign: + name: Sign and attach to GitHub Release + # Only runs on tag pushes — release.py creates the GitHub Release, + # and workflow_dispatch won't have a matching release to attach to. + if: startsWith(github.ref, 'refs/tags/') + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # attach assets to the existing release + id-token: write # sigstore signing + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-package-distributions + path: dist/ + + - name: Wait for GitHub Release to exist + env: + GITHUB_TOKEN: ${{ github.token }} + # release.py creates the GitHub Release after pushing the tag, + # but this workflow starts from the tag push — wait for it. + run: | + for i in $(seq 1 30); do + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $GITHUB_REF_NAME found" + exit 0 + fi + echo "Waiting for release... ($i/30)" + sleep 10 + done + echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload" + echo "skip_sign=true" >> "$GITHUB_ENV" + + - name: Sign with Sigstore + if: env.skip_sign != 'true' + uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + + - name: Attach signed artifacts to GitHub Release + if: env.skip_sign != 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + # release.py already created the GitHub Release — just upload + # the Sigstore signatures alongside the existing assets. + run: >- + gh release upload + "$GITHUB_REF_NAME" dist/*.sigstore.json + --repo "$GITHUB_REPOSITORY" + --clobber diff --git a/scripts/release.py b/scripts/release.py index d3118bc128e..53db4bbec2c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1188,15 +1188,21 @@ def _update_acp_registry_versions(semver: str) -> None: def build_release_artifacts(semver: str) -> list[Path]: """Build sdist/wheel artifacts for the current release. - Returns the artifact paths when the local environment has ``python -m build`` - available. If build tooling is missing or the build fails, returns an empty - list and lets the release proceed without attached Python artifacts. + Tries ``uv build`` first (matching the CI workflow), falls back to + ``python -m build`` if uv is unavailable. """ dist_dir = REPO_ROOT / "dist" shutil.rmtree(dist_dir, ignore_errors=True) + # Prefer uv build (matches CI workflow), fall back to python -m build. + uv_bin = shutil.which("uv") + if uv_bin: + cmd = [uv_bin, "build", "--sdist", "--wheel"] + else: + cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"] + result = subprocess.run( - [sys.executable, "-m", "build", "--sdist", "--wheel"], + cmd, cwd=str(REPO_ROOT), capture_output=True, text=True, @@ -1209,7 +1215,7 @@ def build_release_artifacts(semver: str) -> list[Path]: print(f" {stderr.splitlines()[-1]}") elif stdout: print(f" {stdout.splitlines()[-1]}") - print(" Install the 'build' package to attach semver-named sdist/wheel assets.") + print(" Install uv or the 'build' package to attach sdist/wheel assets.") return [] artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file()) @@ -1316,11 +1322,11 @@ def get_commits(since_tag=None): else: range_spec = "HEAD" - # Format: hash|author_name|author_email|subject\0body - # Using %x00 (null) as separator between subject and body + # Format: hashauthor_nameauthor_emailsubject\0body + # Using %x1f (unit separator) to avoid conflict with | in author names log = git( "log", range_spec, - "--format=%H|%an|%ae|%s%x00%b%x00", + "--format=%H%x1f%an%x1f%ae%x1f%s%x00%b%x00", "--no-merges", ) @@ -1334,14 +1340,14 @@ def get_commits(since_tag=None): entry = entry.strip() if not entry: continue - # Split on first null to separate "hash|name|email|subject" from "body" + # Split on first null to separate "hashnameemailsubject" from "body" if "\0" in entry: header, body = entry.split("\0", 1) body = body.strip() else: header = entry body = "" - parts = header.split("|", 3) + parts = header.split("\x1f", 3) if len(parts) != 4: continue sha, name, email, subject = parts @@ -1361,7 +1367,7 @@ def get_commits(since_tag=None): return commits -def get_pr_number(subject: str) -> str: +def get_pr_number(subject: str) -> str | None: """Extract PR number from commit subject if present.""" match = re.search(r"#(\d+)", subject) if match: @@ -1512,6 +1518,7 @@ def main(): print("No previous tags found. Use --first-release for the initial release.") print(f"Would create tag: {tag_name}") print(f"Would set version: {new_version}") + return # Get commits commits = get_commits(since_tag=prev_tag) @@ -1556,7 +1563,10 @@ def main(): print(f" ✓ Updated version files to v{new_version} ({calver_date})") # Commit version bump - add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE)) + add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)] + if ACP_REGISTRY_MANIFEST.exists(): + add_files.append(str(ACP_REGISTRY_MANIFEST)) + add_result = git_result("add", *add_files) if add_result.returncode != 0: print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}") return @@ -1598,7 +1608,7 @@ def main(): # Create GitHub release changelog_file = REPO_ROOT / ".release_notes.md" - changelog_file.write_text(changelog) + changelog_file.write_text(changelog, encoding="utf-8") gh_cmd = [ "gh", "release", "create", tag_name, From d57a4b3eb51e5c445923d33a5c3da9266e62790b Mon Sep 17 00:00:00 2001 From: libo1106 Date: Sun, 10 May 2026 00:17:13 +0800 Subject: [PATCH 166/214] feat(yuanbao): add _parse_resource_id and update _extract_text for ybres anchors --- gateway/platforms/yuanbao.py | 48 +++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index d79da7856ae..68184b6cd29 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -1645,6 +1645,25 @@ class ExtractContentMiddleware(InboundMiddleware): return None return f"[link: {link} | visit link for full content]" + @staticmethod + def _parse_resource_id(url: str) -> str: + """Extract resourceId from Yuanbao resource URL query parameters. + + Args: + url: Resource URL (e.g., https://...?resourceId=abc123) + + Returns: + Resource ID string, or empty string if not found + """ + if not url: + return "" + try: + query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + ids = query.get("resourceId") or query.get("resourceid") or [] + return str(ids[0]).strip() if ids else "" + except Exception: + return "" + @classmethod def _extract_text(cls, msg_body: list) -> str: """Extract plain text content from MsgBody. @@ -1668,14 +1687,35 @@ class ExtractContentMiddleware(InboundMiddleware): if text: parts.append(text) elif elem_type == "TIMImageElem": - parts.append("[image]") + # Extract resourceId from image_info_array URL + image_info_array = content.get("image_info_array") + if not isinstance(image_info_array, list): + image_info_array = [] + image_info = None + # Prefer medium image (index 1), fallback to index 0 + if len(image_info_array) > 1 and isinstance(image_info_array[1], dict): + image_info = image_info_array[1] + elif len(image_info_array) > 0 and isinstance(image_info_array[0], dict): + image_info = image_info_array[0] + image_url = str((image_info or {}).get("url") or "").strip() + rid = cls._parse_resource_id(image_url) + parts.append(f"[image|ybres:{rid}]" if rid else "[image]") elif elem_type == "TIMFileElem": filename = content.get("file_name", content.get("fileName", content.get("filename", ""))) - parts.append(f"[file: {filename}]" if filename else "[file]") + file_url = str(content.get("url") or "").strip() + rid = cls._parse_resource_id(file_url) + if rid: + parts.append(f"[file:{filename}|ybres:{rid}]" if filename else f"[file|ybres:{rid}]") + else: + parts.append(f"[file: {filename}]" if filename else "[file]") elif elem_type == "TIMSoundElem": - parts.append("[voice]") + sound_url = str(content.get("url") or "").strip() + rid = cls._parse_resource_id(sound_url) + parts.append(f"[voice|ybres:{rid}]" if rid else "[voice]") elif elem_type == "TIMVideoFileElem": - parts.append("[video]") + video_url = str(content.get("url") or "").strip() + rid = cls._parse_resource_id(video_url) + parts.append(f"[video|ybres:{rid}]" if rid else "[video]") elif elem_type == "TIMCustomElem": data_val = content.get("data", "") if data_val: From 80efe664ce5d822b31ca6c76162c6e1f7500796a Mon Sep 17 00:00:00 2001 From: libo1106 Date: Sun, 10 May 2026 00:17:13 +0800 Subject: [PATCH 167/214] feat(yuanbao): add quote_media_refs extraction to QuoteContextMiddleware --- gateway/platforms/yuanbao.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 68184b6cd29..be296558177 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -925,6 +925,7 @@ class InboundContext: # Populated by QuoteContextMiddleware reply_to_message_id: Optional[str] = None reply_to_text: Optional[str] = None + quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename) # Populated by MediaResolveMiddleware media_urls: list = dc_field(default_factory=list) @@ -2172,22 +2173,23 @@ class QuoteContextMiddleware(InboundMiddleware): name = "quote-context" @staticmethod - def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]: + def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]: """Extract quote context, mapping to MessageEvent.reply_to_*. Returns: - (reply_to_message_id, reply_to_text) + (reply_to_message_id, reply_to_text, quote_media_refs) + where quote_media_refs is a list of (rid, kind, filename) tuples """ if not cloud_custom_data: - return None, None + return None, None, [] try: parsed = json.loads(cloud_custom_data) except (json.JSONDecodeError, TypeError): - return None, None + return None, None, [] quote = parsed.get("quote") if isinstance(parsed, dict) else None if not isinstance(quote, dict): - return None, None + return None, None, [] # type=2 corresponds to image reference; desc may be empty, provide a placeholder. quote_type = int(quote.get("type") or 0) @@ -2195,15 +2197,25 @@ class QuoteContextMiddleware(InboundMiddleware): if quote_type == 2 and not desc: desc = "[image]" if not desc: - return None, None + return None, None, [] quote_id = str(quote.get("id") or "").strip() or None sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip() quote_text = f"{sender}: {desc}" if sender else desc - return quote_id, quote_text + + # Extract media references from desc using _YB_RES_REF_RE regex + media_refs: list = [] + for m in _YB_RES_REF_RE.finditer(desc): + head = m.group(1) # "image" | "file:" | "voice" | "video" + rid = m.group(2) + kind, _, filename = head.partition(":") + kind = kind.strip() + media_refs.append((rid, kind, filename.strip())) + + return quote_id, quote_text, media_refs async def handle(self, ctx: InboundContext, next_fn) -> None: - ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data) + ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data) await next_fn() From 3df26b925cae7761763e43f03978600d175417c5 Mon Sep 17 00:00:00 2001 From: libo1106 Date: Sun, 10 May 2026 00:17:13 +0800 Subject: [PATCH 168/214] feat(yuanbao): prioritize quote media refs over history backfill in DispatchMiddleware --- gateway/platforms/yuanbao.py | 69 ++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index be296558177..5696e2667d1 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2510,26 +2510,57 @@ class DispatchMiddleware(InboundMiddleware): media_urls = list(ctx.media_urls) media_types = list(ctx.media_types) - # Backfill observed media from recent transcript history - extra_img_urls: List[str] = [] - extra_img_mimes: List[str] = [] - try: - extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media( - adapter, ctx.source, - ) - except Exception as exc: - logger.warning( - "[%s] observed-image hydration raised, continuing anyway: %s", - adapter.name, exc, - ) - if extra_img_urls: - current = set(media_urls) - for u, m in zip(extra_img_urls, extra_img_mimes): - if u in current: + # If user quoted a message (reply_to_message_id is set), resolve only + # quote_media_refs to avoid injecting unrelated history media. + # Otherwise, backfill observed media from recent transcript history. + if ctx.reply_to_message_id is not None: + # User quoted a message — resolve only media from the quote + for rid, kind, filename in ctx.quote_media_refs: + if kind not in ("image", "file"): continue - media_urls.append(u) - media_types.append(m) - current.add(u) + try: + fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid) + except Exception as exc: + logger.warning( + "[%s] quote media resolve failed: rid=%s kind=%s err=%s", + adapter.name, rid, kind, exc, + ) + continue + cached = await MediaResolveMiddleware._download_and_cache( + adapter, + fetch_url=fresh_url, + kind=kind, + file_name=filename or None, + log_tag=f"quote rid={rid}", + ) + if cached is None: + continue + path, mime = cached + # Avoid duplicates + if path not in media_urls: + media_urls.append(path) + media_types.append(mime) + else: + # No quote — backfill observed media from recent transcript history + extra_img_urls: List[str] = [] + extra_img_mimes: List[str] = [] + try: + extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media( + adapter, ctx.source, + ) + except Exception as exc: + logger.warning( + "[%s] observed-image hydration raised, continuing anyway: %s", + adapter.name, exc, + ) + if extra_img_urls: + current = set(media_urls) + for u, m in zip(extra_img_urls, extra_img_mimes): + if u in current: + continue + media_urls.append(u) + media_types.append(m) + current.add(u) # Replace [kind|ybres:xxx] anchors with local cache paths so # the transcript records usable paths for the model. From fc2754dbdff860cdeb8fe4ed5fe0464bb6295cbb Mon Sep 17 00:00:00 2001 From: libo1106 Date: Sun, 10 May 2026 01:05:23 +0800 Subject: [PATCH 169/214] fix(yuanbao): resolve quoted file/image via transcript lookup when quote desc lacks ybres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user quotes a file message (type=3) and @bot, the quote's desc field only contains the filename without a ybres:// resource reference. The existing QuoteContextMiddleware only extracted media refs from desc using the ybres regex, which always returned empty for file quotes. Fix: add a transcript lookup fallback in QuoteContextMiddleware.handle() — when quote_media_refs is empty but reply_to_message_id is set, search the session transcript for the quoted message_id and extract ybres anchors from its content. Also fix message_type classification: when quote media resolves non-image files, override message_type to DOCUMENT so gateway/run.py's document injection logic properly prepends the file path and content for the agent. --- gateway/platforms/yuanbao.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 5696e2667d1..6c6981c0c2b 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2216,6 +2216,34 @@ class QuoteContextMiddleware(InboundMiddleware): async def handle(self, ctx: InboundContext, next_fn) -> None: ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data) + + # Fallback: if quote has a message_id but no media_refs extracted from desc, + # look up the quoted message in transcript history by message_id to find ybres anchors. + if ctx.reply_to_message_id and not ctx.quote_media_refs: + store = getattr(ctx.adapter, "_session_store", None) + if store: + try: + session_entry = store.get_or_create_session(ctx.source) + history = store.load_transcript(session_entry.session_id) + for msg in (history or []): + mid = msg.get("message_id", "") + if mid and mid == ctx.reply_to_message_id: + content = msg.get("content", "") + if isinstance(content, str) and "|ybres:" in content: + for m in _YB_RES_REF_RE.finditer(content): + head = m.group(1) + rid = m.group(2) + kind, _, filename = head.partition(":") + kind = kind.strip() + if kind in ("image", "file"): + ctx.quote_media_refs.append((rid, kind, filename.strip())) + break + except Exception as exc: + logger.warning( + "[%s] QuoteContext transcript lookup failed: %s", + ctx.adapter.name, exc, + ) + await next_fn() @@ -2589,7 +2617,11 @@ class DispatchMiddleware(InboundMiddleware): event = MessageEvent( text=_patched_event_text, - message_type=ctx.msg_type, + message_type=( + MessageType.DOCUMENT + if any(not mt.startswith("image/") for mt in media_types) + else ctx.msg_type + ), source=ctx.source, message_id=ctx.msg_id or None, raw_message=ctx.push, From 0086cdaf93b2a85abe787fc9b130e45c0b8b8388 Mon Sep 17 00:00:00 2001 From: libo1106 Date: Sun, 10 May 2026 01:47:36 +0800 Subject: [PATCH 170/214] =?UTF-8?q?refactor(yuanbao):=20improve=20quote=20?= =?UTF-8?q?media=20fallback=20=E2=80=94=20move=20to=20DispatchMiddleware,?= =?UTF-8?q?=20tighten=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/yuanbao.py | 63 ++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 6c6981c0c2b..7015e0c848c 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -147,6 +147,9 @@ _YB_RES_REF_RE = re.compile( r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]" ) +# Media kinds that can be resolved and injected into the model context +_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"}) + # Strip page indicators like (1/3) appended by BasePlatformAdapter _INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$') @@ -2217,33 +2220,6 @@ class QuoteContextMiddleware(InboundMiddleware): async def handle(self, ctx: InboundContext, next_fn) -> None: ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data) - # Fallback: if quote has a message_id but no media_refs extracted from desc, - # look up the quoted message in transcript history by message_id to find ybres anchors. - if ctx.reply_to_message_id and not ctx.quote_media_refs: - store = getattr(ctx.adapter, "_session_store", None) - if store: - try: - session_entry = store.get_or_create_session(ctx.source) - history = store.load_transcript(session_entry.session_id) - for msg in (history or []): - mid = msg.get("message_id", "") - if mid and mid == ctx.reply_to_message_id: - content = msg.get("content", "") - if isinstance(content, str) and "|ybres:" in content: - for m in _YB_RES_REF_RE.finditer(content): - head = m.group(1) - rid = m.group(2) - kind, _, filename = head.partition(":") - kind = kind.strip() - if kind in ("image", "file"): - ctx.quote_media_refs.append((rid, kind, filename.strip())) - break - except Exception as exc: - logger.warning( - "[%s] QuoteContext transcript lookup failed: %s", - ctx.adapter.name, exc, - ) - await next_fn() @@ -2412,7 +2388,7 @@ class MediaResolveMiddleware(InboundMiddleware): for ref in media_refs: kind = str(ref.get("kind") or "").strip().lower() url = str(ref.get("url") or "").strip() - if kind not in {"image", "file"} or not url: + if kind not in _RESOLVABLE_MEDIA_KINDS or not url: continue try: @@ -2471,7 +2447,7 @@ class MediaResolveMiddleware(InboundMiddleware): rid = m.group(2) kind, _, filename = head.partition(":") kind = kind.strip() - if kind not in {"image", "file"}: + if kind not in _RESOLVABLE_MEDIA_KINDS: continue if rid in seen: continue @@ -2542,9 +2518,34 @@ class DispatchMiddleware(InboundMiddleware): # quote_media_refs to avoid injecting unrelated history media. # Otherwise, backfill observed media from recent transcript history. if ctx.reply_to_message_id is not None: + # Fallback: if desc didn't contain ybres refs, look up transcript + if not ctx.quote_media_refs: + try: + store = getattr(adapter, "_session_store", None) + if store: + session_entry = store.get_or_create_session(ctx.source) + history = store.load_transcript(session_entry.session_id) + for msg in reversed(history or []): + mid = msg.get("message_id", "") + if mid and mid == ctx.reply_to_message_id: + _content = msg.get("content", "") + if isinstance(_content, str) and "|ybres:" in _content: + for m in _YB_RES_REF_RE.finditer(_content): + head = m.group(1) + rid = m.group(2) + kind, _, filename = head.partition(":") + kind = kind.strip() + if kind in _RESOLVABLE_MEDIA_KINDS: + ctx.quote_media_refs.append((rid, kind, filename.strip())) + break + except Exception as exc: + logger.warning( + "[%s] quote transcript lookup failed: %s", + adapter.name, exc, + ) # User quoted a message — resolve only media from the quote for rid, kind, filename in ctx.quote_media_refs: - if kind not in ("image", "file"): + if kind not in _RESOLVABLE_MEDIA_KINDS: continue try: fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid) @@ -2619,7 +2620,7 @@ class DispatchMiddleware(InboundMiddleware): text=_patched_event_text, message_type=( MessageType.DOCUMENT - if any(not mt.startswith("image/") for mt in media_types) + if any(mt.startswith(("application/", "text/")) for mt in media_types) else ctx.msg_type ), source=ctx.source, From e0e4856d466491ee8a31378c606e65ddfe061ab9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 15 May 2026 01:20:24 -0700 Subject: [PATCH 171/214] feat(skills-hub): add huggingface/skills as trusted default tap (#2549) Adds Hugging Face's official skill catalog to the default GitHub taps and classifies it as a trusted source alongside openai/skills and anthropics/skills. - tools/skills_guard.py: huggingface/skills -> TRUSTED_REPOS - tools/skills_hub.py: GitHubSource.DEFAULT_TAPS += huggingface/skills (skills/) - website/docs: list it under default taps + trusted-source examples Closes #2549. Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- tools/skills_guard.py | 2 +- tools/skills_hub.py | 1 + website/docs/developer-guide/creating-skills.md | 2 +- website/docs/user-guide/features/skills.md | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 363e983da1a..1610c3225cb 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -36,7 +36,7 @@ from typing import List, Tuple # Hardcoded trust configuration # --------------------------------------------------------------------------- -TRUSTED_REPOS = {"openai/skills", "anthropics/skills"} +TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"} INSTALL_POLICY = { # safe caution dangerous diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 3e2c27c338a..35cec56e08e 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -329,6 +329,7 @@ class GitHubSource(SkillSource): DEFAULT_TAPS = [ {"repo": "openai/skills", "path": "skills/"}, {"repo": "anthropics/skills", "path": "skills/"}, + {"repo": "huggingface/skills", "path": "skills/"}, {"repo": "VoltAgent/awesome-agent-skills", "path": "skills/"}, {"repo": "garrytan/gstack", "path": ""}, {"repo": "MiniMax-AI/cli", "path": "skill/"}, diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index 43f088a9a35..7496c661d48 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -360,7 +360,7 @@ All hub-installed skills go through a security scanner that checks for: Trust levels: - `builtin` — ships with Hermes (always trusted) - `official` — from `optional-skills/` in the repo (builtin trust, no third-party warning) -- `trusted` — from openai/skills, anthropics/skills +- `trusted` — from openai/skills, anthropics/skills, huggingface/skills - `community` — non-dangerous findings can be overridden with `--force`; `dangerous` verdicts remain blocked Hermes can now consume third-party skills from multiple external discovery models: diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 9499e15d806..9959bcce112 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -351,6 +351,7 @@ Hermes can install directly from GitHub repositories and GitHub-based taps. This Default taps (browsable without any setup): - [openai/skills](https://github.com/openai/skills) - [anthropics/skills](https://github.com/anthropics/skills) +- [huggingface/skills](https://github.com/huggingface/skills) - [VoltAgent/awesome-agent-skills](https://github.com/VoltAgent/awesome-agent-skills) - [garrytan/gstack](https://github.com/garrytan/gstack) @@ -445,7 +446,7 @@ Important behavior: |-------|--------|--------| | `builtin` | Ships with Hermes | Always trusted | | `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning | -| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills` | More permissive policy than community sources | +| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills` | More permissive policy than community sources | | `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked | ### Update lifecycle From e0e7397c32fa06e4c93ce07bc276ea5c1dca7a84 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Sun, 22 Mar 2026 23:54:02 +0300 Subject: [PATCH 172/214] fix(session): persist auto-reset state across gateway restarts was_auto_reset, auto_reset_reason, and reset_had_activity were not included in SessionEntry.to_dict() / from_dict(), so a gateway restart between session expiry and the user's next message would silently drop the auto-reset notification and context note. Add the three fields to the serialization roundtrip with safe defaults (False / None / False) so existing sessions.json files load cleanly. Add three roundtrip tests to test_session_reset_notify.py. --- gateway/session.py | 6 ++ tests/gateway/test_session_reset_notify.py | 75 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/gateway/session.py b/gateway/session.py index ac6f95eec63..dfa2ca9651d 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -518,6 +518,9 @@ class SessionEntry: else None ), "is_fresh_reset": self.is_fresh_reset, + "was_auto_reset": self.was_auto_reset, + "auto_reset_reason": self.auto_reset_reason, + "reset_had_activity": self.reset_had_activity, } if self.origin: result["origin"] = self.origin.to_dict() @@ -567,6 +570,9 @@ class SessionEntry: resume_reason=data.get("resume_reason"), last_resume_marked_at=last_resume_marked_at, is_fresh_reset=data.get("is_fresh_reset", False), + was_auto_reset=data.get("was_auto_reset", False), + auto_reset_reason=data.get("auto_reset_reason"), + reset_had_activity=data.get("reset_had_activity", False), ) diff --git a/tests/gateway/test_session_reset_notify.py b/tests/gateway/test_session_reset_notify.py index 87903921fbd..a4e9d71d0f8 100644 --- a/tests/gateway/test_session_reset_notify.py +++ b/tests/gateway/test_session_reset_notify.py @@ -205,3 +205,78 @@ class TestResetPolicyNotify: assert restored.notify == original.notify assert restored.notify_exclude_platforms == original.notify_exclude_platforms assert restored.mode == original.mode + + +# --------------------------------------------------------------------------- +# SessionEntry to_dict / from_dict roundtrip for auto-reset fields +# --------------------------------------------------------------------------- + +class TestSessionEntryAutoResetRoundtrip: + def test_was_auto_reset_persists_across_roundtrip(self, tmp_path): + """was_auto_reset=True survives to_dict() → from_dict() (gateway restart).""" + store = _make_store( + SessionResetPolicy(mode="idle", idle_minutes=1), + tmp_path, + ) + source = _make_source() + + entry = store.get_or_create_session(source) + entry.updated_at = datetime.now() - timedelta(minutes=5) + store._save() + + entry2 = store.get_or_create_session(source) + assert entry2.was_auto_reset is True + assert entry2.auto_reset_reason == "idle" + assert entry2.session_id != entry.session_id + + # Simulate gateway restart: reload from disk + store._loaded = False + store._entries.clear() + store._ensure_loaded() + + reloaded = store._entries.get(entry2.session_key) + assert reloaded is not None + assert reloaded.was_auto_reset is True + assert reloaded.auto_reset_reason == "idle" + + def test_reset_had_activity_persists_across_roundtrip(self, tmp_path): + """reset_had_activity survives to_dict() → from_dict() (gateway restart).""" + store = _make_store( + SessionResetPolicy(mode="idle", idle_minutes=1), + tmp_path, + ) + source = _make_source() + + entry = store.get_or_create_session(source) + entry.total_tokens = 1000 + entry.updated_at = datetime.now() - timedelta(minutes=5) + store._save() + + entry2 = store.get_or_create_session(source) + assert entry2.reset_had_activity is True + + store._loaded = False + store._entries.clear() + store._ensure_loaded() + + reloaded = store._entries.get(entry2.session_key) + assert reloaded is not None + assert reloaded.reset_had_activity is True + + def test_auto_reset_reason_none_roundtrip(self, tmp_path): + """auto_reset_reason=None (no reset) survives roundtrip cleanly.""" + store = _make_store(tmp_path=tmp_path) + source = _make_source() + + entry = store.get_or_create_session(source) + assert entry.was_auto_reset is False + + store._loaded = False + store._entries.clear() + store._ensure_loaded() + + reloaded = store._entries.get(entry.session_key) + assert reloaded is not None + assert reloaded.was_auto_reset is False + assert reloaded.auto_reset_reason is None + assert reloaded.reset_had_activity is False From 23ac522d3711ea0735f11f4d8f6131ac24554dd3 Mon Sep 17 00:00:00 2001 From: KiraKatana Date: Fri, 15 May 2026 01:24:44 -0700 Subject: [PATCH 173/214] fix(gateway): isinstance-guard string-form 429 error body When a non-Anthropic provider (e.g. Morpheus proxy) returns a 429 with `{"error": "Too Many Requests"}` instead of the expected `{"error": {"type": ...}}` dict, _err_body.json().get("error", {}) returns the raw string and the next .get("type") line crashes with AttributeError, taking down the message handler. Guard with isinstance(_err_json, dict) so non-dict error bodies fall through to the generic rate-limit hint. Salvaged from PR #2587 by @KiraKatana. The PR's fallback-config `base_url`/`api_key_env` fix was already implemented independently on main (run_agent.py:8759-8780) with additional aliases and Ollama Cloud host handling, so only the gateway guard is cherry-picked. Co-authored-by: KiraKatana --- gateway/run.py | 2 ++ scripts/release.py | 1 + 2 files changed, 3 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index d986917ebab..5e8fce8e18d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7991,6 +7991,8 @@ class GatewayRunner: try: if _err_body is not None: _err_json = _err_body.json().get("error", {}) + if not isinstance(_err_json, dict): + _err_json = {} except Exception: pass if _err_json.get("type") == "usage_limit_reached": diff --git a/scripts/release.py b/scripts/release.py index 53db4bbec2c..47cb78edff8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -637,6 +637,7 @@ AUTHOR_MAP = { "skmishra1991@gmail.com": "bugkill3r", "karamusti912@gmail.com": "MustafaKara7", "kira@ariaki.me": "kira-ariaki", + "kira.ops@proton.me": "KiraKatana", "knopki@duck.com": "knopki", "limars874@gmail.com": "limars874", "lisicheng168@gmail.com": "lesterli", From 814c60092b08df3e4f7ccfcc0bab4e1fbaa39414 Mon Sep 17 00:00:00 2001 From: CoinTheHat <63822243+CoinTheHat@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:23:32 +0300 Subject: [PATCH 174/214] fix: clean stale conversation mappings on response eviction/deletion ResponseStore.put() and .delete() now remove conversations rows that reference evicted or deleted response IDs, preventing 404 errors when a conversation name is reused after its backing response was purged. Adds regression tests for delete, eviction, and handler-level reuse. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/api_server.py | 29 ++++++++++++--- tests/gateway/test_api_server.py | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 8b53db3a99f..809d6cd8a03 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -356,15 +356,34 @@ class ResponseStore: # Evict oldest entries beyond max_size count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0] if count > self._max_size: - self._conn.execute( - "DELETE FROM responses WHERE response_id IN " - "(SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?)", - (count - self._max_size,), - ) + # Collect IDs that will be evicted + evict_ids = [ + row[0] + for row in self._conn.execute( + "SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?", + (count - self._max_size,), + ).fetchall() + ] + if evict_ids: + placeholders = ",".join("?" for _ in evict_ids) + # Clear conversation mappings pointing to evicted responses + self._conn.execute( + f"DELETE FROM conversations WHERE response_id IN ({placeholders})", + evict_ids, + ) + # Delete evicted responses + self._conn.execute( + f"DELETE FROM responses WHERE response_id IN ({placeholders})", + evict_ids, + ) self._conn.commit() def delete(self, response_id: str) -> bool: """Remove a response from the store. Returns True if found and deleted.""" + # Clear conversation mappings pointing to this response + self._conn.execute( + "DELETE FROM conversations WHERE response_id = ?", (response_id,) + ) cursor = self._conn.execute( "DELETE FROM responses WHERE response_id = ?", (response_id,) ) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 66b304fff51..032af7109a5 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -105,6 +105,29 @@ class TestResponseStore: store = ResponseStore(max_size=10) assert store.delete("resp_missing") is False + def test_delete_clears_conversation_mapping(self): + """Deleting a response also removes conversation mappings that reference it.""" + store = ResponseStore(max_size=10) + store.put("resp_1", {"output": "hello"}) + store.set_conversation("chat-a", "resp_1") + assert store.get_conversation("chat-a") == "resp_1" + store.delete("resp_1") + assert store.get_conversation("chat-a") is None + + def test_eviction_clears_conversation_mapping(self): + """LRU eviction also removes conversation mappings for evicted responses.""" + store = ResponseStore(max_size=2) + store.put("resp_1", {"output": "one"}) + store.set_conversation("chat-a", "resp_1") + store.put("resp_2", {"output": "two"}) + store.set_conversation("chat-b", "resp_2") + # Adding a 3rd should evict resp_1 and its conversation mapping + store.put("resp_3", {"output": "three"}) + assert store.get("resp_1") is None + assert store.get_conversation("chat-a") is None + # resp_2 mapping should still be intact + assert store.get_conversation("chat-b") == "resp_2" + # --------------------------------------------------------------------------- # _IdempotencyCache @@ -2870,6 +2893,45 @@ class TestConversationParameter: # Conversation mapping should NOT be set since store=false assert adapter._response_store.get_conversation("ephemeral-chat") is None + @pytest.mark.asyncio + async def test_conversation_reuse_after_eviction_no_404(self, adapter): + """After eviction clears a conversation mapping, reusing that name starts fresh (no 404).""" + adapter._response_store = ResponseStore(max_size=1) + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + {"final_response": "First", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + # Create conversation -> resp stored + resp1 = await cli.post("/v1/responses", json={ + "input": "hello", + "conversation": "my-chat", + }) + assert resp1.status == 200 + + # Evict by adding another response + mock_run.return_value = ( + {"final_response": "Other", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + await cli.post("/v1/responses", json={"input": "other"}) + + # Conversation mapping should have been cleaned by eviction + assert adapter._response_store.get_conversation("my-chat") is None + + # Reuse conversation name — should start fresh, not 404 + mock_run.return_value = ( + {"final_response": "Restarted", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + resp3 = await cli.post("/v1/responses", json={ + "input": "hello again", + "conversation": "my-chat", + }) + assert resp3.status == 200 + # --------------------------------------------------------------------------- # X-Hermes-Session-Id header (session continuity) From 0161d4bb6ce3154e2cdd8ce54d43273cf457840f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:27:31 -0700 Subject: [PATCH 175/214] chore(release): add AUTHOR_MAP entry for CoinTheHat --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 47cb78edff8..10d67f3e708 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -74,6 +74,7 @@ AUTHOR_MAP = { "1095245867@qq.com": "littlewwwhite", "db@project-aeon.com": "db-aeon", "ahmed@abadr.net": "ahmedbadr3", + "63822243+CoinTheHat@users.noreply.github.com": "CoinTheHat", "cleo@edaphic.xyz": "curiouscleo", "hirokazu.ogawa@kwansei.ac.jp": "hrkzogw", "datapod.k@gmail.com": "dandacompany", From 681778a0b753bac894bd30b1d257bcb3eface63d Mon Sep 17 00:00:00 2001 From: Wysie Date: Fri, 15 May 2026 01:29:43 -0700 Subject: [PATCH 176/214] fix(whatsapp): fail fast when Baileys sendMessage hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baileys' sock.sendMessage() can hang indefinitely while uploading media to WhatsApp servers (and, less often, on text sends), pinning the bridge's Express handler until the gateway's aiohttp timeout fires — surfacing to the user as a 120s wait followed by an empty error from the TTS/voice path. Wrap every sock.sendMessage() call inside the bridge in a sendWithTimeout() helper that rejects after WHATSAPP_SEND_TIMEOUT_MS (default 60s) via Promise.race. The four call sites are /send, /edit, and /send-media's primary send. Express handlers catch the rejection in their existing try/catch and return a real 500 to the gateway, which can then surface a retryable error. Salvaged from #2608 — wysie diagnosed the hang and the Promise.race shape; the other two parts of that PR (gateway HTTP session pooling, base.py metadata kwarg removal) already landed on main via separate routes and are no longer needed. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- scripts/whatsapp-bridge/bridge.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 9ff64471e56..5723d8b543b 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -57,11 +57,28 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10); const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10); +// Per-call timeout for sock.sendMessage(). Baileys occasionally hangs forever +// when uploading media to WhatsApp servers (and, less often, on text sends), +// which pins the bridge's HTTP handler until the upstream aiohttp timeout +// fires. Fail fast instead so the gateway can surface a real error and retry. +const SEND_TIMEOUT_MS = parseInt(process.env.WHATSAPP_SEND_TIMEOUT_MS || '60000', 10); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) { + let timer; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`sendMessage timed out after ${timeoutMs / 1000}s`)), + timeoutMs, + ); + }); + return Promise.race([sock.sendMessage(chatId, payload), timeoutPromise]) + .finally(() => clearTimeout(timer)); +} + function formatOutgoingMessage(message) { // In bot mode, messages come from a different number so the prefix is // redundant — the sender identity is already clear. Only prepend in @@ -487,7 +504,7 @@ app.post('/send', async (req, res) => { const chunks = splitLongMessage(formatOutgoingMessage(message)); const messageIds = []; for (let i = 0; i < chunks.length; i += 1) { - const sent = await sock.sendMessage(chatId, { text: chunks[i] }); + const sent = await sendWithTimeout(chatId, { text: chunks[i] }); trackSentMessageId(sent); if (sent?.key?.id) messageIds.push(sent.key.id); if (chunks.length > 1 && i < chunks.length - 1) { @@ -521,10 +538,10 @@ app.post('/edit', async (req, res) => { const chunks = splitLongMessage(formatOutgoingMessage(message)); const messageIds = []; - await sock.sendMessage(chatId, { text: chunks[0], edit: key }); + await sendWithTimeout(chatId, { text: chunks[0], edit: key }); if (chunks.length > 1) { for (let i = 1; i < chunks.length; i += 1) { - const sent = await sock.sendMessage(chatId, { text: chunks[i] }); + const sent = await sendWithTimeout(chatId, { text: chunks[i] }); trackSentMessageId(sent); if (sent?.key?.id) messageIds.push(sent.key.id); if (i < chunks.length - 1) { @@ -625,7 +642,7 @@ app.post('/send-media', async (req, res) => { break; } - const sent = await sock.sendMessage(chatId, msgPayload); + const sent = await sendWithTimeout(chatId, msgPayload); trackSentMessageId(sent); From 04b1fdaecfda15ff4c8f5c9f0041516efd01ba30 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 15 May 2026 14:03:08 +0530 Subject: [PATCH 177/214] security(deps): add upper bounds to 5 loose deps + document supply chain policy (#24226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Mini Shai-Hulud supply chain campaign (May 2026) and the litellm compromise (March 2026), codify the dependency pinning policy that was established in PRs #2810 and #9801 but never written down for contributors. Changes: - pyproject.toml: Add tight upper bounds to the 5 deps that slipped through as review escapes from external contributor PRs: - hindsight-client>=0.4.22,<0.5 (was >=0.4.22) - aiosqlite>=0.20,<0.23 (was >=0.20) - asyncpg>=0.29,<0.32 (was >=0.29) - alibabacloud-dingtalk>=2.0.0,<3 (was >=2.0.0) - youtube-transcript-api>=1.2.0,<2 (was >=1.2.0) Pre-1.0 packages get <0.(current_minor+2) — tight enough to block hostile minor releases but loose enough to not require bumps every week. - CONTRIBUTING.md: Add 'Dependency pinning policy' section under Security with the full rationale, table of source types + treatments, and examples. - AGENTS.md: Add concise 'Dependency Pinning Policy' section for AI coding agents with the decision table and step-by-step checklist. - supply-chain-audit.yml: Add dep-bounds job that fails PRs introducing PyPI deps without > "$GITHUB_OUTPUT" + exit 0 + fi + + # Match PyPI dep specs that have >= but no < ceiling. + # Pattern: "package>=version" without a following ",<" bound. + # Excludes git+ URLs (which use commit SHAs) and comments. + UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true) + + if [ -n "$UNBOUNDED" ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + echo "$UNBOUNDED" > /tmp/unbounded.txt + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post unbounded dep warning + if: steps.bounds.outputs.found == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY="## ⚠️ Unbounded PyPI Dependency Detected + + This PR adds PyPI dependencies without a \`=floor,=1.2.0,<2\"\` + + --- + *See PR #2810 and CONTRIBUTING.md for the full policy rationale.*" + + gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)" + + - name: Fail on unbounded deps + if: steps.bounds.outputs.found == 'true' + run: | + echo "::error::PyPI dependencies without upper bounds detected. Add =floor,=0.28.1,<1"` | +| Git URL | Commit SHA | `git+https://...@<40-char-sha>` | +| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@ # v4` | +| CI-only pip | `==exact` | `pyyaml==6.0.2` | + +**When adding a new dependency to `pyproject.toml`:** +1. Pin to `>=current_version,=1.5.0,<2`). +2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`). +3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it. +4. Run `uv lock` to regenerate `uv.lock` with hashes. + +Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI). + +--- + ## Adding Configuration ### config.yaml options: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cbc26112f6..36b1e9df2d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -800,6 +800,47 @@ Hermes has terminal access. Security matters. If your PR affects security, note it explicitly in the description. +### Dependency pinning policy (supply chain hardening) + +After the [litellm supply chain compromise](https://github.com/BerriAI/litellm/issues/24512) in March 2026 and the [Mini Shai-Hulud worm campaign](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) in May 2026, all dependencies must follow these rules: + +| Source type | Required treatment | Rationale | +|---|---|---| +| **PyPI package** | `>=floor, # vX.Y.Z` | +| **CI-only pip installs** | `==exact` | Hermetic CI builds; churn is acceptable. | + +**Every new PyPI dependency in a PR must have a `=X.Y.Z` specs will be rejected by reviewers. The `supply-chain-audit.yml` CI workflow also flags dependency manifest changes for manual review. + +**How to determine the ceiling:** +- If the package is at version `1.x.y`, use `<2`. +- If the package is at version `0.x.y` (pre-1.0), use `<0.(current_minor + 2)` — e.g. if current is `0.29.x`, use `<0.32`. This gives ~2 minor versions of headroom while keeping the window small enough that a hostile takeover version is unlikely to land inside it. +- Exception: packages with very stable APIs (e.g. `aiohttp-socks`) can use `<1` at reviewer discretion. + +**Examples:** +```toml +# ✅ Correct — post-1.0 +"openai>=2.21.0,<3" +"pydantic>=2.12.5,<3" + +# ✅ Correct — pre-1.0 (tight minor window) +"asyncpg>=0.29,<0.32" +"aiosqlite>=0.20,<0.23" +"hindsight-client>=0.4.22,<0.5" + +# ❌ Rejected — no upper bound +"some-package>=1.2.3" + +# ❌ Rejected — too tight (blocks legitimate patches) +"some-package==1.2.3" + +# ❌ Rejected — too loose for pre-1.0 (allows 80 minor versions) +"some-package>=0.20,<1" +``` + +**Reference PRs:** #2796 (litellm removal), #2810 (upper bounds pass), #9801 (SHA pinning + supply-chain-audit CI). + --- ## Pull Request Process From 9329e06696c968b7a960541d0ee0167df6742f21 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:33:13 -0700 Subject: [PATCH 178/214] feat(image-gen): actionable setup message when no FAL backend is reachable (#26222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the in-tree FAL path has no API key (and no managed gateway), the handler used to return a bare 'FAL_KEY environment variable not set' error. Users had no idea where to get a key, that a managed Nous gateway exists, or that plugin-registered providers are an option. Now `image_generate_tool` returns a structured multi-line message: - signup link (https://fal.ai) - managed-gateway status (if Nous tools are enabled) - pointer to `hermes tools` / `hermes plugins list` for alternate backends, so users on a stale `image_gen.provider` know where to look The schema is untouched — `check_fn` still gates the tool out of the schema when no backend is reachable at startup, consistent with every other conditional tool. This patch fixes the call-time failure modes: managed-gateway 5xx, plugin provider disappearing mid-session, etc. Inspired by #2546 / @Mibayy. The PR was ~5700 commits stale against the new plugin-aware image_gen architecture, so this is a forward port of the actionable-error idea rather than a cherry-pick. Closes #2543 Co-authored-by: Mibayy --- tests/tools/test_image_generation_env.py | 59 ++++++++++++++++++++++++ tools/image_generation_tool.py | 41 ++++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_image_generation_env.py b/tests/tools/test_image_generation_env.py index fc4e6553346..56c9741617f 100644 --- a/tests/tools/test_image_generation_env.py +++ b/tests/tools/test_image_generation_env.py @@ -37,3 +37,62 @@ def test_fal_key_empty_is_unset(monkeypatch): ) assert image_generation_tool.check_fal_api_key() is False + + +# --------------------------------------------------------------------------- +# Actionable setup message when no FAL backend is reachable. +# Regression for the silent-drop UX gap described in issue #2543. +# --------------------------------------------------------------------------- + + +def test_no_backend_message_mentions_fal_signup_and_plugins(monkeypatch): + from tools import image_generation_tool + + monkeypatch.setattr( + image_generation_tool, "managed_nous_tools_enabled", lambda: False + ) + + msg = image_generation_tool._build_no_backend_setup_message() + + assert "FAL_KEY" in msg + assert "https://fal.ai" in msg + # Plugin pointer so users on a stale image_gen.provider know where to look. + assert "hermes tools" in msg or "hermes plugins" in msg + + +def test_no_backend_message_mentions_managed_gateway_when_enabled(monkeypatch): + from tools import image_generation_tool + + monkeypatch.setattr( + image_generation_tool, "managed_nous_tools_enabled", lambda: True + ) + + msg = image_generation_tool._build_no_backend_setup_message() + + assert "managed FAL gateway" in msg + assert "Nous account" in msg or "hermes setup" in msg + + +def test_image_generate_tool_returns_actionable_error_when_no_backend(monkeypatch): + """End-to-end: handler must surface the actionable message, not a bare string.""" + import json + + from tools import image_generation_tool + + monkeypatch.setattr( + image_generation_tool, "fal_key_is_configured", lambda: False + ) + monkeypatch.setattr( + image_generation_tool, "_resolve_managed_fal_gateway", lambda: None + ) + monkeypatch.setattr( + image_generation_tool, "managed_nous_tools_enabled", lambda: False + ) + + result = json.loads( + image_generation_tool.image_generate_tool(prompt="a cat") + ) + + assert result["success"] is False + assert "https://fal.ai" in result["error"] + assert "FAL_KEY" in result["error"] diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index c496166ec98..3d171f093c9 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -698,10 +698,7 @@ def image_generate_tool( raise ValueError("Prompt is required and must be a non-empty string") if not (fal_key_is_configured() or _resolve_managed_fal_gateway()): - message = "FAL_KEY environment variable not set" - if managed_nous_tools_enabled(): - message += " and managed FAL gateway is unavailable" - raise ValueError(message) + raise ValueError(_build_no_backend_setup_message()) aspect_lc = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() if aspect_lc not in VALID_ASPECT_RATIOS: @@ -811,6 +808,42 @@ def check_fal_api_key() -> bool: return bool(fal_key_is_configured() or _resolve_managed_fal_gateway()) +def _build_no_backend_setup_message() -> str: + """Build an actionable error string when no FAL backend is reachable. + + Used by the in-tree FAL path. Mentions: + - FAL_KEY signup link + - managed-gateway status (if Nous tools are enabled) + - plugin alternative pointer (so users on a stale ``image_gen.provider`` + know the registry exists and how to inspect it) + """ + lines = ["Image generation is unavailable in this environment.", ""] + lines.append("Missing requirements:") + if managed_nous_tools_enabled(): + lines.append( + " - FAL_KEY is not set and the managed FAL gateway is unreachable" + ) + else: + lines.append(" - FAL_KEY environment variable is not set") + lines.append("") + lines.append("To enable image generation, do one of:") + lines.append( + " 1. Get a free API key at https://fal.ai and set " + "FAL_KEY= (then restart the session)" + ) + if managed_nous_tools_enabled(): + lines.append( + " 2. Sign in to a Nous account that has the managed FAL " + "gateway enabled (`hermes setup`)" + ) + lines.append( + " 3. Configure a different image_gen provider via `hermes tools` " + "→ Image Generation (run `hermes plugins list` to see installed " + "backends)" + ) + return "\n".join(lines) + + def check_image_generation_requirements() -> bool: """True if any image gen backend is available. From 05d9f641c06043a538ba03e3ed008a97403fcc3b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:34:15 -0700 Subject: [PATCH 179/214] docs(cron): worked recipes for the wakeAgent pre-run gate (#26229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three pre-run gate recipes to the cron docs: - file-change gate (stat + mtime + state file) - external-flag gate (file presence) - SQL-count gate (user's own database, not state.db) These are the use cases @iankar8 proposed adding as a parallel 'trigger' subsystem in #2654. The existing `script` + `wakeAgent` gate already covers all three at $0 — this lands the patterns as documentation so users can find them, instead of adding a second gating mechanism to the cron subsystem. --- website/docs/user-guide/features/cron.md | 80 ++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index c2c67df8a2a..9a14e6dcd1e 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -522,6 +522,86 @@ print(json.dumps({"wakeAgent": True, "context": {"new_issues": latest - prev}})) When `wakeAgent` is omitted, the default is `true` (wake the agent as usual). +#### Recipes: cheap pre-run gates + +The `wakeAgent` gate gives you a $0 way to decide whether a scheduled job should spend any LLM tokens at all. Three patterns cover most use cases. + +**File-change gate** — only run when a watched file has new content since the last successful tick. The scheduler records each job's `last_run_at`; compare it against the file's mtime. + +```bash +#!/bin/bash +# ~/.hermes/scripts/feed-changed.sh +FEED="$HOME/data/feed.json" +STATE="$HOME/.hermes/scripts/.feed-changed.last" +test -f "$FEED" || { echo '{"wakeAgent": false}'; exit 0; } +mtime=$(stat -c %Y "$FEED") +last=$(cat "$STATE" 2>/dev/null || echo 0) +if [ "$mtime" -le "$last" ]; then + echo '{"wakeAgent": false}' +else + echo "$mtime" > "$STATE" + echo '{"wakeAgent": true}' +fi +``` + +```text +cronjob(action="create", name="process-feed", + schedule="every 30m", + script="feed-changed.sh", + prompt="A new ~/data/feed.json has landed. Summarize what changed.") +``` + +**External-flag gate** — only run when some other process has signalled readiness (e.g. a deploy hook drops a file, a CI job sets a value in your state store). + +```bash +#!/bin/bash +# ~/.hermes/scripts/flag-ready.sh +if test -f /tmp/new-data-ready; then + rm -f /tmp/new-data-ready + echo '{"wakeAgent": true}' +else + echo '{"wakeAgent": false}' +fi +``` + +```text +cronjob(action="create", name="nightly-analysis", + schedule="0 9 * * *", + script="flag-ready.sh", + prompt="Run the nightly analysis over today's batch.") +``` + +**SQL-count gate** — only run when there are new rows to process in your own database. The script can also pass the count through to the agent via `context`, so the agent knows how much it's looking at without re-querying. + +```python +#!/usr/bin/env python +# ~/.hermes/scripts/new-rows.py +import json, sqlite3 +conn = sqlite3.connect("/home/me/data/app.db") +n = conn.execute( + "SELECT COUNT(*) FROM messages WHERE ts > strftime('%s','now','-2 hours')" +).fetchone()[0] +if n < 1: + print(json.dumps({"wakeAgent": False})) +else: + print(json.dumps({"wakeAgent": True, "context": {"new_rows": n}})) +``` + +```text +cronjob(action="create", name="summarize-new-msgs", + schedule="every 2h", + script="new-rows.py", + prompt="Summarize the new messages from the last 2 hours.") +``` + +The same pattern works for any data source you can query from a script — Postgres, an HTTP API, your own state store — without baking a SQL evaluator into the cron subsystem. + +:::tip +Hermes's own `~/.hermes/state.db` is an internal schema that changes between releases. Don't query it from a pre-run gate — point at your own database or feed instead. +::: + +Credit: this recipe set was prompted by @iankar8's exploration in [#2654](https://github.com/NousResearch/hermes-agent/pull/2654), which proposed adding sql/file/command triggers as a parallel mechanism. The `script` + `wakeAgent` gate already covers all three cases at $0, so the work landed as documentation instead. + ### Chaining jobs: `context_from` A cron job can consume the most recent successful output of one or more other jobs by listing their names (or IDs) in `context_from`: From 6682f91b80bab57c65435ae6b5cdc791334ed620 Mon Sep 17 00:00:00 2001 From: buntingszn <108427749+buntingszn@users.noreply.github.com> Date: Fri, 15 May 2026 01:33:12 -0700 Subject: [PATCH 180/214] feat(cron): support name-based lookup for job operations Cron mutation operations (run/pause/resume/remove) and 'hermes cron edit' now accept a job name in addition to the hex ID, with case-insensitive matching. Before this, 'hermes cron run my_job_name' died with 'Job with ID my_job_name not found' and forced the user to look up the hex ID first. The original PR matched by name but silently picked the first match when two jobs shared a name. This version refuses to act on an ambiguous name and surfaces every matching job (id, name, schedule, next_run_at) so the caller can pick a specific ID. - cron/jobs.py: - get_job() stays ID-only (preserves existing call-site semantics for web_server/api_server/curator/scheduler/test code that always passes real IDs). - resolve_job_ref() is the new name-or-ID resolver, used by pause/ resume/trigger/remove_job. Exact ID match wins over a name match even if a different job's name happens to equal that ID. Ambiguous name match raises AmbiguousJobReference with all candidate IDs. - tools/cronjob_tools.py: dispatch site uses resolve_job_ref, surfaces ambiguous matches as a structured error with the matching IDs. - hermes_cli/cron.py: 'cron edit' uses resolve_job_ref so editing by name works and ambiguous names are reported with IDs. - tests/cron/test_jobs.py: new TestResolveJobRef covering ID match, case-insensitive name match, ID-wins-over-name, ambiguous refusal, and that pause/resume/trigger/remove all refuse on ambiguity. Closes #2627 --- cron/jobs.py | 67 +++++++++++++++++++++++++------ hermes_cli/cron.py | 10 ++++- tests/cron/test_jobs.py | 87 +++++++++++++++++++++++++++++++++++++++++ tools/cronjob_tools.py | 28 +++++++++++-- 4 files changed, 176 insertions(+), 16 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 6b3bc0e66f9..c5da32d44d5 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -645,6 +645,44 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]: return None +class AmbiguousJobReference(LookupError): + """Raised when a job name matches more than one job.""" + + def __init__(self, ref: str, matches: List[Dict[str, Any]]): + self.ref = ref + self.matches = matches + ids = ", ".join(m["id"] for m in matches) + super().__init__( + f"Job name '{ref}' is ambiguous — matches {len(matches)} jobs: {ids}. " + f"Use the job ID instead." + ) + + +def resolve_job_ref(ref: str) -> Optional[Dict[str, Any]]: + """Resolve a job reference (ID or name) to a job record. + + - Exact ID match wins (works even if a different job's name equals this ID). + - Otherwise, case-insensitive name match. + - If a name matches more than one job, raises AmbiguousJobReference so the + caller can surface the matching IDs rather than silently picking one. + """ + if not ref: + return None + jobs = load_jobs() + for job in jobs: + if job["id"] == ref: + return _normalize_job_record(job) + ref_lower = ref.lower() + name_matches = [j for j in jobs if (j.get("name") or "").lower() == ref_lower] + if not name_matches: + return None + if len(name_matches) > 1: + raise AmbiguousJobReference( + ref, [_normalize_job_record(j) for j in name_matches] + ) + return _normalize_job_record(name_matches[0]) + + def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: """List all jobs, optionally including disabled ones.""" jobs = [_normalize_job_record(j) for j in load_jobs()] @@ -702,9 +740,12 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]: - """Pause a job without deleting it.""" + """Pause a job without deleting it. Accepts a job ID or name.""" + job = resolve_job_ref(job_id) + if not job: + return None return update_job( - job_id, + job["id"], { "enabled": False, "state": "paused", @@ -715,14 +756,14 @@ def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, A def resume_job(job_id: str) -> Optional[Dict[str, Any]]: - """Resume a paused job and compute the next future run from now.""" - job = get_job(job_id) + """Resume a paused job and compute the next future run from now. Accepts a job ID or name.""" + job = resolve_job_ref(job_id) if not job: return None next_run_at = compute_next_run(job["schedule"]) return update_job( - job_id, + job["id"], { "enabled": True, "state": "scheduled", @@ -734,12 +775,12 @@ def resume_job(job_id: str) -> Optional[Dict[str, Any]]: def trigger_job(job_id: str) -> Optional[Dict[str, Any]]: - """Schedule a job to run on the next scheduler tick.""" - job = get_job(job_id) + """Schedule a job to run on the next scheduler tick. Accepts a job ID or name.""" + job = resolve_job_ref(job_id) if not job: return None return update_job( - job_id, + job["id"], { "enabled": True, "state": "scheduled", @@ -751,14 +792,18 @@ def trigger_job(job_id: str) -> Optional[Dict[str, Any]]: def remove_job(job_id: str) -> bool: - """Remove a job by ID.""" + """Remove a job by ID or name.""" + job = resolve_job_ref(job_id) + if not job: + return False + canonical_id = job["id"] jobs = load_jobs() original_len = len(jobs) - jobs = [j for j in jobs if j["id"] != job_id] + jobs = [j for j in jobs if j["id"] != canonical_id] if len(jobs) < original_len: save_jobs(jobs) # Clean up output directory to prevent orphaned dirs accumulating - job_output_dir = OUTPUT_DIR / job_id + job_output_dir = OUTPUT_DIR / canonical_id if job_output_dir.exists(): shutil.rmtree(job_output_dir) return True diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index adf4f0c0927..7bff9c6b87b 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -196,9 +196,15 @@ def cron_create(args): def cron_edit(args): - from cron.jobs import get_job + from cron.jobs import AmbiguousJobReference, resolve_job_ref - job = get_job(args.job_id) + try: + job = resolve_job_ref(args.job_id) + except AmbiguousJobReference as exc: + print(color(str(exc), Colors.RED)) + for m in exc.matches: + print(f" {m['id']} (name: {m.get('name')!r})") + return 1 if not job: print(color(f"Job not found: {args.job_id}", Colors.RED)) return 1 diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index af42ca444b2..16c56cd6220 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -321,6 +321,93 @@ class TestPauseResumeJob: assert resumed["paused_reason"] is None +class TestResolveJobRef: + """Name-based job lookup for CLI/tool callers (PR #2627, @buntingszn).""" + + def test_resolve_by_exact_id(self, tmp_cron_dir): + from cron.jobs import resolve_job_ref + + job = create_job(prompt="A", schedule="1h", name="alpha") + assert resolve_job_ref(job["id"])["id"] == job["id"] + + def test_resolve_by_name(self, tmp_cron_dir): + from cron.jobs import resolve_job_ref + + job = create_job(prompt="A", schedule="1h", name="alpha") + assert resolve_job_ref("alpha")["id"] == job["id"] + + def test_resolve_by_name_case_insensitive(self, tmp_cron_dir): + from cron.jobs import resolve_job_ref + + job = create_job(prompt="A", schedule="1h", name="MyJob") + assert resolve_job_ref("myjob")["id"] == job["id"] + assert resolve_job_ref("MYJOB")["id"] == job["id"] + + def test_resolve_returns_none_when_not_found(self, tmp_cron_dir): + from cron.jobs import resolve_job_ref + + create_job(prompt="A", schedule="1h", name="alpha") + assert resolve_job_ref("does-not-exist") is None + assert resolve_job_ref("") is None + + def test_resolve_id_wins_over_name(self, tmp_cron_dir): + """If a job's name happens to equal another job's ID, ID match wins.""" + from cron.jobs import resolve_job_ref + + j1 = create_job(prompt="A", schedule="1h") + # Create a second job whose name is j1's ID + j2 = create_job(prompt="B", schedule="1h", name=j1["id"]) + # Looking up j1["id"] must return j1, not the colliding-name job j2 + assert resolve_job_ref(j1["id"])["id"] == j1["id"] + assert resolve_job_ref(j1["id"])["id"] != j2["id"] + + def test_resolve_ambiguous_name_raises(self, tmp_cron_dir): + """Two jobs sharing a name → refuse to pick, surface both IDs.""" + from cron.jobs import AmbiguousJobReference, resolve_job_ref + + j1 = create_job(prompt="A", schedule="1h", name="dup") + j2 = create_job(prompt="B", schedule="1h", name="dup") + with pytest.raises(AmbiguousJobReference) as exc_info: + resolve_job_ref("dup") + ids = {m["id"] for m in exc_info.value.matches} + assert ids == {j1["id"], j2["id"]} + # Error message mentions both IDs so the user can pick one + assert j1["id"] in str(exc_info.value) + assert j2["id"] in str(exc_info.value) + + def test_trigger_by_name(self, tmp_cron_dir): + from cron.jobs import trigger_job + + job = create_job(prompt="A", schedule="1h", name="alpha") + result = trigger_job("alpha") + assert result is not None + assert result["id"] == job["id"] + + def test_pause_by_name(self, tmp_cron_dir): + job = create_job(prompt="A", schedule="1h", name="alpha") + result = pause_job("alpha", reason="manual") + assert result is not None + assert result["id"] == job["id"] + assert result["state"] == "paused" + + def test_remove_by_name(self, tmp_cron_dir): + job = create_job(prompt="A", schedule="1h", name="alpha") + assert remove_job("alpha") is True + assert get_job(job["id"]) is None + + def test_mutations_refuse_ambiguous_name(self, tmp_cron_dir): + """pause/resume/trigger/remove must refuse to act on an ambiguous name.""" + from cron.jobs import AmbiguousJobReference, trigger_job + + create_job(prompt="A", schedule="1h", name="dup") + create_job(prompt="B", schedule="1h", name="dup") + for fn in (pause_job, resume_job, trigger_job): + with pytest.raises(AmbiguousJobReference): + fn("dup") + with pytest.raises(AmbiguousJobReference): + remove_job("dup") + + class TestMarkJobRun: def test_increments_completed(self, tmp_cron_dir): job = create_job(prompt="Test", schedule="every 1h") diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index e63b60047ac..3c29431484d 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -21,12 +21,14 @@ logger = logging.getLogger(__name__) sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import ( + AmbiguousJobReference, create_job, get_job, list_jobs, parse_schedule, pause_job, remove_job, + resolve_job_ref, resume_job, trigger_job, update_job, @@ -393,12 +395,32 @@ def cronjob( if not job_id: return tool_error(f"job_id is required for action '{normalized}'", success=False) - job = get_job(job_id) - if not job: + try: + job = resolve_job_ref(job_id) + except AmbiguousJobReference as exc: return json.dumps( - {"success": False, "error": f"Job with ID '{job_id}' not found. Use cronjob(action='list') to inspect jobs."}, + { + "success": False, + "error": str(exc), + "matches": [ + { + "id": m["id"], + "name": m.get("name"), + "schedule": m.get("schedule_display"), + "next_run_at": m.get("next_run_at"), + } + for m in exc.matches + ], + }, indent=2, ) + if not job: + return json.dumps( + {"success": False, "error": f"Job with ID or name '{job_id}' not found. Use cronjob(action='list') to inspect jobs."}, + indent=2, + ) + # Resolve to canonical ID (supports name-based lookup) + job_id = job["id"] if normalized == "remove": removed = remove_job(job_id) From 9f57f2286d9fb52419c69ea64c3119f734b35ef1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:33:16 -0700 Subject: [PATCH 181/214] chore(release): add AUTHOR_MAP entry for buntingszn --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 10d67f3e708..b0e1fda9686 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -64,6 +64,7 @@ AUTHOR_MAP = { "mr@shu.io": "mrshu", "adam.manning@gmail.com": "am423", "buraysandro9@gmail.com": "ygd58", + "108427749+buntingszn@users.noreply.github.com": "buntingszn", "yanglongwei06@gmail.com": "Alex-yang00", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", From 85782a4ed7f2329957c4af9a4243acb51c3cf921 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:36:54 -0700 Subject: [PATCH 182/214] feat(acp): hermes acp --setup-browser bootstraps browser tools for registry installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Zed ACP Registry path (uvx --from 'hermes-agent[acp]==X' hermes-acp) gets a Python-only install. Browser tools depend on the agent-browser npm package + Chromium, neither of which are in the wheel. Without an explicit bootstrap, registry users have no path to working browser tools. Ship a bundled, idempotent bootstrap script (Linux/macOS bash + Windows PowerShell) inside acp_adapter/bootstrap/ as wheel package-data. New entry points: hermes acp --setup-browser # interactive; prompts before Chromium download hermes acp --setup-browser --yes # non-interactive hermes-acp --setup-browser The terminal-auth flow (hermes acp --setup) also offers the browser bootstrap as a follow-up after model selection, so first-run registry users get the option without knowing the flag exists. Key design choices: - npm install -g --prefix $NODE_PREFIX so we never need sudo. System Node on PATH is respected; only the install target is redirected to the user-writable Hermes-managed Node prefix. - tools/browser_tool.py::_browser_candidate_path_dirs() already walks $HERMES_HOME/node/bin, so installed binaries are discovered with no agent-side code change. - System Chrome/Chromium detection short-circuits the ~400 MB Playwright download when a suitable browser already exists. - Bash + PowerShell live as ONE copy each under acp_adapter/bootstrap/. Not duplicated under scripts/. install.sh and install.ps1 keep their inline browser blocks for the source-checkout path. E2E validated end-to-end: bash bootstrap_browser_tools.sh --skip-chromium → installs agent-browser into ~/.hermes/node/bin/ tools.browser_tool._find_agent_browser() → returns the installed path check_browser_requirements() → returns True (browser tools register) Tests: - tests/acp/test_entry.py: 11 tests covering --setup-browser dispatch (linux + windows + --yes forwarding + failure propagation), the terminal-auth follow-up prompt path, and a package-data wheel-shipping assertion that catches any future pyproject.toml regression. Docs: website/docs/user-guide/features/acp.md gains a 'Browser tools (optional)' subsection with the two-line install + what-it-does. --- acp_adapter/bootstrap/__init__.py | 0 .../bootstrap/bootstrap_browser_tools.ps1 | 288 +++++++++++++ .../bootstrap/bootstrap_browser_tools.sh | 399 ++++++++++++++++++ acp_adapter/entry.py | 88 ++++ hermes_cli/main.py | 18 + pyproject.toml | 3 +- tests/acp/test_entry.py | 147 ++++++- website/docs/user-guide/features/acp.md | 21 + 8 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 acp_adapter/bootstrap/__init__.py create mode 100644 acp_adapter/bootstrap/bootstrap_browser_tools.ps1 create mode 100755 acp_adapter/bootstrap/bootstrap_browser_tools.sh diff --git a/acp_adapter/bootstrap/__init__.py b/acp_adapter/bootstrap/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 new file mode 100644 index 00000000000..f840fd2d559 --- /dev/null +++ b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 @@ -0,0 +1,288 @@ +# bootstrap_browser_tools.ps1 — install agent-browser + Playwright Chromium +# into ~/.hermes/node/ for use by Hermes Agent's browser tools on Windows. +# +# Targets the registry-install path: users who got Hermes via +# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, +# so the install.ps1 `npm install`-in-repo flow doesn't apply. This script +# is a self-contained, idempotent slice of install.ps1's browser block. +# +# Usage: +# .\bootstrap_browser_tools.ps1 # use defaults +# .\bootstrap_browser_tools.ps1 -Yes # accept Chromium download +# .\bootstrap_browser_tools.ps1 -SkipChromium # Node + agent-browser only +# +# Idempotent: re-running this is safe and fast. + +[CmdletBinding()] +param( + [switch]$Yes, + [switch]$SkipChromium +) + +$ErrorActionPreference = "Stop" +$NodeVersion = "22" + +# ───────────────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────────────── + +function Write-Info { param([string]$msg) Write-Host "[*] $msg" -ForegroundColor Cyan } +function Write-Success { param([string]$msg) Write-Host "[+] $msg" -ForegroundColor Green } +function Write-Warn { param([string]$msg) Write-Host "[!] $msg" -ForegroundColor Yellow } +function Write-Err { param([string]$msg) Write-Host "[x] $msg" -ForegroundColor Red } + +# ───────────────────────────────────────────────────────────────────────── +# Paths +# ───────────────────────────────────────────────────────────────────────── + +$HermesHome = $env:HERMES_HOME +if (-not $HermesHome) { + $HermesHome = Join-Path $env:USERPROFILE ".hermes" +} +$NodePrefix = Join-Path $HermesHome "node" + +# ───────────────────────────────────────────────────────────────────────── +# Step 1: Node.js +# ───────────────────────────────────────────────────────────────────────── + +function Resolve-NpmExe { + # Same gotcha as install.ps1: prefer npm.cmd over npm.ps1 so the + # PowerShell execution policy doesn't block us. + $cmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $npmExe = $cmd.Source + if ($npmExe -like "*.ps1") { + $sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $sibling) { return $sibling } + } + return $npmExe +} + +function Resolve-NpxExe { + $cmd = Get-Command npx -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $npxExe = $cmd.Source + if ($npxExe -like "*.ps1") { + $sibling = Join-Path (Split-Path $npxExe -Parent) "npx.cmd" + if (Test-Path $sibling) { return $sibling } + } + return $npxExe +} + +function Ensure-Node { + # System Node on PATH? + $sysNode = Get-Command node -ErrorAction SilentlyContinue + if ($sysNode) { + try { + $v = & $sysNode.Source --version + $major = [int]($v -replace '^v(\d+).*', '$1') + if ($major -ge 20) { + Write-Success "Node.js $v found on PATH" + return + } + Write-Warn "Node.js $v is older than v20 — installing managed Node." + } catch { + Write-Warn "Failed to query Node version: $_" + } + } + + # Hermes-managed Node? + $managedNode = Join-Path $NodePrefix "node.exe" + if (Test-Path $managedNode) { + $v = & $managedNode --version + Write-Success "Node.js $v found (Hermes-managed at $NodePrefix)" + # Prepend to current-process PATH so subsequent npm/npx calls find it. + $env:PATH = "$NodePrefix;$env:PATH" + return + } + + Write-Info "Installing Node.js $NodeVersion LTS into $NodePrefix ..." + + $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } + $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" + + try { + $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing + $matches = [regex]::Matches($indexPage.Content, "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip") + if ($matches.Count -eq 0) { + Write-Err "Could not locate Node.js $NodeVersion zip for win-$arch" + throw "no tarball" + } + $zipName = $matches[0].Value + $zipUrl = "$indexUrl$zipName" + + $tmpDir = Join-Path $env:TEMP "hermes-node-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $zipPath = Join-Path $tmpDir $zipName + + Write-Info "Downloading $zipName ..." + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing + + Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force + $extracted = Get-ChildItem -Path $tmpDir -Directory | Where-Object { $_.Name -like "node-v*" } | Select-Object -First 1 + + if (-not $extracted) { Write-Err "Node.js extraction failed"; throw "extract" } + + if (Test-Path $NodePrefix) { Remove-Item -Recurse -Force $NodePrefix } + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + Move-Item -Path $extracted.FullName -Destination $NodePrefix + + Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue + + $env:PATH = "$NodePrefix;$env:PATH" + $v = & "$NodePrefix\node.exe" --version + Write-Success "Node.js $v installed to $NodePrefix" + } catch { + Write-Err "Node.js install failed: $_" + Write-Info "Install Node 20+ manually from https://nodejs.org/en/download/ and re-run." + throw + } +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 2: agent-browser +# ───────────────────────────────────────────────────────────────────────── + +function Ensure-AgentBrowser { + $npmExe = Resolve-NpmExe + if (-not $npmExe) { + Write-Err "npm not on PATH after Node install — aborting" + throw "npm missing" + } + + # Already installed? + $existing = Get-Command agent-browser -ErrorAction SilentlyContinue + if ($existing) { + Write-Success "agent-browser already installed at $($existing.Source)" + return + } + + # When the user has system Node (winget / installer-based), `npm install + # -g` writes to a directory that may require admin rights. Force the + # prefix to the user-writable Hermes-managed Node directory so we never + # need elevation and the agent can always find the result. Mirrors the + # bash bootstrap's `--prefix $NODE_PREFIX` strategy. + New-Item -ItemType Directory -Force -Path $NodePrefix | Out-Null + + Write-Info "Installing agent-browser (npm, prefix=$NodePrefix)..." + & $npmExe install -g --prefix $NodePrefix --silent ` + "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" + if ($LASTEXITCODE -ne 0) { + Write-Err "npm install -g agent-browser failed (exit $LASTEXITCODE)" + throw "npm install" + } + + # Windows npm global installs drop shims at $NodePrefix\ root (not bin/). + # Prepend to PATH so any subsequent npx call resolves them. + $env:PATH = "$NodePrefix;$env:PATH" + + Write-Success "agent-browser installed to $NodePrefix" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 3: Playwright Chromium +# ───────────────────────────────────────────────────────────────────────── + +function Find-SystemBrowser { + $candidates = @( + "C:\Program Files\Google\Chrome\Application\chrome.exe", + "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + "C:\Program Files\Chromium\Application\chromium.exe", + "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", + "${env:LOCALAPPDATA}\Chromium\Application\chromium.exe" + ) + foreach ($p in $candidates) { + if (Test-Path $p) { return $p } + } + # Edge — Chromium-based, agent-browser can use it + foreach ($p in @( + "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + "C:\Program Files\Microsoft\Edge\Application\msedge.exe" + )) { + if (Test-Path $p) { return $p } + } + return $null +} + +function Write-BrowserEnv { + param([string]$BrowserPath) + $envFile = Join-Path $HermesHome ".env" + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + if (Test-Path $envFile) { + $existing = Get-Content $envFile -Raw -ErrorAction SilentlyContinue + if ($existing -and ($existing -match "(?m)^AGENT_BROWSER_EXECUTABLE_PATH=")) { + return + } + } + Add-Content -Path $envFile -Value "" + Add-Content -Path $envFile -Value "# Hermes Agent browser tools — use the system Chrome/Chromium/Edge binary." + Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" + Write-Success "Configured browser tools to use $BrowserPath" +} + +function Confirm-ChromiumDownload { + if ($Yes) { return $true } + if (-not [Environment]::UserInteractive) { + Write-Warn "Non-interactive shell — skipping Chromium prompt." + Write-Info "Re-run with -Yes to install Chromium (~400 MB download)." + return $false + } + $reply = Read-Host "Install Playwright Chromium (~400 MB download)? [y/N]" + return ($reply -match "^(y|yes)$") +} + +function Ensure-Chromium { + if ($SkipChromium) { + Write-Info "Skipping Chromium install (-SkipChromium)" + return + } + + # agent-browser on Windows expects a Playwright-managed Chromium under + # %LOCALAPPDATA%\ms-playwright. The system-browser shortcut from the + # Linux/macOS path doesn't apply the same way on Windows — Playwright's + # default launch path won't pick up a stock Chrome install without an + # explicit AGENT_BROWSER_EXECUTABLE_PATH. We still offer it as a + # fallback when the user doesn't want the download. + + if (-not (Confirm-ChromiumDownload)) { + $sys = Find-SystemBrowser + if ($sys) { + Write-Info "Using system browser at $sys (Chromium download skipped)." + Write-BrowserEnv -BrowserPath $sys + } else { + Write-Info "Chromium install skipped. Browser tools won't launch until" + Write-Info "Chromium is installed or AGENT_BROWSER_EXECUTABLE_PATH is set." + } + return + } + + $npxExe = Resolve-NpxExe + if (-not $npxExe) { + Write-Err "npx not on PATH — cannot install Playwright Chromium" + throw "npx missing" + } + + Write-Info "Installing Playwright Chromium (~400 MB) ..." + & $npxExe --yes playwright install chromium + if ($LASTEXITCODE -ne 0) { + Write-Err "Playwright Chromium install failed (exit $LASTEXITCODE)" + Write-Info "Try again later: npx --yes playwright install chromium" + throw "playwright" + } + Write-Success "Playwright Chromium installed" +} + +# ───────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────── + +Write-Info "Hermes Agent: bootstrapping browser tools" +Write-Info " HERMES_HOME = $HermesHome" +Write-Info " OS = Windows" + +Ensure-Node +Ensure-AgentBrowser +Ensure-Chromium + +Write-Success "Browser tools setup complete." +Write-Info "Hermes Agent will pick up agent-browser from $NodePrefix on next launch." diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.sh b/acp_adapter/bootstrap/bootstrap_browser_tools.sh new file mode 100755 index 00000000000..9981069a6af --- /dev/null +++ b/acp_adapter/bootstrap/bootstrap_browser_tools.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# +# bootstrap_browser_tools.sh — install agent-browser + Playwright Chromium +# into ~/.hermes/node/ for use by Hermes Agent's browser tools. +# +# Targets the registry-install path: users who got Hermes via +# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, +# so the install.sh `npm install`-in-repo flow doesn't apply. This script +# is a self-contained, idempotent slice of install.sh's browser block — +# safe to run from `hermes-acp --setup-browser`, from a fresh terminal, +# or from install.sh itself (it's a no-op when everything is already in place). +# +# Usage: +# bootstrap_browser_tools.sh # use defaults +# bootstrap_browser_tools.sh --yes # accept the ~400MB Chromium download +# bootstrap_browser_tools.sh --skip-chromium # only install Node + agent-browser +# HERMES_HOME=/custom/path bootstrap_browser_tools.sh +# +# Idempotent: re-running this is safe and fast. Each step checks whether +# the work is already done. + +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────── +# Config +# ───────────────────────────────────────────────────────────────────────── + +NODE_VERSION="22" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +NODE_PREFIX="$HERMES_HOME/node" + +SKIP_CHROMIUM=false +ASSUME_YES=false + +# ───────────────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────────────── + +if [ -t 1 ]; then + C_GREEN='\033[0;32m' + C_YELLOW='\033[0;33m' + C_BLUE='\033[0;34m' + C_RED='\033[0;31m' + C_RESET='\033[0m' +else + C_GREEN='' ; C_YELLOW='' ; C_BLUE='' ; C_RED='' ; C_RESET='' +fi + +log_info() { printf "${C_BLUE}[*]${C_RESET} %s\n" "$*"; } +log_success() { printf "${C_GREEN}[✓]${C_RESET} %s\n" "$*"; } +log_warn() { printf "${C_YELLOW}[!]${C_RESET} %s\n" "$*" >&2; } +log_error() { printf "${C_RED}[✗]${C_RESET} %s\n" "$*" >&2; } + +# ───────────────────────────────────────────────────────────────────────── +# Arg parsing +# ───────────────────────────────────────────────────────────────────────── + +while [ $# -gt 0 ]; do + case "$1" in + --skip-chromium) SKIP_CHROMIUM=true ;; + --yes|-y) ASSUME_YES=true ;; + -h|--help) + cat </dev/null 2>&1; then + local found_ver major + found_ver=$(node --version 2>/dev/null) + major=$(echo "$found_ver" | sed -E 's/^v([0-9]+).*/\1/') + if [ -n "$major" ] && [ "$major" -ge 20 ]; then + log_success "Node.js $found_ver found on PATH" + return 0 + fi + log_warn "Node.js $found_ver is older than v20 — installing managed Node." + fi + + if [ -x "$NODE_PREFIX/bin/node" ]; then + local found_ver + found_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") + export PATH="$NODE_PREFIX/bin:$PATH" + log_success "Node.js $found_ver found (Hermes-managed at $NODE_PREFIX)" + return 0 + fi + + log_info "Installing Node.js $NODE_VERSION LTS into $NODE_PREFIX ..." + + local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" + local tarball_name + tarball_name=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.xz" \ + | head -1) + + if [ -z "$tarball_name" ]; then + tarball_name=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.gz" \ + | head -1) + fi + + if [ -z "$tarball_name" ]; then + log_error "Could not locate Node.js $NODE_VERSION tarball for $NODE_OS-$NODE_ARCH" + log_info "Install Node 20+ manually: https://nodejs.org/en/download/" + return 1 + fi + + local tmp_dir + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' RETURN + + log_info "Downloading $tarball_name ..." + if ! curl -fsSL "${index_url}${tarball_name}" -o "$tmp_dir/$tarball_name"; then + log_error "Node.js download failed" + return 1 + fi + + if [[ "$tarball_name" == *.tar.xz ]]; then + tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir" + else + tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" + fi + + local extracted_dir + extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1) + if [ ! -d "$extracted_dir" ]; then + log_error "Node.js extraction failed" + return 1 + fi + + mkdir -p "$HERMES_HOME" + rm -rf "$NODE_PREFIX" + mv "$extracted_dir" "$NODE_PREFIX" + + export PATH="$NODE_PREFIX/bin:$PATH" + + local installed_ver + installed_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") + log_success "Node.js $installed_ver installed to $NODE_PREFIX" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 2: agent-browser + @askjo/camofox-browser via global npm install +# ───────────────────────────────────────────────────────────────────────── + +ensure_agent_browser() { + if ! command -v npm >/dev/null 2>&1; then + log_error "npm not on PATH after Node install — aborting" + return 1 + fi + + # _find_agent_browser() in tools/browser_tool.py walks ~/.hermes/node/bin + # plus a few standard prefixes, so installing globally into the managed + # Node prefix is enough — no PATH manipulation needed from the agent side. + if [ -x "$NODE_PREFIX/bin/agent-browser" ] || command -v agent-browser >/dev/null 2>&1; then + log_success "agent-browser already installed" + return 0 + fi + + # When the system's `npm` resolves to a root-owned prefix (e.g. + # /usr/lib/node_modules), `npm install -g` fails with EACCES without + # sudo. Force the prefix to the user-writable Hermes-managed Node + # directory so we never need sudo and the agent can always find the + # result. If we installed Node ourselves above, this is a no-op + # (managed Node already uses $NODE_PREFIX). If the user has system + # Node, we still drop agent-browser under $NODE_PREFIX/bin/ — which + # is exactly where _browser_candidate_path_dirs() looks first. + mkdir -p "$NODE_PREFIX" + + log_info "Installing agent-browser (npm, prefix=$NODE_PREFIX)..." + if ! npm install -g --prefix "$NODE_PREFIX" --silent \ + agent-browser@^0.26.0 \ + "@askjo/camofox-browser@^1.5.2"; then + log_error "npm install -g agent-browser failed" + return 1 + fi + + # macOS/Linux global installs place the shim into $NODE_PREFIX/bin/. + # Add it to PATH for any subsequent steps (npx playwright). + export PATH="$NODE_PREFIX/bin:$PATH" + + log_success "agent-browser installed to $NODE_PREFIX/bin/" +} + +# ───────────────────────────────────────────────────────────────────────── +# Step 3: Playwright Chromium +# ───────────────────────────────────────────────────────────────────────── + +confirm_chromium_download() { + if [ "$ASSUME_YES" = true ]; then return 0; fi + if [ ! -t 0 ]; then + log_warn "Non-interactive shell — skipping Chromium prompt." + log_info "Re-run with --yes to install Chromium (~400 MB download)." + return 1 + fi + printf "Install Playwright Chromium (~400 MB download)? [y/N] " + local reply="" + read -r reply || reply="" + case "$reply" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +# Detect a usable system Chrome/Chromium. agent-browser's Chrome engine can +# use it instead of downloading Playwright's bundled Chromium, saving the +# download cost. Returns the path or empty string. +find_system_browser() { + 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 + # macOS app-bundle locations + if [ "$OS" = "macos" ]; then + for candidate in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" ; do + if [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + fi + return 1 +} + +write_browser_env() { + local browser_path="$1" + local env_file="$HERMES_HOME/.env" + mkdir -p "$HERMES_HOME" + if [ -f "$env_file" ] && grep -q "^AGENT_BROWSER_EXECUTABLE_PATH=" "$env_file"; then + 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" +} + +ensure_chromium() { + if [ "$SKIP_CHROMIUM" = true ]; then + log_info "Skipping Chromium install (--skip-chromium)" + return 0 + fi + + local system_browser + system_browser="$(find_system_browser 2>/dev/null || true)" + if [ -n "$system_browser" ]; then + log_success "Found system browser: $system_browser" + log_info "Skipping Playwright Chromium download; agent-browser will use it." + write_browser_env "$system_browser" + return 0 + fi + + if ! confirm_chromium_download; then + log_info "Chromium install skipped. Browser tools will only work if you" + log_info "set AGENT_BROWSER_EXECUTABLE_PATH or install Chromium later." + return 0 + fi + + if ! command -v npx >/dev/null 2>&1; then + log_error "npx not on PATH — cannot install Playwright Chromium" + return 1 + fi + + log_info "Installing Playwright Chromium (~400 MB) ..." + + # On apt-based distros, --with-deps requires sudo. Try non-interactively + # only — never prompt — and fall back to the bare browser-only install. + local installed=false + if [ "$OS" = "linux" ]; then + case "$DISTRO" in + ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) + if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then + log_info "Installing system deps with --with-deps (sudo available)" + if npx --yes playwright install --with-deps chromium; then + installed=true + fi + else + log_warn "sudo not available non-interactively — installing Chromium without system deps." + log_info "If browser tools fail to launch, an administrator should run:" + log_info " sudo npx playwright install-deps chromium" + fi + ;; + arch|manjaro|cachyos|endeavouros|garuda) + log_info "Arch-family system dependencies are not auto-installed." + log_info "If launch fails, run: sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + ;; + fedora|rhel|centos|rocky|alma) + log_info "Fedora/RHEL system dependencies are not auto-installed." + log_info "If launch fails, run: sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" + ;; + opensuse*|sles) + log_info "openSUSE system dependencies are not auto-installed." + ;; + esac + fi + + if [ "$installed" = false ]; then + if npx --yes playwright install chromium; then + installed=true + fi + fi + + if [ "$installed" = true ]; then + log_success "Playwright Chromium installed" + else + log_error "Playwright Chromium install failed" + log_info "Try again later: npx --yes playwright install chromium" + return 1 + fi +} + +# ───────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────── + +main() { + log_info "Hermes Agent: bootstrapping browser tools" + log_info " HERMES_HOME = $HERMES_HOME" + log_info " OS / arch = $NODE_OS-$NODE_ARCH ${DISTRO:+($DISTRO)}" + + ensure_node + ensure_agent_browser + ensure_chromium + + log_success "Browser tools setup complete." + log_info "Hermes Agent will pick up agent-browser from $NODE_PREFIX/bin/ on next launch." +} + +main diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 48e677a6522..cf5c2ba9cfb 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -124,6 +124,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Run interactive Hermes provider/model setup for ACP terminal auth", ) + parser.add_argument( + "--setup-browser", + action="store_true", + help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " + "for browser tool support. Idempotent.", + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + dest="assume_yes", + help="Accept all prompts (currently used by --setup-browser to skip the " + "~400 MB Chromium download confirmation).", + ) return parser.parse_args(argv) @@ -150,6 +164,75 @@ def _run_setup() -> None: finally: sys.argv = old_argv + # Offer browser-tools install as a follow-up. The terminal auth method + # is the one supported first-run UX for registry installs, so this is + # the natural moment to ask. Skip silently if stdin isn't a TTY (the + # answer can't be collected anyway). + if not sys.stdin.isatty(): + return + try: + reply = input( + "\nInstall browser tools? Downloads agent-browser (npm) and " + "optionally Playwright Chromium (~400 MB). [y/N] " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + return + if reply in {"y", "yes"}: + _run_setup_browser(assume_yes=False) + + +def _run_setup_browser(assume_yes: bool = False) -> int: + """Bootstrap agent-browser + Playwright Chromium for the registry-install path. + + Shells out to the bundled platform-specific bootstrap script + (acp_adapter/bootstrap/bootstrap_browser_tools.{sh,ps1}) so the install + logic lives in one place — readable, debuggable, and shareable with + install.sh / install.ps1 if we ever want to call it from there too. + + Returns the script's exit code (0 on success). + """ + import platform + import subprocess + + bootstrap_dir = Path(__file__).resolve().parent / "bootstrap" + + if platform.system() == "Windows": + script = bootstrap_dir / "bootstrap_browser_tools.ps1" + if not script.is_file(): + print( + f"Bootstrap script not found at {script} — wheel may be incomplete.", + file=sys.stderr, + ) + return 1 + cmd = [ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", str(script), + ] + if assume_yes: + cmd.append("-Yes") + else: + script = bootstrap_dir / "bootstrap_browser_tools.sh" + if not script.is_file(): + print( + f"Bootstrap script not found at {script} — wheel may be incomplete.", + file=sys.stderr, + ) + return 1 + cmd = ["bash", str(script)] + if assume_yes: + cmd.append("--yes") + + # stdio is inherited so the user sees the bootstrap's progress live. + try: + result = subprocess.run(cmd, check=False) + except FileNotFoundError as exc: + # bash / powershell.exe not on PATH + print(f"Could not launch browser bootstrap: {exc}", file=sys.stderr) + return 1 + return result.returncode + def main(argv: list[str] | None = None) -> None: """Entry point: load env, configure logging, run the ACP agent.""" @@ -163,6 +246,11 @@ def main(argv: list[str] | None = None) -> None: if args.setup: _run_setup() return + if args.setup_browser: + rc = _run_setup_browser(assume_yes=args.assume_yes) + if rc != 0: + sys.exit(rc) + return _setup_logging() _load_env() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6b770edaf28..833172a23b9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11715,6 +11715,20 @@ Examples: action="store_true", help="Run interactive Hermes provider/model setup for ACP terminal auth", ) + acp_parser.add_argument( + "--setup-browser", + action="store_true", + help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ " + "for browser tool support (idempotent).", + ) + acp_parser.add_argument( + "--yes", + "-y", + action="store_true", + dest="assume_yes", + help="Accept all prompts (used by --setup-browser to skip the " + "~400 MB Chromium download confirmation).", + ) def cmd_acp(args): """Launch Hermes Agent as an ACP server.""" @@ -11728,6 +11742,10 @@ Examples: acp_argv.append("--check") if getattr(args, "setup", False): acp_argv.append("--setup") + if getattr(args, "setup_browser", False): + acp_argv.append("--setup-browser") + if getattr(args, "assume_yes", False): + acp_argv.append("--yes") acp_main(acp_argv) except ImportError: print("ACP dependencies not installed.", file=sys.stderr) diff --git a/pyproject.toml b/pyproject.toml index 20fecac228e..ae2fff385a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,9 +212,10 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*"] gateway = ["assets/**/*"] +acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"] [tool.setuptools.packages.find] -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "acp_adapter.*", "plugins", "plugins.*", "providers", "providers.*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 4c7e55f1d4b..81d30cd868c 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -1,6 +1,9 @@ """Tests for acp_adapter.entry startup wiring.""" +import sys + import acp +import pytest from acp_adapter import entry @@ -42,12 +45,152 @@ def test_main_setup_runs_model_configuration(monkeypatch): calls = {} def fake_hermes_main(): - import sys - calls["argv"] = sys.argv[:] monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main) + # Pretend stdin is not a TTY so the follow-up browser prompt is skipped. + # That keeps this test focused on the model-setup wiring; the + # browser-prompt path has its own test below. + monkeypatch.setattr("sys.stdin.isatty", lambda: False) entry.main(["--setup"]) assert calls["argv"][1:] == ["model"] + + +def test_main_setup_offers_browser_install_when_tty(monkeypatch): + """When stdin is a TTY and the user answers yes, model setup is followed + by a browser-tools bootstrap call.""" + monkeypatch.setattr("hermes_cli.main.main", lambda: None) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "y") + + bootstrap_calls = [] + monkeypatch.setattr( + entry, + "_run_setup_browser", + lambda assume_yes=False: bootstrap_calls.append(assume_yes) or 0, + ) + + entry.main(["--setup"]) + + assert bootstrap_calls == [False] + + +def test_main_setup_skips_browser_prompt_on_no(monkeypatch): + monkeypatch.setattr("hermes_cli.main.main", lambda: None) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "") + + called = [] + monkeypatch.setattr( + entry, + "_run_setup_browser", + lambda assume_yes=False: called.append(assume_yes) or 0, + ) + + entry.main(["--setup"]) + + assert called == [] + + +def test_main_setup_browser_invokes_bundled_script(monkeypatch): + """`hermes-acp --setup-browser` must shell out to the bundled bootstrap + script — never reimplement the install logic inline.""" + monkeypatch.setattr("platform.system", lambda: "Linux") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser"]) + + assert captured["cmd"][0] == "bash" + assert captured["cmd"][1].endswith("bootstrap_browser_tools.sh") + # --yes is NOT passed when the flag is absent. + assert "--yes" not in captured["cmd"] + + +def test_main_setup_browser_forwards_yes_flag(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser", "--yes"]) + + assert "--yes" in captured["cmd"] + + +def test_main_setup_browser_uses_powershell_on_windows(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + + captured = {} + + def fake_run(cmd, check=False): + captured["cmd"] = cmd + + class _R: + returncode = 0 + + return _R() + + monkeypatch.setattr("subprocess.run", fake_run) + + entry.main(["--setup-browser", "--yes"]) + + assert captured["cmd"][0] == "powershell.exe" + assert any(part.endswith("bootstrap_browser_tools.ps1") for part in captured["cmd"]) + assert "-Yes" in captured["cmd"] + + +def test_main_setup_browser_propagates_failure(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + + class _R: + returncode = 7 + + monkeypatch.setattr("subprocess.run", lambda cmd, check=False: _R()) + + with pytest.raises(SystemExit) as excinfo: + entry.main(["--setup-browser"]) + assert excinfo.value.code == 7 + + +def test_bootstrap_scripts_ship_with_package(): + """The package-data wiring (pyproject.toml) must include the bootstrap + scripts — otherwise `--setup-browser` 404s at runtime.""" + from pathlib import Path + + bootstrap_dir = Path(entry.__file__).resolve().parent / "bootstrap" + sh = bootstrap_dir / "bootstrap_browser_tools.sh" + ps1 = bootstrap_dir / "bootstrap_browser_tools.ps1" + + assert sh.is_file(), f"missing bundled script: {sh}" + assert ps1.is_file(), f"missing bundled script: {ps1}" + + sh_text = sh.read_text(encoding="utf-8") + ps1_text = ps1.read_text(encoding="utf-8") + + # Sanity: scripts know how to find the Hermes-managed Node prefix. + assert "HERMES_HOME" in sh_text + assert "agent-browser" in sh_text + assert "HermesHome" in ps1_text + assert "agent-browser" in ps1_text diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 92a755c9ada..6540748c889 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -78,6 +78,27 @@ hermes acp --version hermes acp --check ``` +### Browser tools (optional) + +Browser tools (`browser_navigate`, `browser_click`, etc.) depend on the +`agent-browser` npm package and Chromium, which aren't part of the Python +wheel. Install them with: + +```bash +hermes acp --setup-browser # interactive (prompts before ~400 MB download) +hermes acp --setup-browser --yes # accept the download non-interactively +``` + +This is the standalone command. The Zed registry's terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly. + +What it does: + +- Installs Node.js 22 LTS into `~/.hermes/node/` if missing +- `npm install -g agent-browser @askjo/camofox-browser` into that prefix (no sudo needed — `npm`'s `--prefix` points at the user-writable Hermes-managed Node) +- Installs Playwright Chromium, or uses a detected system Chrome/Chromium when available + +The bootstrap is idempotent — re-running it is fast and skips work that's already done. + ## Editor setup ### VS Code From 09d9724a09197b1981c318f3c51c55bc52fdfe29 Mon Sep 17 00:00:00 2001 From: Mibayy Date: Fri, 15 May 2026 01:33:49 -0700 Subject: [PATCH 183/214] feat(gateway): add SimpleX Chat platform plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimpleX Chat (https://simplex.chat) is a private, decentralised messenger with no persistent user IDs — every contact is identified by an opaque internal ID generated at connection time. This adds it as a Hermes gateway platform via the plugin system. The adapter connects to a local simplex-chat daemon via WebSocket, listens for inbound messages, and sends replies. Originally proposed in PR #2558 as a core-modifying integration; reshaped here as a self- contained plugin under plugins/platforms/simplex/ with no edits to any core file. Discovery is filesystem-based (scanned by gateway.config), and the platform identity is resolved on demand via Platform("simplex"). Plugin contract: - check_requirements() requires SIMPLEX_WS_URL AND the websockets package - validate_config() / is_connected() accept env or config.yaml input - _env_enablement() seeds PlatformConfig.extra (ws_url + home_channel) - _standalone_send() supports out-of-process cron delivery - interactive_setup() provides a stdin wizard for hermes gateway setup - register() wires the adapter into the registry with required_env, install_hint, cron_deliver_env_var, allowed_users_env, and a platform_hint for the LLM. Lazy dependency: the websockets Python package is imported inside the functions that need it. The plugin is importable and discoverable even when websockets is missing — check_requirements() simply returns False until `pip install websockets` is run. No new pyproject extras are introduced. Environment variables: SIMPLEX_WS_URL WebSocket URL of the daemon (required) SIMPLEX_ALLOWED_USERS Comma-separated allowed contact IDs SIMPLEX_ALLOW_ALL_USERS Set true to allow all contacts SIMPLEX_HOME_CHANNEL Default contact for cron delivery SIMPLEX_HOME_CHANNEL_NAME Human label for the home channel Closes #2557. --- plugins/platforms/simplex/__init__.py | 3 + plugins/platforms/simplex/adapter.py | 746 +++++++++++++++++++ plugins/platforms/simplex/plugin.yaml | 37 + tests/gateway/test_simplex_plugin.py | 347 +++++++++ website/docs/user-guide/messaging/simplex.md | 99 +++ 5 files changed, 1232 insertions(+) create mode 100644 plugins/platforms/simplex/__init__.py create mode 100644 plugins/platforms/simplex/adapter.py create mode 100644 plugins/platforms/simplex/plugin.yaml create mode 100644 tests/gateway/test_simplex_plugin.py create mode 100644 website/docs/user-guide/messaging/simplex.md diff --git a/plugins/platforms/simplex/__init__.py b/plugins/platforms/simplex/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/simplex/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py new file mode 100644 index 00000000000..b568f29bbb5 --- /dev/null +++ b/plugins/platforms/simplex/adapter.py @@ -0,0 +1,746 @@ +"""SimpleX Chat platform adapter (Hermes plugin). + +Connects to a simplex-chat daemon running in WebSocket mode. +Inbound messages arrive via a persistent WebSocket connection. +Outbound messages use the same WebSocket with JSON commands. + +This adapter ships as a Hermes platform plugin under +``plugins/platforms/simplex/``. The Hermes plugin loader scans the +directory at startup, calls ``register(ctx)``, and the platform +becomes available to ``gateway/run.py`` and ``tools/send_message_tool`` +through the registry — no edits to core files are required. + +SimpleX chat daemon setup: + simplex-chat -p 5225 # start daemon on port 5225 + # or via Docker: + # docker run -p 5225:5225 simplexchat/simplex-chat-cli -p 5225 + +Required environment variables: + SIMPLEX_WS_URL WebSocket URL of the daemon + (default: ws://127.0.0.1:5225) + +Optional environment variables: + SIMPLEX_ALLOWED_USERS Comma-separated contact IDs (allowlist) + SIMPLEX_ALLOW_ALL_USERS Set 'true' to allow all contacts + SIMPLEX_HOME_CHANNEL Default contact/group ID for cron delivery + SIMPLEX_HOME_CHANNEL_NAME Human label for the home channel + +The ``websockets`` Python package is imported lazily — the plugin is +discoverable and `hermes setup` can describe it even when websockets is +not installed. ``check_requirements()`` returns False until the package +is present, so the gateway will not attempt to instantiate the adapter. +""" + +import asyncio +import json +import logging +import os +import random +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +# Lazy import: BasePlatformAdapter and friends live in the main repo. +# Imported at module top because they're stdlib-only inside Hermes — no +# external dependency that would block the plugin from loading. +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +MAX_MESSAGE_LENGTH = 16_000 # SimpleX has no hard limit; keep chunking sane +TYPING_INTERVAL = 10.0 +WS_RETRY_DELAY_INITIAL = 2.0 +WS_RETRY_DELAY_MAX = 60.0 +HEALTH_CHECK_INTERVAL = 30.0 +HEALTH_CHECK_STALE_THRESHOLD = 120.0 + +# Correlation ID prefix for requests we send so we can ignore our own echoes. +_CORR_PREFIX = "hermes-" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_comma_list(value: str) -> List[str]: + """Split a comma-separated string into a stripped list.""" + return [v.strip() for v in value.split(",") if v.strip()] + + +def _guess_extension(data: bytes) -> str: + """Guess file extension from magic bytes.""" + if data[:4] == b"\x89PNG": + return ".png" + if data[:2] == b"\xff\xd8": + return ".jpg" + if data[:4] == b"GIF8": + return ".gif" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + if data[:4] == b"%PDF": + return ".pdf" + if len(data) >= 8 and data[4:8] == b"ftyp": + return ".mp4" + if data[:4] == b"OggS": + return ".ogg" + if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0: + return ".mp3" + return ".bin" + + +def _is_image_ext(ext: str) -> bool: + return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + + +def _is_audio_ext(ext: str) -> bool: + return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + + +# --------------------------------------------------------------------------- +# SimpleX Adapter +# --------------------------------------------------------------------------- + +class SimplexAdapter(BasePlatformAdapter): + """SimpleX Chat adapter using the simplex-chat daemon WebSocket API. + + Instantiated by the ``adapter_factory`` passed to + ``ctx.register_platform()`` in :func:`register`. + """ + + def __init__(self, config: PlatformConfig, **kwargs): + platform = Platform("simplex") + super().__init__(config=config, platform=platform) + + extra = getattr(config, "extra", {}) or {} + self.ws_url = extra.get("ws_url", "ws://127.0.0.1:5225").rstrip("/") + + # Running state + self._ws = None # websockets connection + self._ws_task: Optional[asyncio.Task] = None + self._health_task: Optional[asyncio.Task] = None + self._typing_tasks: Dict[str, asyncio.Task] = {} + self._running = False + self._last_ws_activity = 0.0 + + # Track sent correlation IDs to filter echoes + self._pending_corr_ids: set = set() + self._max_pending_corr = 200 + + logger.info("SimpleX adapter initialized: url=%s", self.ws_url) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to the simplex-chat daemon and start the WebSocket listener.""" + try: + import websockets # noqa: F401 + except ImportError: + logger.error( + "SimpleX: 'websockets' package not installed. " + "Run: pip install websockets" + ) + return False + + if not self.ws_url: + logger.error("SimpleX: SIMPLEX_WS_URL is required") + return False + + # Quick connectivity check — try to open and immediately close + try: + import websockets as _wsclient + async with _wsclient.connect(self.ws_url, open_timeout=10): + pass + except Exception as e: + logger.error("SimpleX: cannot reach daemon at %s: %s", self.ws_url, e) + return False + + self._running = True + self._last_ws_activity = time.time() + self._ws_task = asyncio.create_task(self._ws_listener()) + self._health_task = asyncio.create_task(self._health_monitor()) + + logger.info("SimpleX: connected to %s", self.ws_url) + return True + + async def disconnect(self) -> None: + """Stop WebSocket listener and clean up.""" + self._running = False + + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + + if self._health_task: + self._health_task.cancel() + try: + await self._health_task + except asyncio.CancelledError: + pass + + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() + + if self._ws: + try: + await self._ws.close() + except Exception: + pass + self._ws = None + + logger.info("SimpleX: disconnected") + + # ------------------------------------------------------------------ + # WebSocket listener + # ------------------------------------------------------------------ + + async def _ws_listener(self) -> None: + """Maintain a persistent WebSocket connection to the daemon.""" + import websockets as _wsclient + import websockets as _wsexc + + backoff = WS_RETRY_DELAY_INITIAL + + while self._running: + try: + logger.debug("SimpleX WS: connecting to %s", self.ws_url) + async with _wsclient.connect( + self.ws_url, + ping_interval=20, + ping_timeout=20, + ) as ws: + self._ws = ws + backoff = WS_RETRY_DELAY_INITIAL + self._last_ws_activity = time.time() + logger.info("SimpleX WS: connected") + + async for raw in ws: + if not self._running: + break + self._last_ws_activity = time.time() + try: + msg = json.loads(raw) + await self._handle_event(msg) + except json.JSONDecodeError: + logger.debug("SimpleX WS: invalid JSON: %.100s", raw) + except Exception: + logger.exception("SimpleX WS: error handling event") + + except asyncio.CancelledError: + break + except _wsexc.WebSocketException as e: + if self._running: + logger.warning( + "SimpleX WS: error: %s (reconnecting in %.0fs)", e, backoff + ) + except Exception as e: + if self._running: + logger.warning( + "SimpleX WS: unexpected error: %s (reconnecting in %.0fs)", + e, backoff, + ) + finally: + self._ws = None + + if self._running: + jitter = backoff * 0.2 * random.random() + await asyncio.sleep(backoff + jitter) + backoff = min(backoff * 2, WS_RETRY_DELAY_MAX) + + # ------------------------------------------------------------------ + # Health monitor + # ------------------------------------------------------------------ + + async def _health_monitor(self) -> None: + """Force reconnect if the WebSocket has been idle too long.""" + while self._running: + await asyncio.sleep(HEALTH_CHECK_INTERVAL) + if not self._running: + break + + elapsed = time.time() - self._last_ws_activity + if elapsed > HEALTH_CHECK_STALE_THRESHOLD: + logger.warning( + "SimpleX: WS idle for %.0fs, forcing reconnect", elapsed + ) + self._last_ws_activity = time.time() + if self._ws: + try: + await self._ws.close() + except Exception: + pass + + # ------------------------------------------------------------------ + # Inbound event handling + # ------------------------------------------------------------------ + + async def _handle_event(self, event: dict) -> None: + """Dispatch a daemon event to the appropriate handler.""" + resp_type = event.get("type") or event.get("resp", {}).get("type", "") + + # Filter responses to our own commands (echoes) + corr_id = event.get("corrId", "") + if corr_id and corr_id.startswith(_CORR_PREFIX): + self._pending_corr_ids.discard(corr_id) + return + + if resp_type == "newChatItem": + await self._handle_new_chat_item(event) + elif resp_type == "newChatItems": + # Batch variant — process each item + items = event.get("chatItems") or [] + for item_wrapper in items: + await self._handle_new_chat_item(item_wrapper) + # Ignore all other event types (delivery receipts, contact updates, etc.) + + async def _handle_new_chat_item(self, wrapper: dict) -> None: + """Process a single newChatItem event into a MessageEvent.""" + # The daemon wraps the chat item differently depending on version; + # normalise both layouts. + chat_info = wrapper.get("chatInfo") or wrapper.get("chat") or {} + chat_item = wrapper.get("chatItem") or wrapper.get("item") or {} + + # Only process messages (not calls, deleted items, etc.) + item_content = chat_item.get("content") or {} + msg_content = item_content.get("msgContent") or {} + if not msg_content: + return + + # Filter out messages sent by us (direction == "snd") + meta = chat_item.get("meta") or {} + direction = (meta.get("itemStatus") or {}).get("type", "") + if direction in ("sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"): + return + + # Determine chat type and IDs + chat_type_raw = chat_info.get("type", "") + is_group = chat_type_raw in ("group", "groupInfo") + + if is_group: + group_info = chat_info.get("groupInfo") or chat_info.get("group") or {} + group_id = str(group_info.get("groupId") or group_info.get("id") or "") + group_name = group_info.get("displayName") or group_info.get("groupProfile", {}).get("displayName", "") + chat_id = f"group:{group_id}" if group_id else "" + chat_name = group_name + else: + contact_info = chat_info.get("contact") or {} + contact_id = str(contact_info.get("contactId") or contact_info.get("id") or "") + contact_name = ( + contact_info.get("displayName") + or contact_info.get("localDisplayName") + or contact_id + ) + chat_id = contact_id + chat_name = contact_name + + if not chat_id: + logger.debug("SimpleX: ignoring event with no chat_id") + return + + # Sender — for groups the message includes a chatItemMember sub-object + member = chat_item.get("chatItemMember") or {} + if is_group and member: + sender_id = str(member.get("memberId") or member.get("id") or chat_id) + sender_name = ( + member.get("displayName") + or member.get("localDisplayName") + or sender_id + ) + else: + sender_id = chat_id + sender_name = chat_name + + # Extract text + text = msg_content.get("text") or "" + + # Media attachments + media_urls: List[str] = [] + media_types: List[str] = [] + file_info = chat_item.get("file") or {} + if file_info and file_info.get("fileStatus") not in ("cancelled", "error"): + file_id = file_info.get("fileId") + file_name = file_info.get("fileName", "file") + if file_id: + try: + cached = await self._fetch_file(file_id, file_name) + if cached: + ext = cached.rsplit(".", 1)[-1] + if _is_image_ext("." + ext): + media_types.append("image/" + ext.replace("jpg", "jpeg")) + elif _is_audio_ext("." + ext): + media_types.append("audio/" + ext) + else: + media_types.append("application/octet-stream") + media_urls.append(cached) + except Exception: + logger.exception("SimpleX: failed to fetch file %s", file_id) + + # Timestamp + ts_str = meta.get("itemTs") or meta.get("createdAt") or "" + try: + timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + timestamp = datetime.now(tz=timezone.utc) + + # Build source + source = self.build_source( + chat_id=chat_id, + chat_name=chat_name, + chat_type="group" if is_group else "dm", + user_id=sender_id, + user_name=sender_name, + ) + + # Message type + msg_type = MessageType.TEXT + if media_types: + if any(mt.startswith("audio/") for mt in media_types): + msg_type = MessageType.VOICE + elif any(mt.startswith("image/") for mt in media_types): + msg_type = MessageType.PHOTO + + event_obj = MessageEvent( + source=source, + text=text, + message_type=msg_type, + media_urls=media_urls, + media_types=media_types, + timestamp=timestamp, + raw_message=wrapper, + ) + + await self.handle_message(event_obj) + + async def _fetch_file(self, file_id: Any, file_name: str) -> Optional[str]: + """Ask the daemon to receive and return a file attachment.""" + # simplex-chat exposes `/api/v1/files/{fileId}` on an HTTP port + # when started with --http-port. However, the canonical WebSocket API + # does not have a direct binary download command; files are stored on + # the local filesystem after the daemon accepts them. + # + # We request acceptance first, then read from the daemon's local path. + corr_id = self._make_corr_id() + cmd = { + "corrId": corr_id, + "cmd": f"/freceive {file_id}", + } + await self._send_ws(cmd) + # The daemon will emit a chatItemUpdated event when the file lands; + # for simplicity we just wait briefly and rely on the daemon's default path. + await asyncio.sleep(2) + + # simplex-chat stores received files in ~/Downloads or a configured path. + # We try common locations. + for search_dir in ( + os.path.expanduser("~/Downloads"), + os.path.expanduser("~/.simplex/files"), + "/tmp/simplex_files", + ): + candidate = os.path.join(search_dir, file_name) + if os.path.exists(candidate): + with open(candidate, "rb") as f: + data = f.read() + ext = _guess_extension(data) + if _is_image_ext(ext): + return cache_image_from_bytes(data, ext) + elif _is_audio_ext(ext): + return cache_audio_from_bytes(data, ext) + else: + return cache_document_from_bytes(data, file_name) + return None + + # ------------------------------------------------------------------ + # Outbound messages + # ------------------------------------------------------------------ + + def _make_corr_id(self) -> str: + """Generate a unique correlation ID for a request.""" + corr_id = f"{_CORR_PREFIX}{int(time.time() * 1000)}-{random.randint(0, 9999)}" + self._pending_corr_ids.add(corr_id) + if len(self._pending_corr_ids) > self._max_pending_corr: + # Trim oldest — sets are unordered so just clear the oldest half + to_remove = list(self._pending_corr_ids)[:self._max_pending_corr // 2] + self._pending_corr_ids -= set(to_remove) + return corr_id + + async def _send_ws(self, payload: dict) -> None: + """Send a JSON payload over the WebSocket, queuing if not yet connected.""" + import websockets as _wsexc + ws = self._ws + if not ws: + logger.debug("SimpleX: WS not connected, dropping outbound command") + return + try: + await ws.send(json.dumps(payload)) + except _wsexc.ConnectionClosed: + logger.warning("SimpleX: WS closed while sending") + except Exception as e: + logger.warning("SimpleX: WS send error: %s", e) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message to a contact or group.""" + corr_id = self._make_corr_id() + + if chat_id.startswith("group:"): + group_id = chat_id[6:] + cmd_str = f"#[{group_id}] {content}" + else: + cmd_str = f"@[{chat_id}] {content}" + + payload = { + "corrId": corr_id, + "cmd": cmd_str, + } + + await self._send_ws(payload) + return SendResult(success=True) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """SimpleX does not expose a typing indicator API — no-op.""" + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image (URL) as a message with optional caption. + + SimpleX has no native ``send_image`` over the WebSocket API — file + attachments require the daemon's filesystem-backed flow which is + not driven from this adapter. Fall back to a plain text message + containing the URL and caption. + """ + text = f"{caption}\n{image_url}".strip() if caption else image_url + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def get_chat_info(self, chat_id: str) -> dict: + """Return basic chat info.""" + if chat_id.startswith("group:"): + return {"chat_id": chat_id, "type": "group", "name": chat_id[6:]} + return {"chat_id": chat_id, "type": "dm", "name": chat_id} + + +# --------------------------------------------------------------------------- +# Plugin entry-point hooks +# --------------------------------------------------------------------------- + +def check_requirements() -> bool: + """Plugin gate: require SIMPLEX_WS_URL AND the websockets package. + + Returning False keeps the platform out of ``get_connected_platforms()`` + so the gateway never instantiates the adapter when the dependency is + missing or no daemon URL is configured. + """ + if not os.getenv("SIMPLEX_WS_URL"): + return False + try: + import websockets # noqa: F401 + except ImportError: + return False + return True + + +def validate_config(config) -> bool: + """Validate that the platform config has enough info to connect.""" + extra = getattr(config, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + return bool(ws_url) + + +def is_connected(config) -> bool: + """Check whether SimpleX is configured (env or config.yaml).""" + extra = getattr(config, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + return bool(ws_url) + + +def _env_enablement() -> dict | None: + """Seed ``PlatformConfig.extra`` from env vars during gateway config load. + + Called by the platform registry's env-enablement hook BEFORE adapter + construction, so ``gateway status`` and ``get_connected_platforms()`` + reflect env-only configuration without instantiating the WebSocket + client. Returns ``None`` when SimpleX isn't minimally configured. + + The special ``home_channel`` key in the returned dict is handled by + the core hook — it becomes a proper ``HomeChannel`` dataclass on the + ``PlatformConfig`` rather than being merged into ``extra``. + """ + ws_url = os.getenv("SIMPLEX_WS_URL", "").strip() + if not ws_url: + return None + seed: dict = {"ws_url": ws_url} + home = os.getenv("SIMPLEX_HOME_CHANNEL", "").strip() + if home: + seed["home_channel"] = { + "chat_id": home, + "name": os.getenv("SIMPLEX_HOME_CHANNEL_NAME", "").strip() or home, + } + return seed + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[List[str]] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Open an ephemeral WebSocket to the daemon, send, and close. + + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway + runner is not in this process (e.g. ``hermes cron`` running as a + separate process from ``hermes gateway``). Without this hook, + ``deliver=simplex`` cron jobs fail with "No live adapter for platform". + + ``thread_id`` and ``force_document`` are accepted for signature parity + with other plugins but are not meaningful here. ``media_files`` is + accepted but only the text body is delivered — SimpleX requires the + daemon's filesystem-backed file flow which an ephemeral connection + cannot drive safely. + """ + try: + import websockets as _wsclient + except ImportError: + return {"error": "websockets not installed. Run: pip install websockets"} + + extra = getattr(pconfig, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "ws://127.0.0.1:5225") + if not ws_url: + return {"error": "SimpleX standalone send: SIMPLEX_WS_URL is required"} + + try: + if chat_id.startswith("group:"): + group_id = chat_id[6:] + cmd_str = f"#[{group_id}] {message}" + else: + cmd_str = f"@[{chat_id}] {message}" + + payload = { + "corrId": f"hermes-snd-{int(time.time() * 1000)}", + "cmd": cmd_str, + } + + async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws: + await ws.send(json.dumps(payload)) + # Give the daemon a moment to process the command before closing. + await asyncio.sleep(0.5) + + return {"success": True, "platform": "simplex", "chat_id": chat_id} + except Exception as e: + return {"error": f"SimpleX send failed: {e}"} + + +def interactive_setup() -> None: + """Minimal stdin wizard for ``hermes setup gateway`` → SimpleX. + + Prompts for the WebSocket URL and the optional allowlist / home channel. + Writes to ``~/.hermes/.env`` via ``hermes_cli.config``. + """ + print() + print("SimpleX Chat setup") + print("------------------") + print("Requirements:") + print(" 1. simplex-chat daemon running (e.g. `simplex-chat -p 5225`).") + print(" 2. Python package `websockets` installed (`pip install websockets`).") + print() + + try: + from hermes_cli.config import get_env_value, save_env_value + except ImportError: + print("hermes_cli.config not available; set SIMPLEX_* vars manually in ~/.hermes/.env") + return + + def _prompt(var: str, prompt: str, *, secret: bool = False) -> None: + existing = get_env_value(var) if callable(get_env_value) else None + suffix = " [keep current]" if existing else "" + try: + if secret: + import getpass + value = getpass.getpass(f"{prompt}{suffix}: ") + else: + value = input(f"{prompt}{suffix}: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + if value: + save_env_value(var, value) + + _prompt("SIMPLEX_WS_URL", "Daemon WebSocket URL (default ws://127.0.0.1:5225)") + _prompt("SIMPLEX_ALLOWED_USERS", "Allowed contact IDs (comma-separated; blank=skip)") + _prompt("SIMPLEX_HOME_CHANNEL", "Home channel contact/group ID (or empty)") + print("Done. Make sure the simplex-chat daemon is running before starting the gateway.") + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system at startup.""" + ctx.register_platform( + name="simplex", + label="SimpleX Chat", + adapter_factory=lambda cfg: SimplexAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["SIMPLEX_WS_URL"], + install_hint="pip install websockets # SimpleX adapter requires the websockets package", + setup_fn=interactive_setup, + # Env-driven auto-configuration: seeds PlatformConfig.extra so + # env-only setups show up in `hermes gateway status` without + # instantiating the adapter. + env_enablement_fn=_env_enablement, + # Cron home-channel delivery support — `deliver=simplex` cron jobs + # route to SIMPLEX_HOME_CHANNEL when set. + cron_deliver_env_var="SIMPLEX_HOME_CHANNEL", + # Out-of-process cron delivery. Without this hook, deliver=simplex + # cron jobs fail with "No live adapter" when cron runs separately + # from the gateway. + standalone_sender_fn=_standalone_send, + # Auth env vars for _is_user_authorized() integration + allowed_users_env="SIMPLEX_ALLOWED_USERS", + allow_all_env="SIMPLEX_ALLOW_ALL_USERS", + # SimpleX has no hard line length; we still chunk for sanity. + max_message_length=MAX_MESSAGE_LENGTH, + # Display + emoji="🔒", + # SimpleX uses opaque contact IDs only — no phone numbers or + # email addresses to redact. + pii_safe=True, + allow_update_command=True, + # LLM guidance + platform_hint=( + "You are chatting via SimpleX Chat, a private decentralised " + "messenger. Contacts are identified by opaque internal IDs, " + "not phone numbers or usernames. SimpleX supports standard " + "markdown formatting. There is no typing indicator and no " + "hard message length limit, but keep responses conversational." + ), + ) diff --git a/plugins/platforms/simplex/plugin.yaml b/plugins/platforms/simplex/plugin.yaml new file mode 100644 index 00000000000..2bb87641b63 --- /dev/null +++ b/plugins/platforms/simplex/plugin.yaml @@ -0,0 +1,37 @@ +name: simplex-platform +label: SimpleX Chat +kind: platform +version: 1.0.0 +description: > + SimpleX Chat gateway adapter for Hermes Agent. + Connects to a local simplex-chat daemon via WebSocket and relays + messages between SimpleX contacts/groups and the Hermes agent. + SimpleX is decentralised and assigns no persistent user IDs — + every contact is an opaque internal ID generated at connection + time, making it one of the most private messengers available. +author: Mibayy +# ``requires_env`` and ``optional_env`` entries are surfaced in the +# ``hermes config`` UI via the platform-plugin env var injector in +# ``hermes_cli/config.py``. +requires_env: + - name: SIMPLEX_WS_URL + description: "WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)" + prompt: "SimpleX daemon WebSocket URL" + password: false +optional_env: + - name: SIMPLEX_ALLOWED_USERS + description: "Comma-separated SimpleX contact IDs allowed to talk to the bot" + prompt: "Allowed contact IDs (comma-separated)" + password: false + - name: SIMPLEX_ALLOW_ALL_USERS + description: "Allow any contact to talk to the bot (dev only — disables allowlist)" + prompt: "Allow all contacts? (true/false)" + password: false + - name: SIMPLEX_HOME_CHANNEL + description: "Default contact/group ID for cron / notification delivery" + prompt: "Home channel contact/group ID (or empty)" + password: false + - name: SIMPLEX_HOME_CHANNEL_NAME + description: "Human label for the home channel (defaults to the ID)" + prompt: "Home channel display name (or empty)" + password: false diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py new file mode 100644 index 00000000000..0b1b1b21a85 --- /dev/null +++ b/tests/gateway/test_simplex_plugin.py @@ -0,0 +1,347 @@ +"""Tests for the SimpleX Chat platform-plugin adapter. + +Loaded via the ``_plugin_adapter_loader`` helper so this lives under +``plugin_adapter_simplex`` in ``sys.modules`` and cannot collide with +sibling platform-plugin tests on the same xdist worker. +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.gateway._plugin_adapter_loader import load_plugin_adapter + +_simplex = load_plugin_adapter("simplex") + +SimplexAdapter = _simplex.SimplexAdapter +check_requirements = _simplex.check_requirements +validate_config = _simplex.validate_config +is_connected = _simplex.is_connected +register = _simplex.register +_env_enablement = _simplex._env_enablement +_standalone_send = _simplex._standalone_send +_guess_extension = _simplex._guess_extension +_is_image_ext = _simplex._is_image_ext +_is_audio_ext = _simplex._is_audio_ext +_CORR_PREFIX = _simplex._CORR_PREFIX + + +# --------------------------------------------------------------------------- +# 1. Platform enum (plugin-discovered, not bundled) +# --------------------------------------------------------------------------- + +def test_platform_enum_resolves_via_plugin_scan(): + """The plugin filesystem scan should expose Platform("simplex").""" + from gateway.config import Platform + p = Platform("simplex") + assert p.value == "simplex" + # Identity stability — repeated lookups return the same pseudo-member + assert Platform("simplex") is p + + +# --------------------------------------------------------------------------- +# 2. check_requirements / validate_config / is_connected +# --------------------------------------------------------------------------- + +def test_check_requirements_needs_url(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + assert check_requirements() is False + + +def test_check_requirements_true_when_configured(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + # websockets is a dev dep in this repo via the test plugins; the + # check_requirements() gate also asserts the package imports. + websockets_present = True + try: + import websockets # noqa: F401 + except ImportError: + websockets_present = False + assert check_requirements() is websockets_present + + +def test_validate_config_uses_env_or_extra(): + from gateway.config import PlatformConfig + # Empty extra + no env → invalid + cfg = PlatformConfig(enabled=True) + assert validate_config(cfg) is False + # extra-only path → valid + cfg2 = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + assert validate_config(cfg2) is True + + +def test_is_connected_mirrors_validate(monkeypatch): + from gateway.config import PlatformConfig + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://x"}) + assert is_connected(cfg) is True + assert is_connected(PlatformConfig(enabled=True)) is False + + +# --------------------------------------------------------------------------- +# 3. _env_enablement seeds PlatformConfig.extra +# --------------------------------------------------------------------------- + +def test_env_enablement_none_when_unset(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + assert _env_enablement() is None + + +def test_env_enablement_seeds_ws_url(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.delenv("SIMPLEX_HOME_CHANNEL", raising=False) + seed = _env_enablement() + assert seed == {"ws_url": "ws://127.0.0.1:5225"} + + +def test_env_enablement_seeds_home_channel(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL_NAME", "Personal") + seed = _env_enablement() + assert seed["home_channel"] == {"chat_id": "42", "name": "Personal"} + + +def test_env_enablement_home_channel_defaults_name_to_id(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42") + monkeypatch.delenv("SIMPLEX_HOME_CHANNEL_NAME", raising=False) + seed = _env_enablement() + assert seed["home_channel"] == {"chat_id": "42", "name": "42"} + + +# --------------------------------------------------------------------------- +# 4. Adapter init +# --------------------------------------------------------------------------- + +def test_adapter_init_custom_url(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + assert adapter.ws_url == "ws://localhost:5225" + assert adapter._running is False + assert adapter._ws is None + + +def test_adapter_init_default_url(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True) + adapter = SimplexAdapter(cfg) + assert adapter.ws_url == "ws://127.0.0.1:5225" + + +def test_adapter_platform_identity(): + """Adapter should expose Platform("simplex") identity.""" + from gateway.config import Platform, PlatformConfig + cfg = PlatformConfig(enabled=True) + adapter = SimplexAdapter(cfg) + assert adapter.platform is Platform("simplex") + + +# --------------------------------------------------------------------------- +# 5. Helper functions (magic-byte detection) +# --------------------------------------------------------------------------- + +def test_guess_extension_png(): + assert _guess_extension(b"\x89PNG\r\n\x1a\n") == ".png" + + +def test_guess_extension_jpg(): + assert _guess_extension(b"\xff\xd8\xff\xe0") == ".jpg" + + +def test_guess_extension_ogg(): + assert _guess_extension(b"OggS\x00\x02") == ".ogg" + + +def test_guess_extension_unknown(): + assert _guess_extension(b"\x00\x01\x02\x03") == ".bin" + + +def test_is_image_ext(): + assert _is_image_ext(".png") is True + assert _is_image_ext(".webp") is True + assert _is_image_ext(".ogg") is False + + +def test_is_audio_ext(): + assert _is_audio_ext(".ogg") is True + assert _is_audio_ext(".mp3") is True + assert _is_audio_ext(".pdf") is False + + +# --------------------------------------------------------------------------- +# 6. Correlation IDs +# --------------------------------------------------------------------------- + +def test_corr_id_starts_with_prefix_and_tracks_pending(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + corr_id = adapter._make_corr_id() + assert corr_id.startswith(_CORR_PREFIX) + assert corr_id in adapter._pending_corr_ids + + +def test_corr_id_pending_set_self_trims(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + adapter._max_pending_corr = 4 + for _ in range(10): + adapter._make_corr_id() + # After many additions, the pending set should be bounded by the trim + # logic — at most one trim window above the cap. + assert len(adapter._pending_corr_ids) <= adapter._max_pending_corr + 1 + + +# --------------------------------------------------------------------------- +# 7. Outbound send (mocked WS) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_dm(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + mock_ws = AsyncMock() + adapter._ws = mock_ws + + result = await adapter.send("contact-42", "Hello, SimpleX!") + mock_ws.send.assert_called_once() + payload = json.loads(mock_ws.send.call_args[0][0]) + assert payload["cmd"] == "@[contact-42] Hello, SimpleX!" + assert payload["corrId"].startswith(_CORR_PREFIX) + assert result.success is True + + +@pytest.mark.asyncio +async def test_send_group(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + mock_ws = AsyncMock() + adapter._ws = mock_ws + + result = await adapter.send("group:grp-99", "Hello, group!") + payload = json.loads(mock_ws.send.call_args[0][0]) + assert payload["cmd"] == "#[grp-99] Hello, group!" + assert result.success is True + + +@pytest.mark.asyncio +async def test_send_when_ws_not_connected_does_not_crash(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + # No _ws assigned — _send_ws should drop quietly + result = await adapter.send("contact-42", "hi") + assert result.success is True # send() always returns success — fire-and-forget + + +# --------------------------------------------------------------------------- +# 8. Inbound: filter own-echo by corrId prefix +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_handle_event_filters_own_corr_id(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + # Pretend we sent a command with this corrId + own = adapter._make_corr_id() + handler_mock = AsyncMock() + adapter._handle_new_chat_item = handler_mock # type: ignore + + await adapter._handle_event({"corrId": own, "type": "newChatItem"}) + handler_mock.assert_not_called() + assert own not in adapter._pending_corr_ids # discarded + + +# --------------------------------------------------------------------------- +# 9. Standalone (out-of-process) send for cron +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_standalone_send_missing_websockets(monkeypatch): + """When websockets is unimportable, return a clean error dict. + + Implementation detail: the standalone path does ``import websockets`` + inside the function body. We simulate the package being absent by + pulling it out of ``sys.modules`` and pointing the finder at None. + """ + import sys + saved_websockets = sys.modules.pop("websockets", None) + saved_meta = list(sys.meta_path) + + class _Blocker: + @staticmethod + def find_spec(name, path=None, target=None): + if name == "websockets" or name.startswith("websockets."): + raise ImportError("websockets blocked for test") + return None + + sys.meta_path.insert(0, _Blocker()) + try: + pconfig = MagicMock() + pconfig.extra = {"ws_url": "ws://localhost:5225"} + result = await _standalone_send(pconfig, "contact-42", "hi") + assert isinstance(result, dict) + assert "error" in result + assert "websockets" in result["error"] + finally: + sys.meta_path[:] = saved_meta + if saved_websockets is not None: + sys.modules["websockets"] = saved_websockets + + +@pytest.mark.asyncio +async def test_standalone_send_missing_url(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + pconfig = MagicMock() + pconfig.extra = {} + # We expect the URL fallback (extra+env both empty) to be empty string, + # producing an error. We also need websockets to be importable for the + # url-check branch to be reached, so skip when it's not. + try: + import websockets.client # noqa: F401 + except ImportError: + pytest.skip("websockets not installed") + + result = await _standalone_send(pconfig, "contact-42", "hi") + assert isinstance(result, dict) + # Either error about URL or a connection attempt failure — both are valid + # signals that the standalone path requires configuration. + assert "error" in result + + +# --------------------------------------------------------------------------- +# 10. register() — plugin-side metadata +# --------------------------------------------------------------------------- + +def test_register_calls_register_platform(): + ctx = MagicMock() + register(ctx) + ctx.register_platform.assert_called_once() + kwargs = ctx.register_platform.call_args.kwargs + assert kwargs["name"] == "simplex" + assert kwargs["label"] == "SimpleX Chat" + assert kwargs["required_env"] == ["SIMPLEX_WS_URL"] + assert kwargs["allowed_users_env"] == "SIMPLEX_ALLOWED_USERS" + assert kwargs["allow_all_env"] == "SIMPLEX_ALLOW_ALL_USERS" + assert kwargs["cron_deliver_env_var"] == "SIMPLEX_HOME_CHANNEL" + assert callable(kwargs["check_fn"]) + assert callable(kwargs["validate_config"]) + assert callable(kwargs["is_connected"]) + assert callable(kwargs["env_enablement_fn"]) + assert callable(kwargs["standalone_sender_fn"]) + assert callable(kwargs["adapter_factory"]) + assert callable(kwargs["setup_fn"]) + # SimpleX uses opaque IDs only — no PII to redact. + assert kwargs["pii_safe"] is True diff --git a/website/docs/user-guide/messaging/simplex.md b/website/docs/user-guide/messaging/simplex.md new file mode 100644 index 00000000000..60853acd9f8 --- /dev/null +++ b/website/docs/user-guide/messaging/simplex.md @@ -0,0 +1,99 @@ +# SimpleX Chat + +[SimpleX Chat](https://simplex.chat/) is a private, decentralised messaging platform where users own their contacts and groups. Unlike other platforms, SimpleX assigns no persistent user IDs — every contact is identified by an opaque internal ID generated at connection time, which makes it one of the most private messengers available. + +## Prerequisites + +- The **simplex-chat** CLI installed and running as a daemon +- Python package **websockets** (`pip install websockets`) + +## Install simplex-chat + +Download the latest release from the [simplex-chat GitHub releases](https://github.com/simplex-chat/simplex-chat/releases) page, or via Docker: + +```bash +# Linux / macOS binary +curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64 -o simplex-chat +chmod +x simplex-chat + +# Or Docker +docker run -p 5225:5225 simplexchat/simplex-chat -p 5225 +``` + +## Start the daemon + +```bash +simplex-chat -p 5225 +``` + +The daemon listens on WebSocket at `ws://127.0.0.1:5225` by default. + +## Configure Hermes + +### Via setup wizard + +```bash +hermes setup gateway +``` + +Select **SimpleX Chat** and follow the prompts. + +### Via environment variables + +Add these to `~/.hermes/.env`: + +``` +SIMPLEX_WS_URL=ws://127.0.0.1:5225 +SIMPLEX_ALLOWED_USERS=, +SIMPLEX_HOME_CHANNEL= +``` + +| Variable | Required | Description | +|---|---|---| +| `SIMPLEX_WS_URL` | Yes | WebSocket URL of the simplex-chat daemon | +| `SIMPLEX_ALLOWED_USERS` | Recommended | Comma-separated contact IDs allowed to use the agent | +| `SIMPLEX_ALLOW_ALL_USERS` | Optional | Set `true` to allow every contact (use carefully) | +| `SIMPLEX_HOME_CHANNEL` | Optional | Default contact ID for cron job delivery | +| `SIMPLEX_HOME_CHANNEL_NAME` | Optional | Human label for the home channel | + +## Find your contact ID + +After starting the daemon, open a conversation with your agent contact. The contact ID will appear in session logs or via `hermes send_message action=list`. + +## Authorization + +By default **all contacts are denied**. You must either: + +1. Set `SIMPLEX_ALLOWED_USERS` to a comma-separated list of contact IDs, or +2. Use **DM pairing** — send any message to the bot and it will reply with a pairing code. Enter that code via `hermes gateway pair`. + +## Using SimpleX with cron jobs + +```python +cronjob( + action="create", + schedule="every 1h", + deliver="simplex", # uses SIMPLEX_HOME_CHANNEL + prompt="Check for alerts and summarise." +) +``` + +Or target a specific contact: + +```python +send_message(target="simplex:", message="Done!") +``` + +## Privacy notes + +- SimpleX never reveals phone numbers or email addresses — contacts use opaque IDs +- The connection between Hermes and the daemon is local WebSocket (`ws://127.0.0.1:5225`) — no data leaves your machine +- Messages are end-to-end encrypted by the SimpleX protocol before reaching the daemon + +## Troubleshooting + +**"Cannot reach daemon"** — Ensure `simplex-chat -p 5225` is running and the port matches `SIMPLEX_WS_URL`. + +**"websockets not installed"** — Run `pip install websockets`. + +**Messages not received** — Check that the contact's ID is in `SIMPLEX_ALLOWED_USERS` or approve them via DM pairing. From 47614dbfca86afd9e6cf29dbd8aa4effda0932c9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:33:59 -0700 Subject: [PATCH 184/214] chore: wire simplex docs into sidebar + AUTHOR_MAP - Adds plugins/platforms/simplex docs page to the messaging sidebar between LINE and Open WebUI. - Maps louismichalot@hotmail.com -> Mibayy in scripts/release.py so the attribution check on the salvage PR passes. --- scripts/release.py | 1 + website/sidebars.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index b0e1fda9686..7d761d4aa80 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -398,6 +398,7 @@ AUTHOR_MAP = { "Mibayy@users.noreply.github.com": "Mibayy", "mibayy@users.noreply.github.com": "Mibayy", "mibay@clawhub.io": "Mibayy", + "louismichalot@hotmail.com": "Mibayy", "135070653+sgaofen@users.noreply.github.com": "sgaofen", "lzy.dev@gmail.com": "zhiyanliu", "me@janstepanovsky.cz": "hhhonzik", diff --git a/website/sidebars.ts b/website/sidebars.ts index a2977c87eef..a8d893d6e72 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -145,6 +145,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/teams-meetings', 'user-guide/messaging/msgraph-webhook', 'user-guide/messaging/line', + 'user-guide/messaging/simplex', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], From b6e07417c5242f7a3d6af1c8d8f0173248b4253f Mon Sep 17 00:00:00 2001 From: Mibayy Date: Fri, 15 May 2026 01:39:13 -0700 Subject: [PATCH 185/214] feat(cli): show YOLO mode warning in banner and status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running with --yolo, all dangerous command approvals are bypassed. Make this state visible so users don't forget: - Banner: '⚠ YOLO mode — all approval prompts bypassed' line in red, only shown when YOLO is active. Default case is silent (no extra line, no always-on 'restricted' label). - Status bar: '⚠ YOLO' fragment appended in red (#FF4444 bold) across all three width tiers (<52, <76, ≥76) in both the plain-text fallback and the fragments builder. Closes #2663 Co-authored-by: Mibayy --- cli.py | 22 ++++++++++++++++++++-- hermes_cli/banner.py | 3 +++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index 527269aef7a..27286a3c988 100644 --- a/cli.py +++ b/cli.py @@ -3370,8 +3370,11 @@ class HermesCLI: percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] + yolo_active = bool(os.getenv("HERMES_YOLO_MODE")) if width < 52: text = f"⚕ {snapshot['model_short']} · {duration_label}" + if yolo_active: + text += " · ⚠ YOLO" return self._trim_status_bar_text(text, width) if width < 76: parts = [f"⚕ {snapshot['model_short']}", percent_label] @@ -3379,6 +3382,8 @@ class HermesCLI: if compressions: parts.append(f"🗜️ {compressions}") parts.append(duration_label) + if yolo_active: + parts.append("⚠ YOLO") return self._trim_status_bar_text(" · ".join(parts), width) if snapshot["context_length"]: @@ -3396,6 +3401,8 @@ class HermesCLI: prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: parts.append(prompt_elapsed) + if yolo_active: + parts.append("⚠ YOLO") return self._trim_status_bar_text(" │ ".join(parts), width) except Exception: return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -3412,6 +3419,7 @@ class HermesCLI: # line and produce duplicated status bar rows over long sessions. width = self._get_tui_terminal_width() duration_label = snapshot["duration"] + yolo_active = bool(os.getenv("HERMES_YOLO_MODE")) if width < 52: frags = [ @@ -3419,8 +3427,11 @@ class HermesCLI: ("class:status-bar-strong", snapshot["model_short"]), ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), ] + if yolo_active: + frags.append(("class:status-bar-dim", " · ")) + frags.append(("class:status-bar-yolo", "⚠ YOLO")) + frags.append(("class:status-bar", " ")) else: percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" @@ -3438,8 +3449,11 @@ class HermesCLI: frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), ]) + if yolo_active: + frags.append(("class:status-bar-dim", " · ")) + frags.append(("class:status-bar-yolo", "⚠ YOLO")) + frags.append(("class:status-bar", " ")) else: if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) @@ -3472,6 +3486,9 @@ class HermesCLI: if prompt_elapsed: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-dim", prompt_elapsed)) + if yolo_active: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-yolo", "⚠ YOLO")) frags.append(("class:status-bar", " ")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) @@ -13344,6 +13361,7 @@ class HermesCLI: 'status-bar-warn': 'bg:#1a1a2e #FFD700 bold', 'status-bar-bad': 'bg:#1a1a2e #FF8C00 bold', 'status-bar-critical': 'bg:#1a1a2e #FF6B6B bold', + 'status-bar-yolo': 'bg:#1a1a2e #FF4444 bold', # Bronze horizontal rules around the input area 'input-rule': '#CD7F32', # Clipboard image attachment badges diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index c4ec348ef48..036412ac072 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -470,6 +470,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str, model_short = model_short[:25] + "..." ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else "" left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]") + + if os.getenv("HERMES_YOLO_MODE"): + left_lines.append(f"[bold red]⚠ YOLO mode[/] [dim {dim}]— all approval prompts bypassed[/]") left_lines.append(f"[dim {dim}]{cwd}[/]") if session_id: left_lines.append(f"[dim {session_color}]Session: {session_id}[/]") From 4f8aaf10465566008499e65937f659a29f1ba6ab Mon Sep 17 00:00:00 2001 From: InB4DevOps Date: Fri, 15 May 2026 01:40:03 -0700 Subject: [PATCH 186/214] perf(run_agent): accumulate length-continuation prefix via list+join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n²) string concatenation of truncated_response_prefix in the length-continuation retry loop with a list + ''.join(). Functionally equivalent: same partial response on early return, same prepend on final assembly. The legacy retry path is capped at 3 iterations, so the practical wall-clock win is small, but the new idiom matches the rest of the codebase and removes a needless repeated allocation. Salvaged from PR #2717 (the run_conversation portion only — trajectory refactor dropped because it silently rewrote to
). Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- run_agent.py | 12 ++++++------ .../test_anthropic_truncation_continuation.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/run_agent.py b/run_agent.py index 325e1e13ef3..18ca03bd512 100644 --- a/run_agent.py +++ b/run_agent.py @@ -12207,7 +12207,7 @@ class AIAgent: codex_ack_continuations = 0 length_continue_retries = 0 truncated_tool_call_retries = 0 - truncated_response_prefix = "" + truncated_response_parts: List[str] = [] compression_attempts = 0 _turn_exit_reason = "unknown" # Diagnostic: why the loop ended @@ -13100,7 +13100,7 @@ class AIAgent: interim_msg = self._build_assistant_message(assistant_message, finish_reason) messages.append(interim_msg) if assistant_message.content: - truncated_response_prefix += assistant_message.content + truncated_response_parts.append(assistant_message.content) if length_continue_retries < 3: self._vprint( @@ -13121,7 +13121,7 @@ class AIAgent: restart_with_length_continuation = True break - partial_response = self._strip_think_blocks(truncated_response_prefix).strip() + partial_response = self._strip_think_blocks("".join(truncated_response_parts)).strip() self._cleanup_task_resources(effective_task_id) self._persist_session(messages, conversation_history) return { @@ -15325,9 +15325,9 @@ class AIAgent: codex_ack_continuations = 0 - if truncated_response_prefix: - final_response = truncated_response_prefix + final_response - truncated_response_prefix = "" + if truncated_response_parts: + final_response = "".join(truncated_response_parts) + final_response + truncated_response_parts = [] length_continue_retries = 0 final_response = self._strip_think_blocks(final_response).strip() diff --git a/tests/run_agent/test_anthropic_truncation_continuation.py b/tests/run_agent/test_anthropic_truncation_continuation.py index b7a263f1649..872015bc0bc 100644 --- a/tests/run_agent/test_anthropic_truncation_continuation.py +++ b/tests/run_agent/test_anthropic_truncation_continuation.py @@ -59,7 +59,7 @@ class TestTruncatedAnthropicResponseNormalization: nr = get_transport("anthropic_messages").normalize_response(response) # The continuation block checks these two attributes: - # assistant_message.content → appended to truncated_response_prefix + # assistant_message.content → appended to truncated_response_parts # assistant_message.tool_calls → guards the text-retry branch assert nr.content is not None assert "partial response" in nr.content From 647cc0bb0db4328b941008b290dcb986cdd18c54 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:40:07 -0700 Subject: [PATCH 187/214] chore(release): add AUTHOR_MAP entries for InB4DevOps --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 7d761d4aa80..8d2c6c16990 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -89,6 +89,8 @@ AUTHOR_MAP = { "zhanganzhe@tenclass.com": "luoyuctl", "51604064+luoyuctl@users.noreply.github.com": "luoyuctl", "127238744+teknium1@users.noreply.github.com": "teknium1", + "tolle.lege+github@gmail.com": "InB4DevOps", + "73686890+InB4DevOps@users.noreply.github.com": "InB4DevOps", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", "97489706+purzbeats@users.noreply.github.com": "purzbeats", "hugosequier@gmail.com": "Hugo-SEQUIER", From 5360b542447daaf0ba8d0f7c3cf0be1751ca0008 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:38:30 -0700 Subject: [PATCH 188/214] fix(providers): set User-Agent on ProviderProfile.fetch_models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalog endpoints (OpenCode Zen, etc.) sit behind a WAF that returns 403 for the default Python-urllib/ User-Agent. The generic profile-based live fetch in providers/base.py was silently failing for any such provider — falling through to the static catalog and missing newly-launched models. Set a generic 'hermes-cli/' UA on the catalog probe so every api_key provider profile benefits. Verified live against opencode-zen: before this change, profile.fetch_models() raised HTTP 403; after, it returns 42 models including gpt-5.5, gpt-5.5-pro, kimi-k2.6, glm-5.1 and the *-free variants the static catalog doesn't list. Also strip the now-stale comment in validate_requested_model() claiming opencode-zen's /models returns 404 against the HTML marketing site — the API endpoint at /zen/v1/models returns 200 with valid JSON. Surfaced by #2651 (@aashizpoudel) — fixes the same user-facing gap their PR targeted, applied at the right layer so all api_key provider profiles get live catalogs through the same code path. Co-authored-by: Aashish Poudel --- hermes_cli/models.py | 13 ++++++------- providers/base.py | 18 ++++++++++++++++++ scripts/release.py | 2 ++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 1ffede636a1..bc41132f5d5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3702,13 +3702,12 @@ def validate_requested_model( # Static-catalog fallback: when the /models probe was unreachable, # validate against the curated list from provider_model_ids() — same - # pattern as the openai-codex and minimax branches above. This fixes - # /model switches in the gateway for providers like opencode-go and - # opencode-zen whose /models endpoint returns 404 against the HTML - # marketing site. Without this block, validate_requested_model would - # reject every model on such providers, switch_model() would return - # success=False, and the gateway would never write to - # _session_model_overrides. + # pattern as the openai-codex and minimax branches above. This keeps + # /model switches working in the gateway for providers whose /models + # endpoint is temporarily unreachable or returns a non-JSON payload. + # Without this block, validate_requested_model would reject every model + # on such providers, switch_model() would return success=False, and + # the gateway would never write to _session_model_overrides. provider_label = _PROVIDER_LABELS.get(normalized, normalized) try: catalog_models = provider_model_ids(normalized) diff --git a/providers/base.py b/providers/base.py index a9e76823bb2..fa6765d103c 100644 --- a/providers/base.py +++ b/providers/base.py @@ -21,6 +21,20 @@ logger = logging.getLogger(__name__) OMIT_TEMPERATURE = object() +def _profile_user_agent() -> str: + """Return a ``hermes-cli/`` UA string, with a stable fallback. + + Used by ``ProviderProfile.fetch_models`` so the catalog probe is not + served the default ``Python-urllib/`` UA — some providers + (OpenCode Zen, etc.) sit behind a WAF that returns 403 for that. + """ + try: + from hermes_cli import __version__ as _ver # lazy: avoid layer cycle at import time + return f"hermes-cli/{_ver}" + except Exception: + return "hermes-cli" + + @dataclass class ProviderProfile: """Base provider profile — subclass or instantiate with overrides.""" @@ -153,6 +167,10 @@ class ProviderProfile: if api_key: req.add_header("Authorization", f"Bearer {api_key}") req.add_header("Accept", "application/json") + # Some providers (e.g. OpenCode Zen) sit behind a WAF that blocks + # the default ``Python-urllib/`` User-Agent. Set a generic + # hermes-cli UA so the catalog endpoint is reachable. + req.add_header("User-Agent", _profile_user_agent()) for k, v in self.default_headers.items(): req.add_header(k, v) diff --git a/scripts/release.py b/scripts/release.py index 8d2c6c16990..21587212b02 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -58,6 +58,8 @@ AUTHOR_MAP = { "altriatree@gmail.com": "TruaShamu", "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", + "mr.aashiz@gmail.com": "aashizpoudel", + "30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel", "oleksii.lisikh@gmail.com": "olisikh", "jeremy@geocaching.com": "outdoorsea", "leone.parise@gmail.com": "leoneparise", From 55f3262e788bdd7dd6adcab1d515d476b6cb9321 Mon Sep 17 00:00:00 2001 From: Animesh Mishra Date: Tue, 24 Mar 2026 07:20:51 +0000 Subject: [PATCH 189/214] fix(mcp): pre-compile env-var regex and unify interpolation Remove redundant inner `import re` and regex recompilation on every call in _interpolate_env_vars. Add module-level _ENV_VAR_PATTERN compiled once. Replace the separate _interpolate_value() in mcp_config.py (which used \w+ and would silently fail on env vars containing hyphens or dots) with the shared _ENV_VAR_PATTERN from mcp_tool.py. Remove now-unused import re. --- hermes_cli/mcp_config.py | 10 ++-------- tools/mcp_tool.py | 7 ++++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 8c12ad70758..ed9d7b5f6db 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -25,6 +25,7 @@ from hermes_cli.config import ( ) from hermes_cli.colors import Colors, color from hermes_constants import display_hermes_home +from tools.mcp_tool import _ENV_VAR_PATTERN logger = logging.getLogger(__name__) @@ -551,7 +552,7 @@ def cmd_mcp_test(args): for k, v in headers.items(): if isinstance(v, str) and ("key" in k.lower() or "auth" in k.lower()): # Mask the value - resolved = _interpolate_value(v) + resolved = _ENV_VAR_PATTERN.sub(lambda m: os.getenv(m.group(1), ""), v) if len(resolved) > 8: masked = resolved[:4] + "***" + resolved[-4:] else: @@ -581,13 +582,6 @@ def cmd_mcp_test(args): print() -def _interpolate_value(value: str) -> str: - """Resolve ``${ENV_VAR}`` references in a string.""" - def _replace(m): - return os.getenv(m.group(1), "") - return re.sub(r"\$\{(\w+)\}", _replace, value) - - # ─── hermes mcp login ──────────────────────────────────────────────────────── def cmd_mcp_login(args): diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index ee1843043dc..c2668395e5d 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -279,6 +279,11 @@ _CREDENTIAL_PATTERN = re.compile( re.IGNORECASE, ) +# Pre-compiled pattern for ${VAR_NAME} style env-var interpolation. +# Supports any non-} characters in the variable name (hyphens, dots, etc.) +# so providers like MY-VAR or my.var work correctly. +_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}") + # --------------------------------------------------------------------------- # Security helpers @@ -2104,7 +2109,7 @@ def _interpolate_env_vars(value): if isinstance(value, str): def _replace(m): return os.environ.get(m.group(1), m.group(0)) - return re.sub(r"\$\{([^}]+)\}", _replace, value) + return _ENV_VAR_PATTERN.sub(_replace, value) if isinstance(value, dict): return {k: _interpolate_env_vars(v) for k, v in value.items()} if isinstance(value, list): From 59c7cc64f0265195fa15a400411f381dd20b8b4e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:42:35 -0700 Subject: [PATCH 190/214] chore(release): add AUTHOR_MAP entry for amethystani --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 21587212b02..38392742d43 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -115,6 +115,7 @@ AUTHOR_MAP = { "oswaldb22@users.noreply.github.com": "oswaldb22", "abdielv@proton.me": "AJV20", "mason@growagainorchids.com": "masonjames", + "108541149+amethystani@users.noreply.github.com": "amethystani", "ytchen0719@gmail.com": "liquidchen", "am@studio1.tailb672fe.ts.net": "subtract0", "mike@grossmann.at": "ReqX", From c4a21d783131b04da443be6b624e20bb3b5b87b7 Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Tue, 24 Mar 2026 14:42:16 +0530 Subject: [PATCH 191/214] fix(cli): log swallowed exception in runtime model auto-detection Replaces bare `except Exception: pass` with debug-level logging so failures in local endpoint model discovery are diagnosable instead of silently hidden. --- hermes_cli/runtime_provider.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4ac21ea4568..d7c30fe5648 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -102,8 +102,10 @@ def _auto_detect_local_model(base_url: str) -> str: model_id = models[0].get("id", "") if model_id: return model_id - except Exception: - pass + except Exception as exc: + # Log instead of silently swallowing — aids debugging when + # local model auto-detection fails unexpectedly. + logger.debug("Auto-detect model from %s failed: %s", base_url, exc) return "" From 5301cc212bb72b634fcb4da7bf4380c43d4b3dca Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:46:46 -0700 Subject: [PATCH 192/214] chore(release): add AUTHOR_MAP entry for nidhi-singh02 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 38392742d43..7606d058677 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", "mr.aashiz@gmail.com": "aashizpoudel", + "nidhi2894@gmail.com": "nidhi-singh02", "30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel", "oleksii.lisikh@gmail.com": "olisikh", "jeremy@geocaching.com": "outdoorsea", From eacb398f755b6ee102e75c6d62aed5a9b253e29d Mon Sep 17 00:00:00 2001 From: Nidhi Singh Date: Fri, 15 May 2026 01:49:35 -0700 Subject: [PATCH 193/214] fix(tools): add return_exceptions to asyncio.gather in web_tools Three asyncio.gather() calls in tools/web_tools.py ran without return_exceptions=True. A single failing task (e.g. LLM rate limit on one URL) would raise out of gather() and discard every other successfully fetched/summarized result. Pass return_exceptions=True and filter BaseException entries with a warning log before unpacking. Affects: - chunk summarization gather (large web_extract pages) - firecrawl per-result LLM post-processing - tavily crawl per-result LLM post-processing Closes #2744 --- tools/web_tools.py | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/tools/web_tools.py b/tools/web_tools.py index e2743248d22..597edb0c8fd 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -586,11 +586,20 @@ async def _process_large_content_chunked( # Run all chunk summarizations in parallel tasks = [summarize_chunk(i, chunk) for i, chunk in enumerate(chunks)] - results = await asyncio.gather(*tasks) - - # Collect successful summaries in order + # Use return_exceptions=True so a single task failure does not discard + # all other successfully summarized chunks. + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Filter out exceptions, then collect successful summaries in order + successful_results = [] + for result_item in results: + if isinstance(result_item, BaseException): + logger.warning("Chunk summarization task failed: %s", result_item) + continue + successful_results.append(result_item) + summaries = [] - for chunk_idx, summary in sorted(results, key=lambda x: x[0]): + for chunk_idx, summary in sorted(successful_results, key=lambda x: x[0]): if summary: summaries.append(f"## Section {chunk_idx + 1}\n{summary}") @@ -1038,10 +1047,16 @@ async def web_extract_tool( # Run all LLM processing in parallel results_list = response.get('results', []) tasks = [process_single_result(result) for result in results_list] - processed_results = await asyncio.gather(*tasks) - + # Use return_exceptions=True so a single task failure does not + # discard all other successfully processed results. + processed_results = await asyncio.gather(*tasks, return_exceptions=True) + # Collect metrics and print results - for result, metrics, status in processed_results: + for result_item in processed_results: + if isinstance(result_item, BaseException): + logger.warning("Web result processing task failed: %s", result_item) + continue + result, metrics, status = result_item url = result.get('url', 'Unknown URL') if status == "processed": debug_call_data["compression_metrics"].append(metrics) @@ -1285,8 +1300,14 @@ async def web_crawl_tool( return result, metrics, "too_short" tasks = [_process_tavily_crawl(r) for r in response.get('results', [])] - processed_results = await asyncio.gather(*tasks) - for result, metrics, status in processed_results: + # Use return_exceptions=True so a single task failure does not + # discard all other successfully processed crawl results. + processed_results = await asyncio.gather(*tasks, return_exceptions=True) + for result_item in processed_results: + if isinstance(result_item, BaseException): + logger.warning("Tavily crawl processing task failed: %s", result_item) + continue + result, metrics, status = result_item if status == "processed": debug_call_data["compression_metrics"].append(metrics) debug_call_data["pages_processed_with_llm"] += 1 From 94bdc63ff5f5329e5f2ab0ea213c07e3a7643aff Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 01:49:40 -0700 Subject: [PATCH 194/214] chore(release): add AUTHOR_MAP entry for nidhi-singh02 PR #2751 salvage. CI requires AUTHOR_MAP coverage for all contributor commit emails. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 7606d058677..4a91762ebeb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -223,6 +223,7 @@ AUTHOR_MAP = { "74749461+yuga-hashimoto@users.noreply.github.com": "yuga-hashimoto", "xiangyong@zspace.cn": "CES4751", "harish.kukreja@gmail.com": "counterposition", + "nidhi2894@gmail.com": "nidhi-singh02", "35294173+Fearvox@users.noreply.github.com": "Fearvox", "hypnus.yuan@gmail.com": "Hypnus-Yuan", "15558128926@qq.com": "xsfX20", From 837395685099b130a502db3ec25551475fe3c7cc Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Fri, 15 May 2026 01:49:56 -0700 Subject: [PATCH 195/214] fix(slack): guard split()[0] against whitespace-only command text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends a Slack message like '/hermes ' (trailing whitespace after the slash) the legacy subcommand router hit `text.split()[0]` with a truthy-but-whitespace-only `text`. `' '.split()` returns `[]` → IndexError, blowing up the slash handler before fallthrough to `/help`. Switch to a two-step guard that materializes the parts list first and indexes only if non-empty. Salvaged from PR #2752 by @nidhi-singh02. The PR's other two hunks (`tools/file_operations.py`, `agent/anthropic_adapter.py`) are unreachable in current code — `LINTERS` is a hardcoded constant dict with no empty values, and the anthropic version-detection site is already guarded by a `result.stdout.strip()` truthy check — so only the slack hunk is taken. Closes #2745 Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- gateway/platforms/slack.py | 5 ++++- scripts/release.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index ca34ab4acac..2116b569f96 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -2785,7 +2785,10 @@ class SlackAdapter(BasePlatformAdapter): from hermes_cli.commands import slack_subcommand_map subcommand_map = slack_subcommand_map() subcommand_map["compact"] = "/compress" - first_word = text.split()[0] if text else "" + # Guard against whitespace-only text where ``text`` is truthy but + # ``text.split()`` returns ``[]`` (e.g. user sends ``/hermes ``). + parts = text.split() if text else [] + first_word = parts[0] if parts else "" if first_word in subcommand_map: rest = text[len(first_word):].strip() text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] diff --git a/scripts/release.py b/scripts/release.py index 4a91762ebeb..8a6f30802be 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1071,6 +1071,8 @@ AUTHOR_MAP = { "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) + "nidhi2894@gmail.com": "nidhi-singh02", # PR #2752 salvage (slack whitespace-only IndexError guard) + "38173192+nidhi-singh02@users.noreply.github.com": "nidhi-singh02", } From 6af99423272ed67dd1f8d88bfdf762d4e5b77a2f Mon Sep 17 00:00:00 2001 From: aydnOktay Date: Tue, 24 Mar 2026 13:45:33 +0300 Subject: [PATCH 196/214] fix(url-safety): allow only http and https schemes --- tests/tools/test_url_safety.py | 8 ++++++++ tools/url_safety.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 38d27d40af3..5a0cceb2880 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -22,6 +22,14 @@ class TestIsSafeUrl: ]): assert is_safe_url("https://example.com/image.png") is True + def test_ftp_scheme_blocked(self): + """Only http/https should be allowed for fetch tools.""" + assert is_safe_url("ftp://example.com/file.txt") is False + + def test_missing_scheme_blocked(self): + """Bare host/path should be rejected to avoid ambiguous handling.""" + assert is_safe_url("example.com/path") is False + def test_localhost_blocked(self): with patch("socket.getaddrinfo", return_value=[ (2, 1, 6, "", ("127.0.0.1", 0)), diff --git a/tools/url_safety.py b/tools/url_safety.py index 743510b2757..0f3dd597e49 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -263,6 +263,9 @@ def is_safe_url(url: str) -> bool: parsed = urlparse(url) hostname = (parsed.hostname or "").strip().lower().rstrip(".") scheme = (parsed.scheme or "").strip().lower() + if scheme not in {"http", "https"}: + logger.warning("Blocked request — unsupported URL scheme: %s", scheme or "") + return False if not hostname: return False From 13c72fb486e6bfc047bfde93e54116ea7ef7adf4 Mon Sep 17 00:00:00 2001 From: nidhi-singh02 Date: Fri, 15 May 2026 01:51:41 -0700 Subject: [PATCH 197/214] fix(tools): wrap browser provider network calls with error handling Wrap requests.post() in create_session() for browser_use, browserbase, and firecrawl providers with requests.RequestException handling. Connection timeouts and DNS resolution failures now surface as clean RuntimeError messages instead of raw requests exception tracebacks. Browser Use managed-gateway mode preserves raw exception propagation so the existing idempotency-key retry semantics keep working. Closes #2746 Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- tools/browser_providers/browser_use.py | 22 ++++++-- tools/browser_providers/browserbase.py | 77 ++++++++++++++------------ tools/browser_providers/firecrawl.py | 17 ++++-- 3 files changed, 68 insertions(+), 48 deletions(-) diff --git a/tools/browser_providers/browser_use.py b/tools/browser_providers/browser_use.py index 260249ef0bb..a1f4f425ba0 100644 --- a/tools/browser_providers/browser_use.py +++ b/tools/browser_providers/browser_use.py @@ -137,12 +137,22 @@ class BrowserUseProvider(CloudBrowserProvider): else {} ) - response = requests.post( - f"{config['base_url']}/browsers", - headers=headers, - json=payload, - timeout=30, - ) + try: + response = requests.post( + f"{config['base_url']}/browsers", + headers=headers, + json=payload, + timeout=30, + ) + except requests.RequestException as exc: + # Managed mode: propagate raw so callers can retry with the + # preserved idempotency key. Direct mode: wrap network failures + # into a clean RuntimeError for end users. + if managed_mode: + raise + raise RuntimeError( + f"Browser Use API connection failed: {exc}" + ) from exc if not response.ok: if managed_mode and not _should_preserve_pending_create_key(response): diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 5076af4c7a6..4807345214b 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -92,45 +92,50 @@ class BrowserbaseProvider(CloudBrowserProvider): "X-BB-API-Key": config["api_key"], } - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) + try: + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) - proxies_fallback = False - keepalive_fallback = False + proxies_fallback = False + keepalive_fallback = False - # Handle 402 — paid features unavailable - if response.status_code == 402: - if enable_keep_alive: - keepalive_fallback = True - logger.warning( - "keepAlive may require paid plan (402), retrying without it. " - "Sessions may timeout during long operations." - ) - session_config.pop("keepAlive", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) + # Handle 402 — paid features unavailable + if response.status_code == 402: + if enable_keep_alive: + keepalive_fallback = True + logger.warning( + "keepAlive may require paid plan (402), retrying without it. " + "Sessions may timeout during long operations." + ) + session_config.pop("keepAlive", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) - if response.status_code == 402 and enable_proxies: - proxies_fallback = True - logger.warning( - "Proxies unavailable (402), retrying without proxies. " - "Bot detection may be less effective." - ) - session_config.pop("proxies", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) + if response.status_code == 402 and enable_proxies: + proxies_fallback = True + logger.warning( + "Proxies unavailable (402), retrying without proxies. " + "Bot detection may be less effective." + ) + session_config.pop("proxies", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Browserbase API connection failed: {exc}" + ) from exc if not response.ok: raise RuntimeError( diff --git a/tools/browser_providers/firecrawl.py b/tools/browser_providers/firecrawl.py index 17001f72f1d..4a8ae82a2d2 100644 --- a/tools/browser_providers/firecrawl.py +++ b/tools/browser_providers/firecrawl.py @@ -47,12 +47,17 @@ class FirecrawlProvider(CloudBrowserProvider): body: Dict[str, object] = {"ttl": ttl} - response = requests.post( - f"{self._api_url()}/v2/browser", - headers=self._headers(), - json=body, - timeout=30, - ) + try: + response = requests.post( + f"{self._api_url()}/v2/browser", + headers=self._headers(), + json=body, + timeout=30, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Firecrawl API connection failed: {exc}" + ) from exc if not response.ok: raise RuntimeError( From 274217316e65bd7d4030b105548de30747526ec9 Mon Sep 17 00:00:00 2001 From: Steve Kelly Date: Thu, 14 May 2026 13:19:59 -0400 Subject: [PATCH 198/214] fix(codex-runtime): keep migrated root keys top-level --- hermes_cli/codex_runtime_plugin_migration.py | 40 +++++++++++++++---- .../test_codex_runtime_plugin_migration.py | 23 ++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index dd7faa09794..49b4905d5b2 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -304,6 +304,37 @@ def render_codex_toml_section( return "\n".join(out) + "\n" +def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str: + """Insert Hermes' managed Codex TOML block while keeping root keys root-scoped. + + TOML has no syntax to return to the document root after a table header. + Therefore appending a root key like `default_permissions = ...` after a + user table such as `[features]` actually creates `features.default_permissions`, + which Codex rejects. Insert the managed block before the first table header + so its root keys remain top-level, while preserving user content verbatim. + """ + if not user_text.strip(): + return managed_block + + lines = user_text.splitlines(keepends=True) + first_table_idx: Optional[int] = None + for idx, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith("["): + first_table_idx = idx + break + + if first_table_idx is None: + prefix = user_text.rstrip("\n") + return f"{prefix}\n\n{managed_block}" if prefix else managed_block + + prefix = "".join(lines[:first_table_idx]).rstrip("\n") + suffix = "".join(lines[first_table_idx:]).lstrip("\n") + if prefix: + return f"{prefix}\n\n{managed_block}\n{suffix}" + return f"{managed_block}\n{suffix}" + + def _strip_existing_managed_block(toml_text: str) -> str: """Remove any prior managed section so re-runs idempotently replace it. @@ -571,14 +602,7 @@ def migrate( 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 - ) + new_text = _insert_managed_block_at_top_level(without_managed, managed_block) else: new_text = managed_block diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index b2e27f8c97b..c283a668681 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -567,10 +567,31 @@ class TestMigrate: assert "[model]" in new_text assert 'profile = "default"' in new_text assert "[providers.openai]" in new_text - # And new MCP block appended + # And new MCP block inserted without breaking user tables assert "[mcp_servers.a]" in new_text assert MIGRATION_MARKER in new_text + def test_managed_root_keys_stay_top_level_when_config_ends_in_table(self, tmp_path): + """TOML has no explicit 'leave current table' syntax. If Hermes appends + root keys like default_permissions after a user table such as [features], + Codex parses them as features.default_permissions and rejects the config. + The managed block must therefore be inserted before the first table.""" + import tomllib + + target = tmp_path / "config.toml" + target.write_text( + 'model = "gpt-5.5"\n' + "\n" + "[features]\n" + "terminal_resize_reflow = true\n" + ) + migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + new_text = target.read_text() + parsed = tomllib.loads(new_text) + assert parsed["default_permissions"] == ":workspace" + assert "default_permissions" not in parsed["features"] + assert new_text.index(MIGRATION_MARKER) < new_text.index("[features]") + 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 From 77276070f5a1302908456734f2a5bdfe790260de Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 15 May 2026 14:45:31 +0530 Subject: [PATCH 199/214] fix(codex-runtime): de-dup [plugins.X] tables and stop leaking HERMES_HOME into config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on @steezkelly's Bug A fix (#25857, top-level default_permissions via _insert_managed_block_at_top_level) by addressing the other two config-corruption bugs described in #26250: Bug B (duplicate [plugins.X] tables) - Codex itself writes [plugins."@"] tables to config.toml when the user runs `codex plugins enable` directly, before hermes-agent's managed block exists. On the next migrate run, _query_codex_plugins() re-discovers the same plugins via plugin/list and render_codex_toml_section() re-emits them inside the managed block. Codex's strict TOML parser then rejects the duplicate table header on startup. - Add _strip_unmanaged_plugin_tables() that drops [plugins.*] tables from the user-content portion of the file. Only run it when plugin/list succeeded — if the RPC failed we can't re-emit and must preserve the user's tables. plugin/list is the source of truth when it answers. Bug C (HERMES_HOME pytest-tempdir leak into ~/.codex/config.toml) - _build_hermes_tools_mcp_entry() read HERMES_HOME directly from os.environ, so a sibling pytest's monkeypatch.setenv("HERMES_HOME", tmp_path) silently burned a transient pytest tempdir into the user's real ~/.codex/config.toml. After pytest reaped the tempdir, every codex-routed hermes-tools tool call failed silently. - Derive HERMES_HOME from get_hermes_home() (the canonical resolver that goes through the profile-aware path) and refuse to emit obvious test-tempdir paths via _looks_like_test_tempdir() as belt-and-suspenders for any other callsite that forgets to patch migrate(). - test_enable_succeeds_when_codex_present in test_codex_runtime_switch.py invoked the real migrate() (no mock), writing to Path.home() / .codex using whatever HERMES_HOME the running pytest session had set. Add the same migrate patch the other apply() tests already use, so the suite stops touching the user's real ~/.codex/config.toml. E2E verification (replicating the issue's repro): - Pre-state config.toml with user [mcp_servers.omx_team_run] + codex-installed [plugins."tasks@openai-curated"], HERMES_HOME="/private/var/folders/.../pytest-of-.../..." - On origin/main: tomllib refuses to load the result with "Cannot declare ('plugins', 'tasks@openai-curated') twice" AND the pytest-tempdir HERMES_HOME is burned in. - On this branch: file parses cleanly, default_permissions is top-level, exactly one [plugins."tasks@openai-curated"] table inside the managed block, no HERMES_HOME in the MCP env. 7 new regression tests covering all three bugs + the test-leak guard. `bash scripts/run_tests.sh tests/hermes_cli/test_codex_runtime_*.py` — 95 passed, 0 failed. Closes #26250 --- hermes_cli/codex_runtime_plugin_migration.py | 125 ++++++++++- .../test_codex_runtime_plugin_migration.py | 207 ++++++++++++++++++ tests/hermes_cli/test_codex_runtime_switch.py | 9 +- 3 files changed, 337 insertions(+), 4 deletions(-) diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index 49b4905d5b2..4b30d3ebf26 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -335,6 +335,72 @@ def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> st return f"{managed_block}\n{suffix}" +def _strip_unmanaged_plugin_tables(toml_text: str) -> str: + """Remove ``[plugins."@"]`` tables that live OUTSIDE the + managed block. + + Codex itself writes these tables when the user runs ``codex plugins enable`` + directly (i.e. before Hermes' migrate has ever touched the file). When we + later run migrate, ``_query_codex_plugins()`` reports the same plugins via + the live ``plugin/list`` RPC and we re-emit them inside the managed block. + The result without this strip is duplicate ``[plugins."X@Y"]`` table + headers — codex's strict TOML parser then refuses to load the file. + + We own the ``[plugins.*]`` namespace once migrate has run, so dropping any + pre-existing ``[plugins.*]`` tables is safe: ``plugin/list`` is the source + of truth for what's actually installed. The caller is expected to only + invoke this strip when ``plugin/list`` succeeded — otherwise we'd lose + plugins the user installed via ``codex`` without a way to re-emit them. + + Behavior: + * Lines beginning with ``[plugins.`` start a swallow region that ends at + the next non-``[plugins.`` table header or end-of-file. + * Content inside the managed block is untouched (callers should run + ``_strip_existing_managed_block`` first so the managed block has + already been removed when this runs). + """ + lines = toml_text.splitlines(keepends=True) + out: list[str] = [] + in_plugin_table = False + for line in lines: + stripped = line.lstrip() + # Only treat a line as a table header when it has the shape + # ``[...]`` (optionally followed by a comment). Multi-line array + # continuations like ``["nested"],`` also start with ``[`` after + # lstrip but are not headers — without this guard they would + # falsely flip ``in_plugin_table`` to False mid-table and leak + # array fragments into the output. + if _looks_like_table_header(stripped): + in_plugin_table = stripped.startswith("[plugins.") + if in_plugin_table: + continue + if in_plugin_table: + # Swallow keys/comments/blanks until the next table header. + continue + out.append(line) + return "".join(out) + + +def _looks_like_table_header(stripped_line: str) -> bool: + """Return True if ``stripped_line`` is a TOML table header. + + A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables), + optionally followed by a comment. The closing ``]`` (or ``]]``) must + appear on the same line, and no key-assignment ``=`` can precede it. + This distinguishes real headers from multi-line array continuation + lines that also start with ``[`` after ``lstrip()``. + """ + if not stripped_line.startswith("["): + return False + # Drop trailing comment so e.g. ``[features] # note`` still matches. + head = stripped_line.split("#", 1)[0].rstrip() + if not head.endswith("]"): + return False + # ``key = [x]`` would have an ``=`` before the bracket; a header doesn't. + bracket_idx = head.index("]") + return "=" not in head[: bracket_idx + 1] + + def _strip_existing_managed_block(toml_text: str) -> str: """Remove any prior managed section so re-runs idempotently replace it. @@ -462,6 +528,32 @@ def _query_codex_plugins( return out, None +def _looks_like_test_tempdir(path: str) -> bool: + """Heuristic: does ``path`` look like a pytest/transient tempdir? + + pytest tempdirs live under ``pytest-of-/pytest-/`` (created via + ``tmp_path`` / ``tmp_path_factory``) and are reaped between sessions. + macOS routes ``/tmp`` through ``/private/var/folders/<…>/T`` which is + what pytest's tempdir factory uses by default. If a HERMES_HOME pointing + at one of those paths is burned into ``~/.codex/config.toml``, every + codex-routed hermes-tools call fails silently once the directory is GC'd. + + We err on the side of refusing — losing a (very unlikely) real + ``~/.hermes`` symlink that happens to live under ``/private/var/folders`` + is much less harmful than silently bricking codex's tool surface. + """ + if not path: + return False + needles = ( + "pytest-of-", + "/pytest-", + "/tmp/pytest", + "/private/var/folders/", # macOS tempdir root + ) + normalized = path.lower() + return any(needle in normalized for needle in needles) + + 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 @@ -474,9 +566,22 @@ def _build_hermes_tools_mcp_entry() -> dict: 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") + # HERMES_HOME passes through IF SET so the MCP subprocess sees the same + # config / auth / sessions DB as the parent CLI. Read from os.environ + # (not get_hermes_home()) on purpose: when the env var is unset we want + # codex's subprocess to inherit whatever HERMES_HOME its launcher sets + # at runtime (systemd unit, gateway, kanban dispatcher, custom shell), + # rather than burning the migrate-time resolved default into config.toml + # — that would override the launcher's HERMES_HOME and pin the subprocess + # to the wrong profile. + # + # The pytest-tempdir guard below catches the issue #26250 Bug C scenario: + # a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would + # otherwise leak a transient pytest tempdir into the user's real + # ~/.codex/config.toml and silently brick codex once the tempdir is GC'd. + hermes_home = os.environ.get("HERMES_HOME") or "" + if hermes_home and _looks_like_test_tempdir(hermes_home): + hermes_home = "" if hermes_home: env["HERMES_HOME"] = hermes_home # PYTHONPATH passes through so a worktree-launched hermes finds the @@ -564,10 +669,16 @@ def migrate( # Discover installed Codex curated plugins. Best-effort — never blocks # the migration if codex is unreachable or the RPC fails. plugins: list[dict] = [] + plugin_query_succeeded = False 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 + else: + # plugin/list returned authoritatively (even if the list is empty). + # That means we own [plugins.*] for this re-render and can safely + # strip any pre-existing tables outside the managed block. + plugin_query_succeeded = True for p in plugins: report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}") @@ -602,6 +713,14 @@ def migrate( report.errors.append(f"could not read {target}: {exc}") return report without_managed = _strip_existing_managed_block(existing) + # Bug B: when plugin/list ran authoritatively, codex's own + # [plugins."@"] tables outside our managed block + # would survive _strip_existing_managed_block and then collide with + # the entries we re-emit inside the managed block — producing + # duplicate-table-header parse errors on codex's next startup. Drop + # those pre-existing tables since plugin/list is the source of truth. + if plugin_query_succeeded: + without_managed = _strip_unmanaged_plugin_tables(without_managed) new_text = _insert_managed_block_at_top_level(without_managed, managed_block) else: new_text = managed_block diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index c283a668681..ebdc9f9ae6b 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -8,9 +8,13 @@ import pytest from hermes_cli.codex_runtime_plugin_migration import ( MIGRATION_MARKER, + MIGRATION_END_MARKER, MigrationReport, + _build_hermes_tools_mcp_entry, _format_toml_value, + _looks_like_test_tempdir, _strip_existing_managed_block, + _strip_unmanaged_plugin_tables, _translate_one_server, migrate, render_codex_toml_section, @@ -656,3 +660,206 @@ class TestMigrate: assert "Migrated 2 MCP server(s)" in summary assert "- a" in summary assert "- b" in summary + + +# ---- Bug B: duplicate [plugins.X] tables ---- + + +class TestStripUnmanagedPluginTables: + """Regression tests for issue #26250 Bug B. + + When codex itself writes ``[plugins."@"]`` tables + (via the user running ``codex plugins enable`` directly), re-running + ``hermes codex-runtime migrate`` would re-emit them inside the managed + block and the resulting duplicate-table-header would crash codex. + """ + + def test_strips_plugin_tables_outside_managed_block(self): + text = ( + 'model = "gpt-5.5"\n' + "\n" + "[mcp_servers.user-thing]\n" + 'command = "x"\n' + "\n" + '[plugins."tasks@openai-curated"]\n' + "enabled = true\n" + "\n" + '[plugins."web-search@openai-curated"]\n' + "enabled = true\n" + "\n" + "[features]\n" + "terminal_resize_reflow = true\n" + ) + stripped = _strip_unmanaged_plugin_tables(text) + assert "[plugins." not in stripped + # Non-plugin content preserved + assert "[mcp_servers.user-thing]" in stripped + assert "[features]" in stripped + assert "terminal_resize_reflow = true" in stripped + + def test_preserves_content_when_no_plugin_tables(self): + text = ( + 'model = "gpt-5.5"\n' + "\n" + "[mcp_servers.x]\n" + 'command = "y"\n' + ) + assert _strip_unmanaged_plugin_tables(text) == text + + def test_multi_line_array_in_plugin_table_does_not_leak(self): + """A multi-line TOML array inside a [plugins.X] table whose + continuation lines start with ``[`` (e.g. nested arrays) must NOT + prematurely exit the strip region — otherwise array fragments + leak into top-level output and produce invalid TOML on the next + codex startup. Regression guard for #26260 review. + """ + text = ( + '[plugins."tasks@openai-curated"]\n' + "allowed = [\n" + ' "a",\n' + ' ["nested"],\n' + "]\n" + "[features]\n" + "x = 1\n" + ) + stripped = _strip_unmanaged_plugin_tables(text) + # Everything inside the plugin table — including the multi-line + # array's continuation lines starting with `[` — should be gone. + assert '["nested"]' not in stripped + assert "allowed" not in stripped + # Sibling user table survives intact. + assert "[features]" in stripped + assert "x = 1" in stripped + # Result is still valid TOML. + import tomllib + tomllib.loads(stripped) + + def test_migrate_dedups_codex_owned_plugin_tables(self, tmp_path, monkeypatch): + """End-to-end: codex's pre-existing [plugins.X] tables get replaced by + the managed block's re-emission rather than duplicated.""" + target = tmp_path / "config.toml" + target.write_text( + "[mcp_servers.user-server]\n" + 'command = "x"\n' + "\n" + '[plugins."tasks@openai-curated"]\n' + "enabled = true\n" + ) + + # Simulate codex's plugin/list reporting the same plugin tasks@openai-curated. + def fake_query(codex_home=None, timeout=8.0): + return ( + [{"name": "tasks", "marketplace": "openai-curated", "enabled": True}], + None, + ) + + monkeypatch.setattr( + "hermes_cli.codex_runtime_plugin_migration._query_codex_plugins", + fake_query, + ) + migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) + new_text = target.read_text() + # Only ONE [plugins."tasks@openai-curated"] header should remain — inside + # the managed block — not the original outside-the-block copy. + assert new_text.count('[plugins."tasks@openai-curated"]') == 1 + # And the surviving one is inside our managed section. + managed_start = new_text.index(MIGRATION_MARKER) + managed_end = new_text.index(MIGRATION_END_MARKER) + plugin_idx = new_text.index('[plugins."tasks@openai-curated"]') + assert managed_start < plugin_idx < managed_end + # File parses cleanly as TOML (the original duplicate-key error is gone). + import tomllib + tomllib.loads(new_text) + + def test_migrate_preserves_plugin_tables_when_plugin_list_fails(self, tmp_path, monkeypatch): + """If plugin/list RPC fails, we can't re-emit plugins authoritatively, + so we must NOT strip the user's existing [plugins.X] tables — that + would silently lose them.""" + target = tmp_path / "config.toml" + target.write_text( + '[plugins."tasks@openai-curated"]\n' + "enabled = true\n" + ) + + def fake_query(codex_home=None, timeout=8.0): + return ([], "plugin/list query failed: codex not installed") + + monkeypatch.setattr( + "hermes_cli.codex_runtime_plugin_migration._query_codex_plugins", + fake_query, + ) + migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) + new_text = target.read_text() + # User's plugin table preserved verbatim — we can't re-emit it. + assert '[plugins."tasks@openai-curated"]' in new_text + + +# ---- Bug C: HERMES_HOME tempdir leak into ~/.codex/config.toml ---- + + +class TestHermesHomeLeakGuard: + """Regression tests for issue #26250 Bug C. + + Previously ``_build_hermes_tools_mcp_entry()`` read ``HERMES_HOME`` + directly from ``os.environ``, so a pytest ``monkeypatch.setenv`` would + leak a transient tempdir path into the user's real ``~/.codex/config.toml`` + once codex spawned the hermes-tools MCP subprocess. + """ + + def test_tempdir_detector_recognizes_pytest_paths(self): + assert _looks_like_test_tempdir( + "/private/var/folders/abc/pytest-of-kshitij/pytest-137/popen-gw2/test_X/hermes_test" + ) + assert _looks_like_test_tempdir( + "/tmp/pytest-of-user/pytest-12/test_X/hermes" + ) + assert _looks_like_test_tempdir( + "/private/var/folders/zz/T/pytest-of-bob/pytest-1" + ) + + def test_tempdir_detector_accepts_real_hermes_home(self): + assert not _looks_like_test_tempdir("/Users/alice/.hermes") + assert not _looks_like_test_tempdir("/home/bob/.hermes") + assert not _looks_like_test_tempdir("/opt/hermes") + assert not _looks_like_test_tempdir("") + + def test_pytest_tempdir_not_burned_into_mcp_env(self, monkeypatch): + """The headline regression: even when HERMES_HOME points at a pytest + tempdir, _build_hermes_tools_mcp_entry() must NOT propagate it.""" + monkeypatch.setenv( + "HERMES_HOME", + "/private/var/folders/xx/pytest-of-user/pytest-99/test_x/hermes_test", + ) + entry = _build_hermes_tools_mcp_entry() + env = entry.get("env", {}) + assert "HERMES_HOME" not in env, ( + f"pytest-tempdir HERMES_HOME leaked into codex MCP entry: " + f"{env.get('HERMES_HOME')!r}" + ) + + def test_real_hermes_home_propagates(self, monkeypatch, tmp_path): + """A legitimate HERMES_HOME (not a tempdir path) DOES propagate so the + MCP subprocess sees the same config as the parent CLI.""" + # Use a path that looks real — under /Users or /home, not /var/folders. + # We can't easily create one in the test, so just use a stable path + # outside any tempdir-detector needle. The detector checks for tempdir + # markers, not for path existence. + real_path = "/Users/alice/.hermes" + monkeypatch.setenv("HERMES_HOME", real_path) + entry = _build_hermes_tools_mcp_entry() + env = entry.get("env", {}) + assert env.get("HERMES_HOME") == real_path + + def test_unset_hermes_home_omits_env_key(self, monkeypatch): + """When HERMES_HOME is unset in the environment, the MCP entry MUST + NOT bake in a resolved-default path. The codex subprocess should + inherit whatever HERMES_HOME its launcher (systemd, gateway, shell) + sets at runtime, rather than being pinned to migrate-time defaults. + Regression guard for issue #26250 follow-up review.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + entry = _build_hermes_tools_mcp_entry() + env = entry.get("env", {}) + assert "HERMES_HOME" not in env, ( + f"HERMES_HOME should not be set when env var is unset, got: " + f"{env.get('HERMES_HOME')!r}" + ) diff --git a/tests/hermes_cli/test_codex_runtime_switch.py b/tests/hermes_cli/test_codex_runtime_switch.py index 9a01543776e..7bf1a59e1e7 100644 --- a/tests/hermes_cli/test_codex_runtime_switch.py +++ b/tests/hermes_cli/test_codex_runtime_switch.py @@ -114,8 +114,15 @@ class TestApply: def persist(c): persisted.update(c) + # Patch migrate so this test doesn't reach into the user's real + # ~/.codex/config.toml. See issue #26250 Bug C — without this patch, + # crs.apply() invokes the real migrate() which writes to + # Path.home() / ".codex" using whatever HERMES_HOME the running pytest + # session has set, leaking pytest tempdir paths into the user's + # codex config. with patch.object(crs, "check_codex_binary_ok", - return_value=(True, "0.130.0")): + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate"): r = crs.apply(cfg, "codex_app_server", persist_callback=persist) assert r.success assert r.new_value == "codex_app_server" From f199cd9f84d8e59f0e50ce8d99aa9ac8adcc571a Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 15 May 2026 05:03:43 -0700 Subject: [PATCH 200/214] chore(release): map brian@dralth.com to btorresgil for #22345 salvage (#26319) PR #22345 by @btorresgil authors commits as 'Brian Conklin ' (git config carries a different name/email than the GitHub account). GitHub's commit-author mapping correctly attributes these commits to @btorresgil based on the public-key registration, but Hermes' release attribution audit reads the raw commit email, not the GitHub mapping. Without this AUTHOR_MAP entry, salvaging #22345 would fail `scripts/contributor_audit.py` strict mode at release time. Prerequisite for the langfuse trace fix salvage that cherry-picks @btorresgil's commits onto current main. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 8a6f30802be..f3df43c3fe1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -265,6 +265,7 @@ AUTHOR_MAP = { "yuxiangl490@gmail.com": "y0shua1ee", "manmit0x@gmail.com": "0xDevNinja", "stevekelly622@gmail.com": "steezkelly", + "brian@dralth.com": "btorresgil", "momowind@gmail.com": "momowind", "clockwork-codex@users.noreply.github.com": "misery-hl", "207811921+misery-hl@users.noreply.github.com": "misery-hl", From db84a78e618bf973ffc403ed2e1f8162f2591daa Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 15 May 2026 05:04:02 -0700 Subject: [PATCH 201/214] =?UTF-8?q?fix(langfuse):=20complete=20observabili?= =?UTF-8?q?ty=20fix=20=E2=80=94=20trace=20I/O,=20tool=20outputs,=20placeho?= =?UTF-8?q?lder=20credentials=20(closes=20#22342,=20#22763)=20(#26320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(langfuse): reject placeholder credentials with one-shot warning When operators leave HERMES_LANGFUSE_PUBLIC_KEY / HERMES_LANGFUSE_SECRET_KEY at a template value like 'placeholder', 'test-key', or 'your-langfuse-key', the Langfuse SDK silently accepts the credentials at construction time and drops every trace at flush time. No warning, no error — just an empty Langfuse dashboard the operator only notices hours later. Add prefix-based validation in _get_langfuse() against the documented 'pk-lf-' / 'sk-lf-' prefixes that Langfuse always issues server-side. Anything else fires a single warning naming the offending env var(s) with a log-safe value preview (full string for short placeholders so the operator knows which template they left in place; truncated for long values so a real secret pasted into the wrong field never hits the log), then short-circuits via the existing _INIT_FAILED cache so the warning fires once per process, not once per hook invocation. The check sits after the 'Langfuse is None' SDK-installed guard so hosts without the optional langfuse SDK don't see misleading 'set real keys' hints when the actionable fix is 'pip install langfuse'. Missing credentials remains the documented opt-out path and stays silent — no log noise for unconfigured installs. Fixes #22763 Fixes #23823 * fix(langfuse): use actual API request messages for generation input on_pre_llm_request previously used the messages kwarg alone, which could be None when Hermes passes the payload via request_messages, conversation_history, or user_message instead. Add _coerce_request_messages to pick the first available list across all variants, falling back to a synthetic user message. Generations now show the real outbound payload rather than an empty input. * fix(langfuse): record tool call outputs in traces Tool observations showed input (arguments) but output was always undefined. Root cause: when tool_call_id is empty, pre_tool_call stored observations under a unique time-based key that post_tool_call could never reconstruct, so every tool span was closed without output by the _finish_trace sweep. Fix pre/post matching by routing empty-tool_call_id tools through a per-name FIFO queue (pending_tools_by_name) instead of the time-based key. Tools with a tool_call_id continue to use the id-keyed dict. Also: - Preserve OpenAI-style nested function shape in serialized tool calls so Langfuse renders name/arguments correctly - Keep name + tool_call_id on role:tool messages for proper pairing - Backfill tool results onto the matching turn_tool_calls entry so the generation's tool-call record carries the result alongside arguments - Coerce request messages from whichever field the runtime provides (request_messages, messages, conversation_history, user_message) * fix(langfuse): salvage-review polish — drop dead is_first_turn, shallow-copy request_messages, real threaded FIFO test Self-review of the combined #22345 + #23831 salvage surfaced three issues worth fixing in the same PR rather than as follow-ups: 1. Drop is_first_turn from the pre_api_request hook. The boolean expression `not bool(conversation_history)` was wrong: conversation_history is reassigned to None mid-run after compression (5 sites in run_agent.py), so the value flips False -> True mid-conversation on every post-compression API call. The langfuse plugin never consumed it, so the kwarg was both misleading AND dead. 2. Replace copy.deepcopy(request_messages) with shallow list() copy. The pre_api_request hook contract discards return values (invoke_hook never writes back to api_kwargs), and the langfuse plugin's _serialize_messages already builds its own snapshot dicts via _safe_value. A deepcopy on every API call would walk every tool result and base64 image — significant overhead for no real isolation benefit. Shallow copy of the outer list protects against later mutations of api_messages without paying for the inner-dict walk. 3. Rename test_empty_tool_call_id_concurrent_fifo_order -> test_empty_tool_call_id_observations_are_fifo_within_tool_name and add a real test_threaded_post_calls_preserve_fifo_under_lock that spawns 8 threads behind a barrier to actually exercise _STATE_LOCK on the pending_tools_by_name queue. The original test was sequential and only validated Python list semantics; this one validates the lock discipline. 4. Fix stale 'Cleared by reset_cache_for_tests()' comment on _INIT_FAILED — that function does not exist. Tests reload the module via sys.modules.pop + importlib.import_module instead. Tests: 37 langfuse plugin tests pass, 658 plugin tests overall pass. --------- Co-authored-by: xxxigm Co-authored-by: Brian Conklin --- plugins/observability/langfuse/__init__.py | 168 ++++++- run_agent.py | 16 + tests/plugins/test_langfuse_plugin.py | 538 ++++++++++++++++++++- tests/run_agent/test_run_agent.py | 5 +- 4 files changed, 705 insertions(+), 22 deletions(-) diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index 9c9583261a6..8516030fb01 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -47,6 +47,7 @@ class TraceState: root_span: Any generations: Dict[str, Any] = field(default_factory=dict) tools: Dict[str, Any] = field(default_factory=dict) + pending_tools_by_name: Dict[str, list] = field(default_factory=dict) turn_tool_calls: list[dict[str, Any]] = field(default_factory=list) last_updated_at: float = field(default_factory=time.time) @@ -58,6 +59,17 @@ _READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$") _READ_FILE_HEAD_LINES = 25 _READ_FILE_TAIL_LINES = 15 +# Langfuse-issued keys always carry these prefixes (cloud or self-hosted — +# the prefix is baked into the server-side issuance flow, not a UI hint). +# Anything else (`placeholder`, `test-key`, `your-langfuse-key`, etc.) is a +# leftover template value and would cause the SDK to silently accept the +# credentials at construction time but drop every trace at flush time. +# See #23823 — the silent-failure bug this guard fixes. +_LANGFUSE_KEY_PREFIXES: Dict[str, str] = { + "HERMES_LANGFUSE_PUBLIC_KEY": "pk-lf-", + "HERMES_LANGFUSE_SECRET_KEY": "sk-lf-", +} + def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() @@ -82,10 +94,49 @@ def _debug(message: str) -> None: # Sentinel: "_get_langfuse() has tried and failed". Lets us short-circuit # every subsequent hook call without re-checking env vars or re-attempting -# SDK init. Cleared by reset_cache_for_tests(). +# SDK init. Tests clear this by reloading the module via +# ``sys.modules.pop(...) + importlib.import_module(...)`` rather than via a +# dedicated reset function. Runtime callers cannot reset the cache; if an +# operator fixes a misconfigured credential they must restart the process. _INIT_FAILED = object() +def _redact_key_preview(value: str) -> str: + """Return a brief, log-safe preview of a credential value. + + Keeps enough characters to disambiguate common placeholders + (``placeholder``, ``test-key``, ``your-key``) without echoing a + real secret in full if an operator pasted one into the wrong env + var. Used only for the once-per-process placeholder-detection + warning in :func:`_get_langfuse`. + """ + if not value: + return "" + if len(value) <= 12: + return repr(value) + return repr(value[:6] + "...") + + +def _validate_langfuse_key(env_name: str, value: str) -> Optional[str]: + """Return an error message if ``value`` is not a real Langfuse key. + + Returns ``None`` when the value matches the documented Langfuse + prefix for ``env_name``, or when no prefix is registered for the + name (in which case we trust the operator). When validation + fails the returned string is suitable for direct inclusion in a + single log line — it names the env var and shows a safe preview. + """ + expected = _LANGFUSE_KEY_PREFIXES.get(env_name, "") + if not expected: + return None + if value.startswith(expected): + return None + return ( + f"{env_name}={_redact_key_preview(value)} " + f"(expected {expected!r} prefix)" + ) + + def _get_langfuse() -> Optional[Langfuse]: """Return a cached Langfuse client, or ``None`` if unavailable. @@ -111,6 +162,33 @@ def _get_langfuse() -> Optional[Langfuse]: _LANGFUSE_CLIENT = _INIT_FAILED return None + # Reject placeholder credentials with a one-shot warning so the + # operator sees the misconfiguration instead of silently shipping a + # broken observability stack (#23823). The SDK does not validate + # keys at construction time — it queues traces in memory and only + # discovers the auth failure when the background flush thread tries + # to post them, by which point the warning is buried under whatever + # else the process is logging. Catch it here, surface it once, and + # short-circuit via the same _INIT_FAILED path as the empty case. + placeholder_issues = [ + msg + for msg in ( + _validate_langfuse_key("HERMES_LANGFUSE_PUBLIC_KEY", public_key), + _validate_langfuse_key("HERMES_LANGFUSE_SECRET_KEY", secret_key), + ) + if msg + ] + if placeholder_issues: + logger.warning( + "Langfuse plugin: credentials look like placeholders, traces will " + "NOT be emitted (%s). Set real Langfuse keys (pk-lf-... / sk-lf-...) " + "or unset HERMES_LANGFUSE_PUBLIC_KEY / HERMES_LANGFUSE_SECRET_KEY to " + "silence this warning.", + "; ".join(placeholder_issues), + ) + _LANGFUSE_CLIENT = _INIT_FAILED + return None + base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") release = _env("HERMES_LANGFUSE_RELEASE") or _env("LANGFUSE_RELEASE") @@ -328,6 +406,21 @@ def _extract_last_user_message(messages: Any) -> Any: return None +def _coerce_request_messages( + *, + request_messages: Any = None, + messages: Any = None, + conversation_history: Any = None, + user_message: Any = None, +) -> list[dict[str, Any]]: + for candidate in (request_messages, messages, conversation_history): + if isinstance(candidate, list): + return candidate + if user_message is None: + return [] + return [{"role": "user", "content": user_message}] + + def _serialize_messages(messages: Any) -> list[dict[str, Any]]: if not isinstance(messages, list): return [] @@ -343,8 +436,11 @@ def _serialize_messages(messages: Any) -> list[dict[str, Any]]: parse_json_strings=(role == "tool"), ), } - if role == "tool" and message.get("tool_call_id"): - item["tool_call_id"] = message.get("tool_call_id") + if role == "tool": + if message.get("tool_call_id"): + item["tool_call_id"] = message.get("tool_call_id") + if message.get("name"): + item["name"] = _safe_value(message.get("name")) if message.get("tool_calls"): item["tool_calls"] = _safe_value(message.get("tool_calls"), parse_json_strings=True) serialized.append(item) @@ -359,15 +455,16 @@ def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: fn = getattr(tool_call, "function", None) name = getattr(fn, "name", None) if fn else None arguments = getattr(fn, "arguments", None) if fn else None - if isinstance(arguments, str): - try: - arguments = json.loads(arguments) - except Exception: - pass + safe_arguments = _safe_value(arguments, parse_json_strings=False) serialized.append({ "id": getattr(tool_call, "id", None), + "type": getattr(tool_call, "type", None) or "function", "name": name, - "arguments": _safe_value(arguments, parse_json_strings=True), + "arguments": safe_arguments, + "function": { + "name": name, + "arguments": safe_arguments, + }, }) return serialized @@ -564,6 +661,9 @@ def _finish_trace(task_key: str, *, output: Any = None) -> None: _end_observation(observation) for observation in state.tools.values(): _end_observation(observation) + for queue in state.pending_tools_by_name.values(): + for observation in queue: + _end_observation(observation) final_output = _merge_trace_output(output, state) if final_output is not None: state.root_span.set_trace_io(output=final_output) @@ -636,6 +736,7 @@ def on_pre_llm_request( base_url: str = "", api_mode: str = "", api_call_count: int = 0, + request_messages: Any = None, messages: Any = None, turn_type: str = "user", message_count: int = 0, @@ -643,12 +744,21 @@ def on_pre_llm_request( approx_input_tokens: int = 0, request_char_count: int = 0, max_tokens: Any = None, + conversation_history: Any = None, + user_message: Any = None, **_: Any, ) -> None: client = _get_langfuse() if client is None: return + input_messages = _coerce_request_messages( + request_messages=request_messages, + messages=messages, + conversation_history=conversation_history, + user_message=user_message, + ) + task_key = _trace_key(task_id, session_id) req_key = _request_key(api_call_count) @@ -663,7 +773,7 @@ def on_pre_llm_request( provider=provider, model=model, api_mode=api_mode, - messages=messages, + messages=input_messages, client=client, ) _TRACE_STATE[task_key] = state @@ -676,7 +786,7 @@ def on_pre_llm_request( client=client, name=f"LLM call {api_call_count}", as_type="generation", - input_value=_serialize_messages(messages), + input_value=_serialize_messages(input_messages), metadata={ "provider": provider, "platform": platform, @@ -815,13 +925,12 @@ def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "" return task_key = _trace_key(task_id, session_id) - tool_key = tool_call_id or f"{tool_name}:{time.time_ns()}" with _STATE_LOCK: state = _TRACE_STATE.get(task_key) if state is None: return - state.tools[tool_key] = _start_child_observation( + observation = _start_child_observation( state, client=client, name=f"Tool: {tool_name}", @@ -829,22 +938,29 @@ def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "" input_value=_safe_value(args), metadata={"tool_name": tool_name, "tool_call_id": tool_call_id}, ) + if tool_call_id: + state.tools[tool_call_id] = observation + else: + state.pending_tools_by_name.setdefault(tool_name, []).append(observation) def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None, task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None: task_key = _trace_key(task_id, session_id) - tool_key = tool_call_id or "" observation = None with _STATE_LOCK: state = _TRACE_STATE.get(task_key) if state is None: return - if tool_key: - observation = state.tools.pop(tool_key, None) - elif state.tools: - _, observation = state.tools.popitem() + if tool_call_id: + observation = state.tools.pop(tool_call_id, None) + if observation is None: + queue = state.pending_tools_by_name.get(tool_name) + if queue: + observation = queue.pop(0) + if not queue: + state.pending_tools_by_name.pop(tool_name, None) if observation is None: return @@ -854,10 +970,24 @@ def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = No else: result_value = result result_value = _normalize_payload(result_value, tool_name=tool_name, args=args) + safe_result_value = _safe_value(result_value, parse_json_strings=True) + + # Backfill so the generation's tool_call record carries the result alongside arguments. + if tool_call_id: + with _STATE_LOCK: + state = _TRACE_STATE.get(task_key) + if state is not None: + for tool_call in reversed(state.turn_tool_calls): + if tool_call.get("id") == tool_call_id: + tool_call["output"] = safe_result_value + function_payload = tool_call.get("function") + if isinstance(function_payload, dict): + function_payload["output"] = safe_result_value + break _end_observation( observation, - output=_safe_value(result_value, parse_json_strings=True), + output=safe_result_value, metadata={"tool_name": tool_name, "args": _safe_value(args, parse_json_strings=True)}, ) diff --git a/run_agent.py b/run_agent.py index 18ca03bd512..a4df8749777 100644 --- a/run_agent.py +++ b/run_agent.py @@ -12668,16 +12668,30 @@ class AIAgent: try: from hermes_cli.plugins import invoke_hook as _invoke_hook + request_messages = api_kwargs.get("messages") + if not isinstance(request_messages, list): + request_messages = api_kwargs.get("input") + if not isinstance(request_messages, list): + request_messages = api_messages + # Shallow-copy the outer list so plugins that retain the + # reference for async snapshotting don't observe later + # mutations of api_messages. The inner dicts are not + # mutated by the agent loop, so a shallow copy is + # sufficient; a deepcopy would walk every tool result + # and base64 image on every API call. _invoke_hook( "pre_api_request", task_id=effective_task_id, session_id=self.session_id or "", + user_message=original_user_message, + conversation_history=list(messages), platform=self.platform or "", model=self.model, provider=self.provider, base_url=self.base_url, api_mode=self.api_mode, api_call_count=api_call_count, + request_messages=list(request_messages) if isinstance(request_messages, list) else [], message_count=len(api_messages), tool_count=len(self.tools or []), approx_input_tokens=approx_tokens, @@ -14582,7 +14596,9 @@ class AIAgent: finish_reason=finish_reason, message_count=len(api_messages), response_model=getattr(response, "model", None), + response=response, usage=self._usage_summary_for_api_request_hook(response), + assistant_message=assistant_message, assistant_content_chars=len(_assistant_text), assistant_tool_call_count=len(_assistant_tool_calls), ) diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index 6d9fcce38ee..313d2e94a72 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib +import logging import sys from pathlib import Path @@ -164,7 +165,542 @@ class TestHooksInert: # Each hook should just return; no exceptions. mod.on_pre_llm_call(task_id="t", session_id="s", messages=[{"role": "user", "content": "hi"}]) - mod.on_pre_llm_request(task_id="t", session_id="s", api_call_count=1, messages=[]) + mod.on_pre_llm_request(task_id="t", session_id="s", api_call_count=1, request_messages=[]) mod.on_post_llm_call(task_id="t", session_id="s", api_call_count=1) mod.on_pre_tool_call(tool_name="read_file", args={}, task_id="t", session_id="s") mod.on_post_tool_call(tool_name="read_file", args={}, result="ok", task_id="t", session_id="s") + + +# --------------------------------------------------------------------------- +# Placeholder-credential guard (#23823). +# +# Regression coverage for the silent-failure bug: when an operator leaves +# HERMES_LANGFUSE_PUBLIC_KEY / SECRET_KEY at a template value like +# "placeholder", "test-key", or "your-langfuse-key", the SDK accepts the +# credentials at construction time (it does no server-side validation +# eagerly) but drops every trace at flush time, with no signal in the +# Hermes logs. The fix in `_get_langfuse()` validates the documented +# `pk-lf-` / `sk-lf-` prefix Langfuse always issues, surfaces a one-shot +# warning naming the offending env var(s), and short-circuits via the +# same `_INIT_FAILED` path used for missing credentials so subsequent +# hook invocations don't re-log. +# --------------------------------------------------------------------------- + + +class _FakeLangfuse: + """Stand-in for the real :class:`langfuse.Langfuse` so tests don't + need the optional ``langfuse`` SDK installed. The plugin's runtime + gate refuses to proceed past ``if Langfuse is None`` when the SDK + is missing, which would short-circuit before the placeholder check + can fire. Patching ``plugin.Langfuse`` with this class lets the + placeholder validator exercise its full code path.""" + + instances: list["_FakeLangfuse"] = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + _FakeLangfuse.instances.append(self) + + +class TestPlaceholderKeyDetection: + LOGGER_NAME = "plugins.observability.langfuse" + + def _fresh_plugin(self, monkeypatch=None): + mod_name = "plugins.observability.langfuse" + sys.modules.pop(mod_name, None) + mod = importlib.import_module(mod_name) + if monkeypatch is not None: + # Pretend the SDK is installed so `_get_langfuse()` actually + # reaches the placeholder check. Real SDK calls are never + # made because the placeholder/missing-credentials paths + # return before constructing a client. + _FakeLangfuse.instances.clear() + monkeypatch.setattr(mod, "Langfuse", _FakeLangfuse, raising=False) + return mod + + @staticmethod + def _clear_env(monkeypatch): + for k in ( + "HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_SECRET_KEY", + "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", + ): + monkeypatch.delenv(k, raising=False) + + # -- helper unit tests (no SDK stub needed: these don't go through + # _get_langfuse, they exercise the pure-Python helpers directly) ------ + + def test_redact_key_preview_empty(self, monkeypatch): + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + assert plugin._redact_key_preview("") == "" + + def test_redact_key_preview_short_value_echoed(self, monkeypatch): + """Short placeholder strings are echoed in full so the operator + can see exactly which template they forgot to replace.""" + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + assert plugin._redact_key_preview("placeholder") == "'placeholder'" + assert plugin._redact_key_preview("test-key") == "'test-key'" + + def test_redact_key_preview_long_value_truncated(self, monkeypatch): + """If an operator pasted a real secret into the wrong env var the + preview must NOT echo it in full — only the leading 6 chars.""" + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + result = plugin._redact_key_preview("sk-lf-abcdefghijklmnop") + assert "abcdefghij" not in result + assert result.startswith("'sk-lf-") + assert result.endswith("...'") + + def test_validate_langfuse_key_accepts_documented_prefix(self, monkeypatch): + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + assert plugin._validate_langfuse_key( + "HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-real-public-xyz" + ) is None + assert plugin._validate_langfuse_key( + "HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz" + ) is None + + def test_validate_langfuse_key_rejects_wrong_prefix(self, monkeypatch): + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + msg = plugin._validate_langfuse_key( + "HERMES_LANGFUSE_PUBLIC_KEY", "placeholder" + ) + assert msg is not None + assert "HERMES_LANGFUSE_PUBLIC_KEY" in msg + assert "pk-lf-" in msg + + def test_validate_langfuse_key_unknown_name_passes(self, monkeypatch): + """Defensive: an env var with no registered prefix is trusted.""" + self._clear_env(monkeypatch) + plugin = self._fresh_plugin() + assert plugin._validate_langfuse_key("HERMES_LANGFUSE_BASE_URL", "anything") is None + + # -- end-to-end _get_langfuse() behaviour -------------------------------- + # These tests pass `monkeypatch` to _fresh_plugin() so the helper can + # stub out `Langfuse` (the optional SDK). Without that, every call + # short-circuits at `if Langfuse is None` before reaching the + # placeholder validator — masking the very behaviour we're testing. + + def test_placeholder_public_key_warns_and_skips(self, monkeypatch, caplog): + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + text = caplog.text + assert "HERMES_LANGFUSE_PUBLIC_KEY" in text + assert "'placeholder'" in text + assert "pk-lf-" in text + # The valid secret value must NOT appear (the var NAME does, in + # the "or unset ..." hint, but the value preview shouldn't). + assert "'sk-lf-" not in text + # Never constructed the SDK client — short-circuited before that. + assert _FakeLangfuse.instances == [] + + def test_placeholder_secret_key_warns_and_skips(self, monkeypatch, caplog): + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-real-public-xyz") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "test-key") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + text = caplog.text + assert "HERMES_LANGFUSE_SECRET_KEY" in text + assert "'test-key'" in text + assert "sk-lf-" in text + # The valid public value must NOT appear. + assert "'pk-lf-" not in text + assert _FakeLangfuse.instances == [] + + def test_both_placeholders_one_warning_with_both_keys(self, monkeypatch, caplog): + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "placeholder") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + warnings = [r for r in caplog.records if r.levelname == "WARNING" + and r.name == self.LOGGER_NAME] + assert len(warnings) == 1, ( + f"Expected a single combined warning; got {len(warnings)}:\n" + + "\n".join(r.getMessage() for r in warnings) + ) + text = warnings[0].getMessage() + assert "HERMES_LANGFUSE_PUBLIC_KEY" in text + assert "HERMES_LANGFUSE_SECRET_KEY" in text + + def test_repeated_calls_do_not_re_warn(self, monkeypatch, caplog): + """The cached ``_INIT_FAILED`` sentinel must short-circuit + subsequent calls so each hook invocation isn't a fresh log + line — otherwise a busy gateway will spam the operator's + terminal.""" + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "placeholder") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + for _ in range(15): + assert plugin._get_langfuse() is None + warnings = [r for r in caplog.records if r.levelname == "WARNING" + and r.name == self.LOGGER_NAME] + assert len(warnings) == 1, ( + f"Warning fired {len(warnings)} times across 15 calls; " + "expected 1 (cached via _INIT_FAILED)" + ) + + @pytest.mark.parametrize("placeholder", [ + "placeholder", + "test-key", + "your-langfuse-key", + "change-me", + "xxx", + "dummy-key-here", + "", + "REPLACE_ME", + ]) + def test_common_placeholders_detected(self, monkeypatch, caplog, placeholder): + """A grab-bag of values that real-world ``.env.example`` templates + use as stand-ins. Any of them in either key must trip the guard.""" + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", placeholder) + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + assert "HERMES_LANGFUSE_PUBLIC_KEY" in caplog.text + + def test_legacy_LANGFUSE_PUBLIC_KEY_also_validated(self, monkeypatch, caplog): + """The plugin reads both the canonical HERMES_-prefixed env var and + the legacy bare ``LANGFUSE_PUBLIC_KEY``. The validator must run on + whichever value ``_get_langfuse()`` actually consumed.""" + self._clear_env(monkeypatch) + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "placeholder") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + # Warning names the canonical user-facing env var (the bare + # LANGFUSE_PUBLIC_KEY is a backwards-compat alias for the + # HERMES_-prefixed one — operators set the HERMES_-prefixed one). + assert "HERMES_LANGFUSE_PUBLIC_KEY" in caplog.text + assert "'placeholder'" in caplog.text + + def test_missing_credentials_still_skip_silently(self, monkeypatch, caplog): + """Missing-creds is the documented opt-out path (operator hasn't + configured the plugin yet) — it must remain SILENT. Regression + guard against the placeholder validator accidentally running on + empty values and re-introducing log noise for unconfigured + installs.""" + self._clear_env(monkeypatch) + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + warnings = [r for r in caplog.records if r.levelname == "WARNING" + and r.name == self.LOGGER_NAME] + assert warnings == [] + + def test_sdk_not_installed_still_skips_silently(self, monkeypatch, caplog): + """If the langfuse SDK isn't installed at all, the placeholder + check should never run — there's nothing the operator can do + about a credential mismatch when the package is missing, and + re-warning here would dilute the actually-actionable SDK-missing + signal upstream. The ``Langfuse is None`` guard at the top of + ``_get_langfuse`` already handles this; this test pins that + behaviour.""" + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "placeholder") + # NO monkeypatch on Langfuse here — falls back to whatever the + # plugin imported at module load (None if SDK absent). + plugin = self._fresh_plugin() + monkeypatch.setattr(plugin, "Langfuse", None, raising=False) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + assert plugin._get_langfuse() is None + warnings = [r for r in caplog.records if r.levelname == "WARNING" + and r.name == self.LOGGER_NAME] + assert warnings == [] + + def test_valid_prefixes_do_not_trigger_placeholder_warning(self, monkeypatch, caplog): + """Real Langfuse keys (``pk-lf-…`` / ``sk-lf-…``) must pass the + guard and proceed to SDK init. We stub the SDK constructor with + a recording fake so the assertion can confirm BOTH that the + placeholder warning didn't fire AND that the client was actually + constructed — the latter is the success signal the bug report + wanted.""" + self._clear_env(monkeypatch) + monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-real-public-xyz") + monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz") + plugin = self._fresh_plugin(monkeypatch) + with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME): + client = plugin._get_langfuse() + assert isinstance(client, _FakeLangfuse) + assert client.kwargs["public_key"] == "pk-lf-real-public-xyz" + assert client.kwargs["secret_key"] == "sk-lf-real-secret-xyz" + assert "placeholders" not in caplog.text.lower(), ( + f"Valid Langfuse keys tripped the placeholder guard: {caplog.text!r}" + ) + + +class TestRequestMessageCoercion: + def test_prefers_request_messages_then_messages_then_history_then_user_message(self): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + + assert mod._coerce_request_messages( + request_messages=[{"role": "system", "content": "s"}], + messages=[{"role": "user", "content": "m"}], + conversation_history=[{"role": "user", "content": "h"}], + user_message="u", + ) == [{"role": "system", "content": "s"}] + assert mod._coerce_request_messages( + messages=[{"role": "user", "content": "m"}], + conversation_history=[{"role": "user", "content": "h"}], + user_message="u", + ) == [{"role": "user", "content": "m"}] + assert mod._coerce_request_messages( + conversation_history=[{"role": "user", "content": "h"}], + user_message="u", + ) == [{"role": "user", "content": "h"}] + assert mod._coerce_request_messages(user_message="u") == [{"role": "user", "content": "u"}] + + +class TestToolCallOutputBackfill: + def test_post_tool_call_backfills_matching_turn_tool_call_output(self, monkeypatch): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + + observation = object() + state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=None) + state.tools["call-1"] = observation + state.turn_tool_calls.append({ + "id": "call-1", + "type": "function", + "name": "web_extract", + "arguments": '{"urls": ["https://example.com"]}', + "function": { + "name": "web_extract", + "arguments": '{"urls": ["https://example.com"]}', + }, + }) + + task_key = mod._trace_key("task-1", "session-1") + monkeypatch.setitem(mod._TRACE_STATE, task_key, state) + + ended = {} + + def fake_end_observation(obs, *, output=None, metadata=None, usage_details=None, cost_details=None): + ended["observation"] = obs + ended["output"] = output + ended["metadata"] = metadata + + monkeypatch.setattr(mod, "_end_observation", fake_end_observation) + + mod.on_post_tool_call( + tool_name="web_extract", + args={"urls": ["https://example.com"]}, + result='{"results": [{"url": "https://example.com", "content": "Example Domain"}]}', + task_id="task-1", + session_id="session-1", + tool_call_id="call-1", + ) + + assert ended["observation"] is observation + assert state.turn_tool_calls[0]["output"] == ended["output"] + assert state.turn_tool_calls[0]["function"]["output"] == ended["output"] + assert state.turn_tool_calls[0]["output"] == { + "results": [{"url": "https://example.com", "content": "Example Domain"}] + } + + def test_serialize_messages_keeps_tool_name_and_call_id(self): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + + messages = [{ + "role": "tool", + "name": "web_extract", + "tool_call_id": "call-1", + "content": '{"ok": true}', + }] + + assert mod._serialize_messages(messages) == [{ + "role": "tool", + "name": "web_extract", + "tool_call_id": "call-1", + "content": {"ok": True}, + }] + + def test_serialize_tool_calls_emits_openai_style_function_shape(self): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + + class _Fn: + name = "web_extract" + arguments = '{"urls": ["https://example.com"]}' + + class _ToolCall: + id = "call-1" + type = "function" + function = _Fn() + + assert mod._serialize_tool_calls([_ToolCall()]) == [{ + "id": "call-1", + "type": "function", + "name": "web_extract", + "arguments": '{"urls": ["https://example.com"]}', + "function": { + "name": "web_extract", + "arguments": '{"urls": ["https://example.com"]}', + }, + }] + + +class TestToolObservationKeying: + """Tests for pre/post tool_call observation matching when tool_call_id is absent.""" + + def _make_mod(self): + sys.modules.pop("plugins.observability.langfuse", None) + return importlib.import_module("plugins.observability.langfuse") + + def test_empty_tool_call_id_single_tool_sets_output(self, monkeypatch): + mod = self._make_mod() + obs = object() + state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None) + state.pending_tools_by_name.setdefault("my_tool", []).append(obs) + + task_key = mod._trace_key("task-1", "sess-1") + monkeypatch.setitem(mod._TRACE_STATE, task_key, state) + + ended = {} + + def fake_end(o, *, output=None, metadata=None, **kw): + ended["obs"] = o + ended["output"] = output + + monkeypatch.setattr(mod, "_end_observation", fake_end) + + mod.on_post_tool_call( + tool_name="my_tool", + args={}, + result='{"ok": true}', + task_id="task-1", + session_id="sess-1", + tool_call_id="", + ) + + assert ended["obs"] is obs + assert ended["output"] == {"ok": True} + assert state.pending_tools_by_name.get("my_tool") is None + + def test_empty_tool_call_id_observations_are_fifo_within_tool_name(self, monkeypatch): + """Two queued observations are consumed in FIFO order so the first + post hook gets the first observation's output, not the second. + + Sequential-on-one-thread coverage; the real concurrent case is + guarded by ``_STATE_LOCK`` around every read-modify-write on + ``pending_tools_by_name`` and is exercised in + ``test_threaded_post_calls_preserve_fifo_under_lock`` below. + """ + mod = self._make_mod() + obs_a, obs_b = object(), object() + state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None) + state.pending_tools_by_name["web_extract"] = [obs_a, obs_b] + + task_key = mod._trace_key("task-1", "sess-1") + monkeypatch.setitem(mod._TRACE_STATE, task_key, state) + + calls = [] + + def fake_end(o, *, output=None, metadata=None, **kw): + calls.append((o, output)) + + monkeypatch.setattr(mod, "_end_observation", fake_end) + + mod.on_post_tool_call( + tool_name="web_extract", args={}, result='{"val": "a"}', + task_id="task-1", session_id="sess-1", tool_call_id="", + ) + mod.on_post_tool_call( + tool_name="web_extract", args={}, result='{"val": "b"}', + task_id="task-1", session_id="sess-1", tool_call_id="", + ) + + assert calls[0] == (obs_a, {"val": "a"}) + assert calls[1] == (obs_b, {"val": "b"}) + assert state.pending_tools_by_name.get("web_extract") is None + + def test_threaded_post_calls_preserve_fifo_under_lock(self, monkeypatch): + """The actual concurrency contract: when 8 threads race to drain + the pending queue, no observation is consumed twice and none is + lost. Validates ``_STATE_LOCK`` discipline, not Python list + semantics.""" + import threading + + mod = self._make_mod() + n = 8 + observations = [object() for _ in range(n)] + state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None) + state.pending_tools_by_name["web_extract"] = list(observations) + + task_key = mod._trace_key("task-thr", "sess-thr") + monkeypatch.setitem(mod._TRACE_STATE, task_key, state) + + recorded: list = [] + lock = threading.Lock() + + def fake_end(o, *, output=None, metadata=None, **kw): + with lock: + recorded.append(o) + + monkeypatch.setattr(mod, "_end_observation", fake_end) + + barrier = threading.Barrier(n) + + def worker(): + barrier.wait() + mod.on_post_tool_call( + tool_name="web_extract", args={}, result='{"ok": true}', + task_id="task-thr", session_id="sess-thr", tool_call_id="", + ) + + threads = [threading.Thread(target=worker) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Every observation was consumed exactly once; queue is empty. + assert len(recorded) == n + assert set(map(id, recorded)) == set(map(id, observations)) + assert state.pending_tools_by_name.get("web_extract") is None + + def test_explicit_tool_call_id_uses_tools_dict(self, monkeypatch): + """When tool_call_id is present, pending_tools_by_name is not touched.""" + mod = self._make_mod() + obs = object() + state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None) + state.tools["call-99"] = obs + + task_key = mod._trace_key("task-1", "sess-1") + monkeypatch.setitem(mod._TRACE_STATE, task_key, state) + + ended = {} + + def fake_end(o, *, output=None, metadata=None, **kw): + ended["obs"] = o + ended["output"] = output + + monkeypatch.setattr(mod, "_end_observation", fake_end) + + mod.on_post_tool_call( + tool_name="my_tool", args={}, result='{"status": "done"}', + task_id="task-1", session_id="sess-1", tool_call_id="call-99", + ) + + assert ended["obs"] is obs + assert ended["output"] == {"status": "done"} + assert not state.tools + diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index dadb7b31cce..c493f91509a 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2524,8 +2524,9 @@ class TestRunConversation: assert [call["api_call_count"] for call in pre_request_calls] == [1, 2] assert [call["api_call_count"] for call in post_request_calls] == [1, 2] assert all(call["session_id"] == agent.session_id for call in pre_request_calls) - assert all("message_count" in c and "messages" not in c for c in pre_request_calls) - assert all("usage" in c and "response" not in c for c in post_request_calls) + assert all("message_count" in c and isinstance(c.get("request_messages"), list) for c in pre_request_calls) + assert any(msg.get("role") == "user" and msg.get("content") == "search something" for msg in pre_request_calls[0]["request_messages"]) + assert all("usage" in c and "response" in c and "assistant_message" in c for c in post_request_calls) def test_content_with_tool_calls_stays_silent_for_non_cli_quiet_mode(self, agent): self._setup_agent(agent) From d5416284f11ccbc735c8357f0ab35ce5f683ccc3 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 15 May 2026 19:31:00 +0530 Subject: [PATCH 202/214] fix(tui): autonomous background process completion notifications (#26071) (#26327) * feat(process-registry): add format_process_notification shared helper * feat(process-registry): add drain_notifications method * refactor(cli): use shared drain_notifications and format_process_notification * feat(tui): add background notification poller for completion_queue * feat(tui): wire notification poller into session init/finalize * refactor(tui): add post-turn drain using shared helper as safety net --- cli.py | 59 +--------- tests/test_tui_gateway_server.py | 155 +++++++++++++++++++++++++++ tests/tools/test_process_registry.py | 135 +++++++++++++++++++++++ tools/process_registry.py | 58 ++++++++++ tui_gateway/server.py | 134 +++++++++++++++++++++++ 5 files changed, 486 insertions(+), 55 deletions(-) diff --git a/cli.py b/cli.py index 27286a3c988..50e7a8c8ce9 100644 --- a/cli.py +++ b/cli.py @@ -1965,43 +1965,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: return resolved -def _format_process_notification(evt: dict) -> "str | None": - """Format a process notification event into a [IMPORTANT: ...] message. - Handles both completion events (notify_on_complete) and watch pattern - match events from the unified completion_queue. - """ - evt_type = evt.get("type", "completion") - _sid = evt.get("session_id", "unknown") - _cmd = evt.get("command", "unknown") - - if evt_type == "watch_disabled": - return f"[IMPORTANT: {evt.get('message', '')}]" - - if evt_type == "watch_match": - _pat = evt.get("pattern", "?") - _out = evt.get("output", "") - _sup = evt.get("suppressed", 0) - text = ( - f"[IMPORTANT: Background process {_sid} matched " - f"watch pattern \"{_pat}\".\n" - f"Command: {_cmd}\n" - f"Matched output:\n{_out}" - ) - if _sup: - text += f"\n({_sup} earlier matches were suppressed by rate limit)" - text += "]" - return text - - # Default: completion event - _exit = evt.get("exit_code", "?") - _out = evt.get("output", "") - return ( - f"[IMPORTANT: Background process {_sid} completed " - f"(exit code {_exit}).\n" - f"Command: {_cmd}\n" - f"Output:\n{_out}]" - ) def _detect_file_drop(user_input: str) -> "dict | None": @@ -13518,16 +13482,8 @@ class HermesCLI: # and watch pattern matches) while agent is idle. try: from tools.process_registry import process_registry - if not process_registry.completion_queue.empty(): - evt = process_registry.completion_queue.get_nowait() - # Skip if the agent already consumed this via wait/poll/log - _evt_sid = evt.get("session_id", "") - if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): - pass # already delivered via tool result - else: - _synth = _format_process_notification(evt) - if _synth: - self._pending_input.put(_synth) + for _evt, _synth in process_registry.drain_notifications(): + self._pending_input.put(_synth) except Exception: pass continue @@ -13635,15 +13591,8 @@ class HermesCLI: # that arrived while the agent was running. try: from tools.process_registry import process_registry - while not process_registry.completion_queue.empty(): - evt = process_registry.completion_queue.get_nowait() - # Skip if the agent already consumed this via wait/poll/log - _evt_sid = evt.get("session_id", "") - if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): - continue # already delivered via tool result - _synth = _format_process_notification(evt) - if _synth: - self._pending_input.put(_synth) + for _evt, _synth in process_registry.drain_notifications(): + self._pending_input.put(_synth) except Exception: pass # Non-fatal — don't break the main loop diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 64a154bb9a7..0d5bad8e875 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4649,3 +4649,158 @@ def test_config_show_displays_nested_max_turns(monkeypatch): ) assert ["Max Turns", "120"] in agent_rows + + +def test_notification_poller_delivers_completion(monkeypatch): + """Poller picks up completion events and triggers agent turns.""" + from tools.process_registry import process_registry + + turns = [] + emitted = [] + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + turns.append(prompt) + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + def start(self): + self._target() + + sess = _session(agent=_Agent()) + server._sessions["sid_poll"] = sess + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + + # Clear queue + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + process_registry._completion_consumed.discard("proc_poller_test") + + stop = threading.Event() + + # Put event on queue, then immediately signal stop so the poller + # runs exactly one iteration. + process_registry.completion_queue.put({ + "type": "completion", + "session_id": "proc_poller_test", + "command": "echo hello", + "exit_code": 0, + "output": "hello", + }) + stop.set() + + try: + server._notification_poller_loop(stop, "sid_poll", sess) + + # Should have emitted a status.update with kind=process + status_calls = [a for a in emitted if a[0] == "status.update"] + assert len(status_calls) >= 1 + assert status_calls[0][2]["kind"] == "process" + + # Should have triggered an agent turn + assert len(turns) == 1 + assert "[IMPORTANT: Background process proc_poller_test completed" in turns[0] + finally: + server._sessions.pop("sid_poll", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_notification_poller_skips_consumed(monkeypatch): + """Already-consumed completions are not dispatched by the poller.""" + from tools.process_registry import process_registry + + turns = [] + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + turns.append(prompt) + return {"final_response": "ok", "messages": []} + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + def start(self): + self._target() + + sess = _session(agent=_Agent()) + server._sessions["sid_skip"] = sess + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *a, **kw: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + process_registry._completion_consumed.add("proc_already_done") + process_registry.completion_queue.put({ + "type": "completion", + "session_id": "proc_already_done", + "command": "echo x", + "exit_code": 0, + "output": "x", + }) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_skip", sess) + assert len(turns) == 0 + finally: + server._sessions.pop("sid_skip", None) + process_registry._completion_consumed.discard("proc_already_done") + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_notification_poller_requeues_when_busy(monkeypatch): + """When the agent is busy, the poller requeues the event.""" + from tools.process_registry import process_registry + + emitted = [] + + sess = _session(running=True) # agent is busy + server._sessions["sid_busy"] = sess + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + process_registry._completion_consumed.discard("proc_busy_test") + + evt = { + "type": "completion", + "session_id": "proc_busy_test", + "command": "make build", + "exit_code": 0, + "output": "ok", + } + process_registry.completion_queue.put(evt) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_busy", sess) + + # Status update was emitted (user sees it) + status_calls = [a for a in emitted if a[0] == "status.update"] + assert len(status_calls) == 1 + + # Event was requeued (agent was busy, no turn triggered) + assert not process_registry.completion_queue.empty() + requeued = process_registry.completion_queue.get_nowait() + assert requeued["session_id"] == "proc_busy_test" + finally: + server._sessions.pop("sid_busy", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index f438b637e28..46c29bb9d09 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -865,3 +865,138 @@ class TestProcessToolHandler: from tools.process_registry import _handle_process result = json.loads(_handle_process({"action": "unknown_action"})) assert "error" in result + + +# ========================================================================= +# format_process_notification + drain_notifications (shared helpers) +# ========================================================================= + +from tools.process_registry import format_process_notification + + +def test_format_completion_event(): + evt = { + "type": "completion", + "session_id": "proc_abc", + "command": "sleep 5", + "exit_code": 0, + "output": "done", + } + result = format_process_notification(evt) + assert "[IMPORTANT: Background process proc_abc completed" in result + assert "exit code 0" in result + assert "Command: sleep 5" in result + assert "Output:\ndone]" in result + + +def test_format_watch_match_event(): + evt = { + "type": "watch_match", + "session_id": "proc_xyz", + "command": "tail -f log", + "pattern": "ERROR", + "output": "ERROR: disk full", + "suppressed": 0, + } + result = format_process_notification(evt) + assert 'watch pattern "ERROR"' in result + assert "Matched output:\nERROR: disk full" in result + + +def test_format_watch_match_with_suppressed(): + evt = { + "type": "watch_match", + "session_id": "proc_xyz", + "command": "tail -f log", + "pattern": "WARN", + "output": "WARN: low mem", + "suppressed": 3, + } + result = format_process_notification(evt) + assert "3 earlier matches were suppressed" in result + + +def test_format_watch_disabled_event(): + evt = { + "type": "watch_disabled", + "message": "Watch disabled for proc_xyz: too many matches", + } + result = format_process_notification(evt) + assert "[IMPORTANT: Watch disabled for proc_xyz" in result + + +def test_format_returns_none_for_empty_event(): + evt = {} + result = format_process_notification(evt) + assert result is not None + assert "unknown" in result + + +def test_drain_notifications_returns_pending_events(): + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + process_registry.completion_queue.put({ + "type": "completion", + "session_id": "proc_drain1", + "command": "echo hi", + "exit_code": 0, + "output": "hi", + }) + process_registry.completion_queue.put({ + "type": "watch_match", + "session_id": "proc_drain2", + "command": "tail -f x", + "pattern": "ERR", + "output": "ERR found", + "suppressed": 0, + }) + + try: + results = process_registry.drain_notifications() + assert len(results) == 2 + assert results[0][0]["session_id"] == "proc_drain1" + assert "proc_drain1 completed" in results[0][1] + assert results[1][0]["session_id"] == "proc_drain2" + assert "watch pattern" in results[1][1] + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + process_registry._completion_consumed.discard("proc_drain1") + process_registry._completion_consumed.discard("proc_drain2") + + +def test_drain_notifications_skips_consumed(): + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + process_registry._completion_consumed.add("proc_consumed") + process_registry.completion_queue.put({ + "type": "completion", + "session_id": "proc_consumed", + "command": "echo done", + "exit_code": 0, + "output": "done", + }) + + try: + results = process_registry.drain_notifications() + assert len(results) == 0 + finally: + process_registry._completion_consumed.discard("proc_consumed") + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_empty_queue(): + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + results = process_registry.drain_notifications() + assert results == [] diff --git a/tools/process_registry.py b/tools/process_registry.py index 405abc04a3c..184939adf75 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -826,6 +826,26 @@ class ProcessRegistry: """Check if a completion notification was already consumed via wait/poll/log.""" return session_id in self._completion_consumed + def drain_notifications(self) -> "list[tuple[dict, str]]": + """Pop all pending notification events and return formatted pairs. + + Returns a list of (raw_event, formatted_text) tuples. + Skips completion events that were already consumed via wait/poll/log. + """ + results = [] + while not self.completion_queue.empty(): + try: + evt = self.completion_queue.get_nowait() + except Exception: + break + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and self.is_completion_consumed(_evt_sid): + continue + text = format_process_notification(evt) + if text: + results.append((evt, text)) + return results + def get(self, session_id: str) -> Optional[ProcessSession]: """Get a session by ID (running or finished).""" with self._lock: @@ -1388,6 +1408,44 @@ class ProcessRegistry: process_registry = ProcessRegistry() +def format_process_notification(evt: dict) -> "str | None": + """Format a process notification event into a [IMPORTANT: ...] message. + + Handles completion events (notify_on_complete), watch pattern matches, + and watch disabled events from the unified completion_queue. + """ + evt_type = evt.get("type", "completion") + _sid = evt.get("session_id", "unknown") + _cmd = evt.get("command", "unknown") + + if evt_type == "watch_disabled": + return f"[IMPORTANT: {evt.get('message', '')}]" + + if evt_type == "watch_match": + _pat = evt.get("pattern", "?") + _out = evt.get("output", "") + _sup = evt.get("suppressed", 0) + text = ( + f"[IMPORTANT: Background process {_sid} matched " + f"watch pattern \"{_pat}\".\n" + f"Command: {_cmd}\n" + f"Matched output:\n{_out}" + ) + if _sup: + text += f"\n({_sup} earlier matches were suppressed by rate limit)" + text += "]" + return text + + _exit = evt.get("exit_code", "?") + _out = evt.get("output", "") + return ( + f"[IMPORTANT: Background process {_sid} completed " + f"(exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + + # --------------------------------------------------------------------------- # Registry -- the "process" tool schema + handler # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 230387ce23b..4a9bc2b6590 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -287,6 +287,9 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No if not session or session.get("_finalized"): return session["_finalized"] = True + stop_event = session.get("_notif_stop") + if stop_event is not None: + stop_event.set() agent = session.get("agent") lock = session.get("history_lock") @@ -579,6 +582,7 @@ def _start_agent_build(sid: str, session: dict) -> None: pass _wire_callbacks(sid) + _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) info = _session_info(agent) @@ -1955,6 +1959,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): # session startup resilient). pass _wire_callbacks(sid) + _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) _notify_session_boundary("on_session_reset", key) _emit("session.info", sid, _session_info(agent)) @@ -3027,6 +3032,105 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "streaming"}) +def _notification_poller_loop( + stop_event: threading.Event, sid: str, session: dict +) -> None: + """Poll completion_queue and dispatch notifications autonomously. + + Runs in a daemon thread started by _init_session(). Emits a + status.update (kind=process) for user visibility, then chains an + agent turn via _run_prompt_submit if the session is idle. + + NOTE: The completion_queue is global (one per process). If multiple + TUI sessions coexist, whichever poller wakes first grabs the event, + even if the process was started by a different session. This matches + CLI/gateway behavior (single session per process). + """ + from tools.process_registry import process_registry, format_process_notification + + while not stop_event.is_set() and not session.get("_finalized"): + try: + evt = process_registry.completion_queue.get(timeout=0.5) + except Exception: + continue + + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + continue + + text = format_process_notification(evt) + if not text: + continue + + _emit("status.update", sid, {"kind": "process", "text": text}) + + with session["history_lock"]: + if session.get("running"): + process_registry.completion_queue.put(evt) + continue + session["running"] = True + + rid = f"__notif__{int(time.time() * 1000)}" + try: + _emit("message.start", sid) + _run_prompt_submit(rid, sid, session, text) + except Exception as exc: + print( + f"[tui_gateway] notification poller dispatch failed: " + f"{type(exc).__name__}: {exc}", + file=sys.stderr, + ) + with session["history_lock"]: + session["running"] = False + + # Drain any remaining events after stop signal (process all pending + # before exiting so nothing is lost on shutdown). + while not process_registry.completion_queue.empty(): + try: + evt = process_registry.completion_queue.get_nowait() + except Exception: + break + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + continue + text = format_process_notification(evt) + if not text: + continue + + _emit("status.update", sid, {"kind": "process", "text": text}) + + with session["history_lock"]: + if session.get("running"): + process_registry.completion_queue.put(evt) + break + session["running"] = True + + rid = f"__notif__{int(time.time() * 1000)}" + try: + _emit("message.start", sid) + _run_prompt_submit(rid, sid, session, text) + except Exception as exc: + print( + f"[tui_gateway] notification poller dispatch failed: " + f"{type(exc).__name__}: {exc}", + file=sys.stderr, + ) + with session["history_lock"]: + session["running"] = False + + +def _start_notification_poller(sid: str, session: dict) -> threading.Event: + """Start the background notification poller for a TUI session.""" + stop = threading.Event() + t = threading.Thread( + target=_notification_poller_loop, + args=(stop, sid, session), + daemon=True, + ) + t.start() + return stop + + def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: with session["history_lock"]: history = list(session["history"]) @@ -3385,6 +3489,36 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: with session["history_lock"]: session["running"] = False + # Drain completion notifications that arrived during this turn. + # The background poller handles between-turn delivery; this is + # the safety net for events that arrived mid-turn. + try: + from tools.process_registry import process_registry + + for _evt, synth in process_registry.drain_notifications(): + with session["history_lock"]: + if session.get("running"): + process_registry.completion_queue.put(_evt) + break + session["running"] = True + try: + _emit("message.start", sid) + _run_prompt_submit(rid, sid, session, synth) + except Exception as _n_exc: + print( + f"[tui_gateway] completion notification dispatch failed: " + f"{type(_n_exc).__name__}: {_n_exc}", + file=sys.stderr, + ) + with session["history_lock"]: + session["running"] = False + except Exception as _drain_exc: + print( + f"[tui_gateway] completion queue drain failed: " + f"{type(_drain_exc).__name__}: {_drain_exc}", + file=sys.stderr, + ) + threading.Thread(target=run, daemon=True).start() From 9fb40e6a3d6338b6a6a616010de7a16672148924 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 15 May 2026 07:41:50 -0700 Subject: [PATCH 203/214] fix(tui): restrict fast-echo bypass to ASCII so Vietnamese/CJK/IME input renders correctly (#26011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): restrict fast-echo bypass to ASCII so Vietnamese/CJK/IME input renders correctly The composer's fast-echo path (canFastAppend / canFastBackspace) writes characters straight to stdout to skip an Ink re-render on the hot typing path. The previous guard only checked 'stringWidth(text) === text.length', which lets a lot of non-ASCII through: - Vietnamese precomposed letters (ề, ắ, ờ, ự, ...) report width 1 and length 1, but a Vietnamese Telex / IME stack produces them across multiple keystrokes; the intermediate composition state must be drawn by Ink so the rendered cell, the stored value, and the cursor column stay in lockstep when the final commit replaces the preview. - NFD combining marks (U+0300..U+036F) are zero-width but length 1, so even a passing equality lets them slip and silently desync the cell column. - CJK/East-Asian wide and emoji rejected only because their length differs, but the boundary was shape-shaped, not intent-shaped. User-visible bug from the original report: Example: eê noiói nge neène -> the bypass committed the IME preview char before the diacritic replaced it, leaving doubled letters on screen. Fix: gate fast-echo on pure printable ASCII (0x20-0x7e). The performance-critical English typing path is unchanged; everything else goes through the normal Ink render path so layout stays accurate. Also extracts the shape preconditions as pure exported helpers (canFastAppendShape / canFastBackspaceShape) so the regression matrix is testable without spinning up a TextInput. Tests: ui-tui/src/__tests__/textInputFastEcho.test.ts adds 20 cases covering ASCII still works, Vietnamese precomposed + NFD, CJK, emoji, NBSP / Latin-1, ANSI / control bytes, multi-line, and end-of-line preconditions. Verified RED on the previous guard (11 of 20 fail) and GREEN on the new guard. Refs: #5221, #7443, #17602, #17603 (similar wide-char rendering bugs). * docs(tui): clarify Vietnamese char terminology in regression comment Address Copilot review: 'single byte width' implied UTF-8 byte semantics, but the relevant property is JS code units (`text.length === 1`) and display width (`stringWidth === 1`). Reworded to match. --- .../src/__tests__/textInputFastEcho.test.ts | 136 ++++++++++++++++++ ui-tui/src/components/textInput.tsx | 101 ++++++++++--- 2 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputFastEcho.test.ts diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts new file mode 100644 index 00000000000..7f246f19f21 --- /dev/null +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' + +import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js' + +// The fast-echo path bypasses Ink and writes characters directly to stdout +// for the common case of typing plain English at the end of the line. These +// tests pin the shape preconditions that make that bypass safe. +// +// Regression intent: any non-ASCII text — Vietnamese precomposed letters +// (one grapheme, `text.length === 1`, `stringWidth === 1`, but produced +// via IME composition across multiple keystrokes), combining marks +// (zero width), CJK (double width), emoji (variable width), or anything +// that could be produced by an in-flight IME composition — must NOT +// take the bypass. Closes: +// - "TUI is experiencing font errors when using Unicode to type Vietnamese" +// - #5221 TUI input box renders incorrectly for CJK / East-Asian wide +// - #7443 CLI TUI renders and deletes Chinese characters incorrectly +// - #17602 / #17603 Chinese text scattering / ghosting + +describe('canFastAppendShape', () => { + const COLS = 40 + + it('accepts plain ASCII appended at end of single-line input', () => { + expect(canFastAppendShape('hello', 5, 'x', COLS, 5)).toBe(true) + expect(canFastAppendShape('hello', 5, ' world', COLS, 5)).toBe(true) + }) + + it('rejects when cursor is not at end of line', () => { + expect(canFastAppendShape('hello', 3, 'x', COLS, 5)).toBe(false) + }) + + it('rejects when current is empty (placeholder render path needed)', () => { + expect(canFastAppendShape('', 0, 'x', COLS, 0)).toBe(false) + }) + + it('rejects when current contains a newline (multi-line layout)', () => { + expect(canFastAppendShape('hi\nthere', 8, 'x', COLS, 5)).toBe(false) + }) + + it('rejects when appending would hit the wrap column', () => { + // Reaching cols on append must trigger a wrap, which the bypass + // cannot draw. Stay strictly below cols. + expect(canFastAppendShape('hello', 5, 'x', 6, 5)).toBe(false) + }) + + // -- Regression coverage: Vietnamese / combining marks / IME -- + + it('rejects Vietnamese precomposed letter ề (U+1EC1) — IME composition path', () => { + // 'ề' is one grapheme, length 1, width 1, but Vietnamese Telex/IME + // produces it via a multi-key composition. Fast-echo would commit the + // intermediate state to stdout and desync once the final commit + // arrives. + expect(canFastAppendShape('hello', 5, 'ề', COLS, 5)).toBe(false) + }) + + it('rejects Vietnamese tone marks ă, ơ, ư (Latin-Extended-A/B)', () => { + for (const ch of ['ă', 'ắ', 'ơ', 'ờ', 'ư', 'ự']) { + expect(canFastAppendShape('hello', 5, ch, COLS, 5)).toBe(false) + } + }) + + it('rejects NFD combining marks (U+0300 grave, U+0301 acute, U+0302 circumflex)', () => { + // Decomposed Vietnamese: 'e' + combining circumflex + combining grave + // = 'ề'. Each combining mark is zero-width but length 1; without the + // ASCII guard the second/third keypress would be fast-echoed and + // desync the cell column. + expect(canFastAppendShape('hello', 5, '\u0300', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, '\u0301', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, '\u0302', COLS, 5)).toBe(false) + }) + + it('rejects CJK (East-Asian wide) characters', () => { + expect(canFastAppendShape('hello', 5, '你', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, '日本', COLS, 5)).toBe(false) + }) + + it('rejects emoji', () => { + expect(canFastAppendShape('hello', 5, '🙂', COLS, 5)).toBe(false) + }) + + it('rejects ANSI-bearing or control text', () => { + expect(canFastAppendShape('hello', 5, '\x1b[31m', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, '\t', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, '\x7f', COLS, 5)).toBe(false) + }) + + it('rejects NBSP and Latin-1 letters that would change the line shape', () => { + expect(canFastAppendShape('hello', 5, '\u00a0', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, 'é', COLS, 5)).toBe(false) + expect(canFastAppendShape('hello', 5, 'ñ', COLS, 5)).toBe(false) + }) +}) + +describe('canFastBackspaceShape', () => { + it('accepts deleting the last ASCII char', () => { + expect(canFastBackspaceShape('hello', 5)).toBe(true) + }) + + it('rejects when cursor is not at end', () => { + expect(canFastBackspaceShape('hello', 3)).toBe(false) + }) + + it('rejects when there is nothing to delete', () => { + expect(canFastBackspaceShape('', 0)).toBe(false) + expect(canFastBackspaceShape('hello', 0)).toBe(false) + }) + + it('rejects when value contains a newline', () => { + expect(canFastBackspaceShape('hi\nthere', 8)).toBe(false) + }) + + it('rejects deleting Vietnamese precomposed letter ề', () => { + // The "\b \b" shortcut clears one terminal cell; that's fine for a + // 1-cell ASCII char but if the previous grapheme is a Vietnamese + // letter that the IME may still be holding open, we want Ink to + // re-render so composition state stays consistent. + expect(canFastBackspaceShape('helloề', 'helloề'.length)).toBe(false) + }) + + it('rejects deleting a CJK character (2 cells)', () => { + expect(canFastBackspaceShape('hi你', 'hi你'.length)).toBe(false) + }) + + it('rejects deleting a NFD-composed grapheme with combining marks', () => { + // 'e' + U+0302 (circumflex) + U+0300 (grave) — final grapheme is one + // cluster but the previous-grapheme slice is multi-codepoint. Width + // is 1 but the bypass would be unsafe because the rendered cell + // already contained the combined glyph. + const s = 'hello' + 'e\u0302\u0300' + expect(canFastBackspaceShape(s, s.length)).toBe(false) + }) + + it('rejects deleting an emoji', () => { + expect(canFastBackspaceShape('hi🙂', 'hi🙂'.length)).toBe(false) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 0c63ceb93c8..91e109fa366 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -179,6 +179,84 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number { export { offsetFromPosition } +const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/ + +/** + * Pure shape-only precondition for the fast-echo append path. + * + * The fast-echo path bypasses Ink's renderer and writes text directly to + * stdout, so the stored value, the rendered terminal cells, and the cursor + * column must all stay in sync without any layout work. We only allow it + * when the inserted text is pure printable ASCII so that: + * + * - `text.length` matches the number of grapheme clusters (no combining + * marks, no surrogate pairs, no precomposed CJK / Latin-Extended + * letters that an IME might still be holding open as a composition), + * - terminal width is exactly 1 cell per character (no East-Asian wide, + * no zero-width, no ambiguous-width fonts), + * - input methods (Vietnamese Telex, IME, dead-keys) cannot leak + * intermediate composition bytes through the bypass before the final + * commit arrives — those always go through the normal Ink render path + * and stay layout-accurate (closes #5221, #7443, #17602/#17603). + * + * We deliberately do NOT just check `stringWidth(text) === text.length`: + * Vietnamese precomposed letters like "ề" (U+1EC1) report width 1 and + * length 1 but are still produced by IME compositions and must not be + * fast-echoed. + */ +export function canFastAppendShape( + current: string, + cursor: number, + text: string, + columns: number, + currentLineWidth: number +): boolean { + if (cursor !== current.length) { + return false + } + + if (current.length === 0) { + return false + } + + if (current.includes('\n')) { + return false + } + + if (!ASCII_PRINTABLE_RE.test(text)) { + return false + } + + return currentLineWidth + text.length < Math.max(1, columns) +} + +/** + * Pure shape-only precondition for the fast-echo backspace path. + * + * Same reasoning as canFastAppendShape — only allow the direct + * "\b \b" stdout shortcut when the deleted grapheme is pure printable + * ASCII. Anything else (combining marks, IME compositions, wide chars, + * tabs, ANSI fragments) goes through the normal render path so Ink can + * recompute cell widths. + */ +export function canFastBackspaceShape(current: string, cursor: number): boolean { + if (cursor !== current.length) { + return false + } + + if (cursor <= 0) { + return false + } + + if (current.includes('\n')) { + return false + } + + const removed = current.slice(prevPos(current, cursor), cursor) + + return ASCII_PRINTABLE_RE.test(removed) +} + function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -444,26 +522,11 @@ export function TextInput({ const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY - const canFastAppend = (current: string, cursor: number, text: string) => { - const sw = stringWidth(text) + const canFastAppend = (current: string, cursor: number, text: string) => + canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) - return ( - canFastEchoBase() && - cursor === current.length && - current.length > 0 && - !current.includes('\n') && - sw === text.length && - lineWidthRef.current + sw < Math.max(1, columns) - ) - } - - const canFastBackspace = (current: string, cursor: number) => { - if (!canFastEchoBase() || cursor !== current.length || cursor <= 0 || current.includes('\n')) { - return false - } - - return stringWidth(current.slice(prevPos(current, cursor), cursor)) === 1 - } + const canFastBackspace = (current: string, cursor: number) => + canFastEchoBase() && canFastBackspaceShape(current, cursor) const commit = ( next: string, From b62c9979732c732480491c63a4399034f668a44f Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 16:10:38 +0100 Subject: [PATCH 204/214] feat(xai-oauth): add xAI Grok OAuth (SuperGrok Subscription) provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new authentication provider that lets SuperGrok subscribers sign in to Hermes with their xAI account via the standard OAuth 2.0 PKCE loopback flow, instead of pasting a raw API key from console.x.ai. Highlights ---------- * OAuth 2.0 PKCE loopback login against accounts.x.ai with discovery, state/nonce, and a strict CORS-origin allowlist on the callback. * Authorize URL carries `plan=generic` (required for non-allowlisted loopback clients) and `referrer=hermes-agent` for best-effort attribution in xAI's OAuth server logs. * Token storage in `auth.json` with file-locked atomic writes; JWT `exp`-based expiry detection with skew; refresh-token rotation synced both ways between the singleton store and the credential pool so multi-process / multi-profile setups don't tear each other's refresh tokens. * Reactive 401 retry: on a 401 from the xAI Responses API, the agent refreshes the token, swaps it back into `self.api_key`, and retries the call once. Guarded against silent account swaps when the active key was sourced from a different (manual) pool entry. * Auxiliary tasks (curator, vision, embeddings, etc.) route through a dedicated xAI Responses-mode auxiliary client instead of falling back to OpenRouter billing. * Direct HTTP tools (`tools/xai_http.py`, transcription, TTS, image-gen plugin) resolve credentials through a unified runtime → singleton → env-var fallback chain so xai-oauth users get them for free. * `hermes auth add xai-oauth` and `hermes auth remove xai-oauth N` are wired through the standard auth-commands surface; remove cleans up the singleton loopback_pkce entry so it doesn't silently reinstate. * `hermes model` provider picker shows "xAI Grok OAuth (SuperGrok Subscription)" and the model-flow falls back to pool credentials when the singleton is missing. Hardening --------- * Discovery and refresh responses validate the returned `token_endpoint` host against the same `*.x.ai` allowlist as the authorization endpoint, blocking MITM persistence of a hostile endpoint. * Discovery / refresh / token-exchange `response.json()` calls are wrapped to raise typed `AuthError` on malformed bodies (captive portals, proxy error pages) instead of leaking JSONDecodeError tracebacks. * `prompt_cache_key` is routed through `extra_body` on the codex transport (sending it as a top-level kwarg trips xAI's SDK with a TypeError). * Credential-pool sync-back preserves `active_provider` so refreshing an OAuth entry doesn't silently flip the active provider out from under the running agent. Testing ------- * New `tests/hermes_cli/test_auth_xai_oauth_provider.py` (~63 tests) covers JWT expiry, OAuth URL params (plan + referrer), CORS origins, redirect URI validation, singleton↔pool sync, concurrency races, refresh error paths, runtime resolution, and malformed-JSON guards. * Extended `test_credential_pool.py`, `test_codex_transport.py`, and `test_run_agent_codex_responses.py` cover the pool sync-back, `extra_body` routing, and 401 reactive refresh paths. * 165 tests passing on this branch via `scripts/run_tests.sh`. --- agent/auxiliary_client.py | 72 + agent/codex_responses_adapter.py | 15 +- agent/credential_pool.py | 184 +- agent/credential_sources.py | 30 + agent/transports/codex.py | 31 +- hermes_cli/auth.py | 806 ++++++++- hermes_cli/auth_commands.py | 31 +- hermes_cli/main.py | 89 +- hermes_cli/models.py | 43 +- hermes_cli/providers.py | 10 + hermes_cli/runtime_provider.py | 23 + hermes_cli/setup.py | 116 +- hermes_cli/tools_config.py | 74 +- plugins/image_gen/xai/__init__.py | 51 +- plugins/video_gen/xai/__init__.py | 97 +- run_agent.py | 78 +- .../agent/transports/test_codex_transport.py | 43 + .../test_auth_xai_oauth_provider.py | 1605 +++++++++++++++++ tests/plugins/image_gen/test_xai_provider.py | 9 +- tests/plugins/video_gen/test_xai_plugin.py | 44 + .../test_run_agent_codex_responses.py | 205 ++- tools/transcription_tools.py | 31 +- tools/tts_tool.py | 19 +- tools/xai_http.py | 49 + website/docs/guides/xai-grok-oauth.md | 214 +++ website/docs/integrations/providers.md | 4 +- website/sidebars.ts | 1 + 27 files changed, 3843 insertions(+), 131 deletions(-) create mode 100644 tests/hermes_cli/test_auth_xai_oauth_provider.py create mode 100644 website/docs/guides/xai-grok-oauth.md diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 96ad615bf6f..cd655e70e56 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1254,6 +1254,30 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ return api_key, base_url +def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]: + """Resolve a fresh xAI OAuth (api_key, base_url) for auxiliary clients. + + Routes through ``hermes_cli.auth``'s runtime resolver so the auto-refresh + path is shared with the main agent, instead of relying on whatever raw + tokens happen to be sitting in auth.json or the credential pool. Returns + ``None`` if the user is not authenticated with xAI Grok OAuth (so + ``_resolve_auto`` Step 1 falls through to the next provider in the chain). + """ + try: + from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + + creds = resolve_xai_oauth_runtime_credentials() + except Exception as exc: + logger.debug("Auxiliary xAI OAuth runtime credential resolution failed: %s", exc) + return None + + api_key = str(creds.get("api_key") or "").strip() + base_url = str(creds.get("base_url") or "").strip().rstrip("/") + if not api_key or not base_url: + return None + return api_key, base_url + + def _read_codex_access_token() -> Optional[str]: """Read a valid, non-expired Codex OAuth access token from Hermes auth store. @@ -1744,6 +1768,32 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: return _fallback_client, model +def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str]]: + """Build a CodexAuxiliaryClient for an xAI Grok OAuth-authenticated session. + + xAI's ``/v1/responses`` endpoint speaks the OpenAI Responses API, so we + wrap a plain ``OpenAI`` client in ``CodexAuxiliaryClient`` to translate + ``chat.completions.create()`` calls into ``responses.stream()`` requests. + + The caller must pass an explicit model — pinning a default for Grok + would silently rot when xAI's allowlist drifts. Returns ``(None, None)`` + when the user has not authenticated with xAI Grok OAuth. + """ + if not model: + logger.warning( + "Auxiliary client: xai-oauth requested without a model; " + "pass model explicitly (auxiliary..model in config.yaml)." + ) + return None, None + resolved = _resolve_xai_oauth_for_aux() + if resolved is None: + return None, None + api_key, base_url = resolved + logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) + real_client = OpenAI(api_key=api_key, base_url=base_url) + return CodexAuxiliaryClient(real_client, model), model + + def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]: """Build a CodexAuxiliaryClient for an explicitly-requested model. @@ -2851,6 +2901,26 @@ def resolve_provider_client( return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) + # ── xAI Grok OAuth (loopback PKCE → Responses API) ─────────────── + # Without this branch, an xai-oauth main provider falls through to the + # generic ``oauth_external`` arm below and returns ``(None, None)``, + # silently re-routing every auxiliary task (compression, web extract, + # session search, curator, etc.) to whatever Step-2 fallback the user + # has configured. Users on xAI Grok OAuth would then see surprise + # OpenRouter / Nous bills for side tasks they thought were running on + # their xAI subscription. + if provider == "xai-oauth": + client, default = _build_xai_oauth_aux_client(model) + if client is None: + logger.warning( + "resolve_provider_client: xai-oauth requested but no xAI " + "OAuth token found (run: hermes model -> xAI Grok OAuth — SuperGrok Subscription)" + ) + return None, None + final_model = _normalize_resolved_model(model or default, provider) + return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + else (client, final_model)) + # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ─────────── if provider == "custom": if explicit_base_url: @@ -3201,6 +3271,8 @@ def resolve_provider_client( return resolve_provider_client("nous", model, async_mode) if provider == "openai-codex": return resolve_provider_client("openai-codex", model, async_mode) + if provider == "xai-oauth": + return resolve_provider_client("xai-oauth", model, async_mode) # Other OAuth providers not directly supported logger.warning("resolve_provider_client: OAuth provider %s not " "directly supported, try 'auto'", provider) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index ef4119ceb89..00345f054e8 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -726,7 +726,7 @@ def _preflight_codex_api_kwargs( "model", "instructions", "input", "tools", "store", "reasoning", "include", "max_output_tokens", "temperature", "tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier", - "extra_headers", + "extra_headers", "extra_body", } normalized: Dict[str, Any] = { "model": model, @@ -776,6 +776,19 @@ def _preflight_codex_api_kwargs( if normalized_headers: normalized["extra_headers"] = normalized_headers + extra_body = api_kwargs.get("extra_body") + if extra_body is not None: + if not isinstance(extra_body, dict): + raise ValueError("Codex Responses request 'extra_body' must be an object.") + # Pass extra_body through verbatim — used by xAI Responses to + # carry `prompt_cache_key` as a body-level field (the documented + # cache-routing surface on /v1/responses). The openai SDK + # serializes extra_body into the JSON body without per-field + # type checks, so it survives Responses.stream() kwarg-signature + # changes that would otherwise raise TypeError before the wire. + if extra_body: + normalized["extra_body"] = dict(extra_body) + if allow_stream: stream = api_kwargs.get("stream") if stream is not None and stream is not True: diff --git a/agent/credential_pool.py b/agent/credential_pool.py index aeda76225c8..504742145c1 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -29,6 +29,7 @@ from hermes_cli.auth import ( _resolve_zai_base_url, _save_auth_store, _save_provider_state, + _store_provider_state, read_credential_pool, write_credential_pool, ) @@ -539,6 +540,64 @@ class CredentialPool: logger.debug("Failed to sync Codex entry from auth.json: %s", exc) return entry + def _sync_xai_oauth_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential: + """Sync an xAI OAuth pool entry from auth.json if tokens differ. + + xAI OAuth refresh tokens are single-use. When another Hermes process + (or another profile sharing the same auth.json) refreshes the token, + it writes the new pair to ``providers["xai-oauth"]["tokens"]`` under + ``_auth_store_lock``. Without this resync, our in-memory pool entry + keeps the consumed refresh_token and the next ``_refresh_entry`` call + would replay it and get a ``refresh_token_reused``-style 4xx. + + Only applies to entries seeded from the singleton (``loopback_pkce``); + manually added entries (``manual:xai_pkce``) are independent + credentials with their own refresh-token lifecycle. + """ + if self.provider != "xai-oauth" or entry.source != "loopback_pkce": + return entry + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "xai-oauth") + if not isinstance(state, dict): + return entry + tokens = state.get("tokens") + if not isinstance(tokens, dict): + return entry + store_access = tokens.get("access_token", "") + store_refresh = tokens.get("refresh_token", "") + entry_access = entry.access_token or "" + entry_refresh = entry.refresh_token or "" + if store_access and ( + store_access != entry_access + or (store_refresh and store_refresh != entry_refresh) + ): + logger.debug( + "Pool entry %s: syncing xAI OAuth tokens from auth.json " + "(refreshed by another process)", + entry.id, + ) + field_updates: Dict[str, Any] = { + "access_token": store_access, + "refresh_token": store_refresh or entry.refresh_token, + "last_status": None, + "last_status_at": None, + "last_error_code": None, + "last_error_reason": None, + "last_error_message": None, + "last_error_reset_at": None, + } + if state.get("last_refresh"): + field_updates["last_refresh"] = state["last_refresh"] + updated = replace(entry, **field_updates) + self._replace_entry(entry, updated) + self._persist() + return updated + except Exception as exc: + logger.debug("Failed to sync xAI OAuth entry from auth.json: %s", exc) + return entry + def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential: """Sync a Nous pool entry from auth.json if tokens differ. @@ -604,9 +663,22 @@ class CredentialPool: re-seeding a consumed single-use refresh token. Applies to any OAuth provider whose singleton lives in auth.json - (currently Nous and OpenAI Codex). + (currently Nous, OpenAI Codex, and xAI Grok OAuth). + + ``set_active=False`` on every write: a pool sync-back is a + token-rotation side effect, not the user choosing a provider. + Using ``_save_provider_state`` (which sets ``active_provider``) + here would mean every Nous/Codex/xAI refresh in a multi-provider + setup silently flips the ``active_provider`` flag — the next + ``hermes`` invocation that defaults to the active provider + (e.g. setup wizard, ``hermes auth status``) would land on + whatever provider happened to refresh last, not whatever the + user actually chose. """ - if entry.source != "device_code": + # Only sync entries that were seeded *from* a singleton. Manually + # added pool entries (source="manual:*") are independent credentials + # and must not write back to the singleton. + if entry.source not in {"device_code", "loopback_pkce"}: return try: with _auth_store_lock(): @@ -632,7 +704,7 @@ class CredentialPool: state[extra_key] = val if entry.inference_base_url: state["inference_base_url"] = entry.inference_base_url - _save_provider_state(auth_store, "nous", state) + _store_provider_state(auth_store, "nous", state, set_active=False) elif self.provider == "openai-codex": state = _load_provider_state(auth_store, "openai-codex") @@ -646,7 +718,21 @@ class CredentialPool: tokens["refresh_token"] = entry.refresh_token if entry.last_refresh: state["last_refresh"] = entry.last_refresh - _save_provider_state(auth_store, "openai-codex", state) + _store_provider_state(auth_store, "openai-codex", state, set_active=False) + + elif self.provider == "xai-oauth": + state = _load_provider_state(auth_store, "xai-oauth") + if not isinstance(state, dict): + return + tokens = state.get("tokens") + if not isinstance(tokens, dict): + return + tokens["access_token"] = entry.access_token + if entry.refresh_token: + tokens["refresh_token"] = entry.refresh_token + if entry.last_refresh: + state["last_refresh"] = entry.last_refresh + _store_provider_state(auth_store, "xai-oauth", state, set_active=False) else: return @@ -699,6 +785,25 @@ class CredentialPool: refresh_token=refreshed["refresh_token"], last_refresh=refreshed.get("last_refresh"), ) + elif self.provider == "xai-oauth": + # Adopt fresher tokens from auth.json before spending the + # refresh_token — single-use tokens consumed by another + # process (or another profile sharing the singleton) would + # otherwise trigger ``refresh_token_reused`` on the next + # POST. Only meaningful for singleton-seeded entries. + synced = self._sync_xai_oauth_entry_from_auth_store(entry) + if synced is not entry: + entry = synced + refreshed = auth_mod.refresh_xai_oauth_pure( + entry.access_token, + entry.refresh_token, + ) + updated = replace( + entry, + access_token=refreshed["access_token"], + refresh_token=refreshed["refresh_token"], + last_refresh=refreshed.get("last_refresh"), + ) elif self.provider == "nous": synced = self._sync_nous_entry_from_auth_store(entry) if synced is not entry: @@ -777,6 +882,30 @@ class CredentialPool: # Credentials file had a valid (non-expired) token — use it directly logger.debug("Credentials file has valid token, using without refresh") return synced + # For xai-oauth: same race as nous — another process may have + # consumed the refresh token between our proactive sync and the + # HTTP call. Re-check auth.json and adopt the fresh tokens if + # they have rotated since. Only meaningful for singleton-seeded + # (loopback_pkce) entries; manual entries don't share state with + # the singleton. + if self.provider == "xai-oauth": + synced = self._sync_xai_oauth_entry_from_auth_store(entry) + if synced.refresh_token != entry.refresh_token: + logger.debug( + "xAI OAuth refresh failed but auth.json has newer tokens — adopting" + ) + updated = replace( + synced, + last_status=STATUS_OK, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) + self._replace_entry(synced, updated) + self._persist() + return updated # For nous: another process may have consumed the refresh token # between our proactive sync and the HTTP call. Re-sync from # auth.json and adopt the fresh tokens if available. @@ -829,6 +958,11 @@ class CredentialPool: entry.access_token, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) + if self.provider == "xai-oauth": + return auth_mod._xai_access_token_is_expiring( + entry.access_token, + auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ) if self.provider == "nous": # Nous refresh/mint can require network access and should happen when # runtime credentials are actually resolved, not merely when the pool @@ -883,6 +1017,17 @@ class CredentialPool: if synced is not entry: entry = synced cleared_any = True + # For xai-oauth singleton-seeded entries, identical pattern: + # an entry frozen as exhausted may simply be holding stale + # tokens that another process (or a fresh `hermes model` -> + # xAI Grok OAuth login) has since rotated in auth.json. + if (self.provider == "xai-oauth" + and entry.source == "loopback_pkce" + and entry.last_status == STATUS_EXHAUSTED): + synced = self._sync_xai_oauth_entry_from_auth_store(entry) + if synced is not entry: + entry = synced + cleared_any = True if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: @@ -1394,6 +1539,37 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup }, ) + elif provider == "xai-oauth": + # When the user logs in via ``hermes model`` -> xAI Grok OAuth, + # tokens are written to the auth.json singleton + # (``providers["xai-oauth"]``). Surface them in the pool too so + # ``hermes auth list`` reflects the logged-in state and so the pool + # is the single source of truth for refresh during runtime resolution. + if _is_suppressed(provider, "loopback_pkce"): + return changed, active_sources + + state = _load_provider_state(auth_store, "xai-oauth") + tokens = state.get("tokens") if isinstance(state, dict) else None + if isinstance(tokens, dict) and tokens.get("access_token"): + active_sources.add("loopback_pkce") + from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL + + base_url = DEFAULT_XAI_OAUTH_BASE_URL + changed |= _upsert_entry( + entries, + provider, + "loopback_pkce", + { + "source": "loopback_pkce", + "auth_type": AUTH_TYPE_OAUTH, + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token"), + "base_url": base_url, + "last_refresh": state.get("last_refresh"), + "label": label_from_token(tokens.get("access_token", ""), "loopback_pkce"), + }, + ) + return changed, active_sources diff --git a/agent/credential_sources.py b/agent/credential_sources.py index 74204919248..ee035426023 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -265,6 +265,31 @@ def _remove_minimax_oauth(provider: str, removed) -> RemovalResult: return result +def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult: + """xAI OAuth tokens live in auth.json providers.xai-oauth — clear them. + + Without this step, ``hermes auth remove xai-oauth `` silently undoes + itself: the central dispatcher only removes the in-memory pool entry, + leaves ``providers.xai-oauth`` in auth.json intact, and on the next + ``load_pool("xai-oauth")`` call ``_seed_from_singletons`` re-seeds the + entry from the still-present singleton — credentials reappear with no + user feedback. Clearing the singleton in step with the suppression set + by the central dispatcher makes the removal stick. + + Belt-and-braces against the manual entry path: ``hermes auth add + xai-oauth`` produces a ``manual:xai_pkce`` entry whose removal step + falls through to "unregistered → nothing to clean up" (correct — + manual entries are pool-only). + """ + result = RemovalResult() + if _clear_auth_store_provider(provider): + result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store") + result.hints.append( + "Run `hermes model` → xAI Grok OAuth (SuperGrok Subscription) to re-authenticate if needed." + ) + return result + + def _remove_codex_device_code(provider: str, removed) -> RemovalResult: """Codex tokens live in TWO places: our auth store AND ~/.codex/auth.json. @@ -397,6 +422,11 @@ def _register_all_sources() -> None: remove_fn=_remove_codex_device_code, description="auth.json providers.openai-codex + ~/.codex/auth.json", )) + register(RemovalStep( + provider="xai-oauth", source_id="loopback_pkce", + remove_fn=_remove_xai_oauth_loopback_pkce, + description="auth.json providers.xai-oauth", + )) register(RemovalStep( provider="qwen-oauth", source_id="qwen-cli", remove_fn=_remove_qwen_cli, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 6738ed3220c..46169e971ba 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -89,18 +89,25 @@ class ResponsesApiTransport(ProviderTransport): _effort_clamp = {"minimal": "low"} reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) + response_tools = _responses_tools(tools) kwargs = { "model": model, "instructions": instructions, "input": _chat_messages_to_responses_input(payload_messages), - "tools": _responses_tools(tools), - "tool_choice": "auto", - "parallel_tool_calls": True, + "tools": response_tools, "store": False, } + if response_tools: + kwargs["tool_choice"] = "auto" + kwargs["parallel_tool_calls"] = True session_id = params.get("session_id") - if not is_github_responses and session_id: + # xAI's Responses API uses `prompt_cache_key` (body-level) as the + # cache-routing key, not a top-level kwarg — the body-field + # injection below survives openai SDK builds whose + # Responses.stream() signature drops the kwarg. Everything else + # that ISN'T github/xAI keeps using the typed kwarg. + if not is_github_responses and not is_xai_responses and session_id: kwargs["prompt_cache_key"] = session_id if reasoning_enabled and is_xai_responses: @@ -165,6 +172,22 @@ class ResponsesApiTransport(ProviderTransport): merged_extra_headers["x-grok-conv-id"] = session_id kwargs["extra_headers"] = merged_extra_headers + # xAI Responses cache-routing field. Lives in the request body + # (per https://docs.x.ai/.../prompt-caching/maximizing-cache-hits), + # so we ship it via extra_body — the openai SDK serializes + # extra_body fields into the JSON body without per-field type + # validation, sidestepping the TypeError that fires on + # Responses.stream() builds whose `prompt_cache_key` kwarg has + # been dropped. Setdefault preserves a caller-supplied value + # (e.g. request_overrides.extra_body.prompt_cache_key) over + # the auto-derived session_id. + existing_extra_body = kwargs.get("extra_body") + merged_extra_body: Dict[str, Any] = {} + if isinstance(existing_extra_body, dict): + merged_extra_body.update(existing_extra_body) + merged_extra_body.setdefault("prompt_cache_key", session_id) + kwargs["extra_body"] = merged_extra_body + return kwargs def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2dcf6a03b45..8749cd9461c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -72,6 +72,7 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +DEFAULT_XAI_OAUTH_BASE_URL = "https://api.x.ai/v1" MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113" MINIMAX_OAUTH_SCOPE = "group_id profile model.completion" MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code" @@ -89,6 +90,14 @@ STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 +XAI_OAUTH_ISSUER = "https://auth.x.ai" +XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration" +XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access" +XAI_OAUTH_REDIRECT_HOST = "127.0.0.1" +XAI_OAUTH_REDIRECT_PORT = 56121 +XAI_OAUTH_REDIRECT_PATH = "/callback" +XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @@ -162,6 +171,12 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), + "xai-oauth": ProviderConfig( + id="xai-oauth", + name="xAI Grok OAuth (SuperGrok Subscription)", + auth_type="oauth_external", + inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ), "qwen-oauth": ProviderConfig( id="qwen-oauth", name="Qwen OAuth", @@ -1364,6 +1379,8 @@ def resolve_provider( "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", "x-ai": "xai", "x.ai": "xai", "grok": "xai", + "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", + "grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", "step": "stepfun", "stepfun-coding-plan": "stepfun", @@ -1907,6 +1924,16 @@ def _spotify_code_challenge(code_verifier: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") +def _oauth_pkce_code_verifier(length: int = 64) -> str: + raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii") + return raw.rstrip("=")[:128] + + +def _oauth_pkce_code_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + def _spotify_build_authorize_url( *, client_id: str, @@ -2029,6 +2056,158 @@ def _spotify_wait_for_callback( ) +def _xai_validate_loopback_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: + parsed = urlparse(redirect_uri) + if parsed.scheme != "http": + raise AuthError( + "xAI OAuth redirect_uri must use http://127.0.0.1.", + provider="xai-oauth", + code="xai_redirect_invalid", + ) + host = parsed.hostname or "" + if host != XAI_OAUTH_REDIRECT_HOST: + raise AuthError( + "xAI OAuth redirect_uri must point to 127.0.0.1.", + provider="xai-oauth", + code="xai_redirect_invalid", + ) + if not parsed.port: + raise AuthError( + "xAI OAuth redirect_uri must include an explicit localhost port.", + provider="xai-oauth", + code="xai_redirect_invalid", + ) + return host, parsed.port, parsed.path or "/" + + +def _xai_callback_cors_origin(origin: Optional[str]) -> str: + allowed = { + "https://accounts.x.ai", + "https://auth.x.ai", + "https://accounts.mouseion.dev", + "http://localhost:20000", + "http://127.0.0.1:20000", + } + return origin if origin in allowed else "" + + +def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]: + result: dict[str, Any] = { + "code": None, + "state": None, + "error": None, + "error_description": None, + } + + class _XAICallbackHandler(BaseHTTPRequestHandler): + def _maybe_write_cors_headers(self) -> None: + origin = self.headers.get("Origin") + allow_origin = _xai_callback_cors_origin(origin) + if allow_origin: + self.send_header("Access-Control-Allow-Origin", allow_origin) + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Private-Network", "true") + self.send_header("Vary", "Origin") + + def do_OPTIONS(self) -> None: # noqa: N802 + self.send_response(204) + self._maybe_write_cors_headers() + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != expected_path: + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found.") + return + + params = parse_qs(parsed.query) + result["code"] = params.get("code", [None])[0] + result["state"] = params.get("state", [None])[0] + result["error"] = params.get("error", [None])[0] + result["error_description"] = params.get("error_description", [None])[0] + + self.send_response(200) + self._maybe_write_cors_headers() + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + if result["error"]: + body = "

xAI authorization failed.

You can close this tab." + else: + body = "

xAI authorization received.

You can close this tab." + self.wfile.write(body.encode("utf-8")) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return + + return _XAICallbackHandler, result + + +def _xai_start_callback_server( + preferred_port: int = XAI_OAUTH_REDIRECT_PORT, +) -> tuple[HTTPServer, threading.Thread, dict[str, Any], str]: + host = XAI_OAUTH_REDIRECT_HOST + expected_path = XAI_OAUTH_REDIRECT_PATH + handler_cls, result = _make_xai_callback_handler(expected_path) + + class _ReuseHTTPServer(HTTPServer): + allow_reuse_address = True + + ports_to_try = [preferred_port] + if preferred_port != 0: + ports_to_try.append(0) + server = None + last_error: Optional[OSError] = None + for port in ports_to_try: + try: + server = _ReuseHTTPServer((host, port), handler_cls) + break + except OSError as exc: + last_error = exc + if server is None: + raise AuthError( + f"Could not bind xAI callback server on {host}:{preferred_port}: {last_error}", + provider="xai-oauth", + code="xai_callback_bind_failed", + ) from last_error + + actual_port = int(server.server_address[1]) + redirect_uri = f"http://{host}:{actual_port}{expected_path}" + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.1}, + daemon=True, + ) + thread.start() + return server, thread, result, redirect_uri + + +def _xai_wait_for_callback( + server: HTTPServer, + thread: threading.Thread, + result: dict[str, Any], + *, + timeout_seconds: float = 180.0, +) -> dict[str, Any]: + deadline = time.monotonic() + max(5.0, timeout_seconds) + try: + while time.monotonic() < deadline: + if result["code"] or result["error"]: + return result + time.sleep(0.1) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + raise AuthError( + "xAI authorization timed out waiting for the local callback.", + provider="xai-oauth", + code="xai_callback_timeout", + ) + + def _spotify_token_payload_to_state( token_payload: Dict[str, Any], *, @@ -2680,6 +2859,348 @@ def resolve_codex_runtime_credentials( } +# ============================================================================= +# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json +# ============================================================================= + +def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: + if _lock: + with _auth_store_lock(): + auth_store = _load_auth_store() + else: + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "xai-oauth") + if not state: + raise AuthError( + "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok Subscription) in `hermes model`.", + provider="xai-oauth", + code="xai_auth_missing", + relogin_required=True, + ) + tokens = state.get("tokens") + if not isinstance(tokens, dict): + raise AuthError( + "xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.", + provider="xai-oauth", + code="xai_auth_invalid_shape", + relogin_required=True, + ) + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_token = str(tokens.get("refresh_token", "") or "").strip() + if not access_token: + raise AuthError( + "xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.", + provider="xai-oauth", + code="xai_auth_missing_access_token", + relogin_required=True, + ) + if not refresh_token: + raise AuthError( + "xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.", + provider="xai-oauth", + code="xai_auth_missing_refresh_token", + relogin_required=True, + ) + return { + "tokens": tokens, + "last_refresh": state.get("last_refresh"), + "discovery": state.get("discovery") or {}, + "redirect_uri": state.get("redirect_uri"), + } + + +def _save_xai_oauth_tokens( + tokens: Dict[str, Any], + *, + discovery: Optional[Dict[str, Any]] = None, + redirect_uri: str = "", + last_refresh: Optional[str] = None, +) -> None: + if last_refresh is None: + last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "xai-oauth") or {} + state["tokens"] = tokens + state["last_refresh"] = last_refresh + state["auth_mode"] = "oauth_pkce" + if discovery: + state["discovery"] = discovery + if redirect_uri: + state["redirect_uri"] = redirect_uri + _save_provider_state(auth_store, "xai-oauth", state) + _save_auth_store(auth_store) + + +def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool: + if not isinstance(access_token, str) or "." not in access_token: + return False + try: + parts = access_token.split(".") + if len(parts) < 2: + return False + payload_b64 = parts[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) + exp = payload.get("exp") + if not isinstance(exp, (int, float)): + return False + return float(exp) <= (time.time() + max(0, int(skew_seconds))) + except Exception: + return False + + +def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: + """Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin. + + The OIDC discovery response is a long-lived, low-frequency request whose + output is cached in ``~/.hermes/auth.json``. A single MITM during initial + login could substitute a malicious ``token_endpoint``; that URL would + then receive the refresh_token on every subsequent refresh — a permanent + credential leak from a one-time MITM. Validating scheme + host pins the + cached endpoint to the xAI auth origin (or a future ``*.x.ai`` subdomain + if xAI migrates) so the cache poisoning loses its persistence guarantee. + + RFC 8414 §2 requires the issuer to be ``https://`` and SHOULD-keeps the + token_endpoint on the same origin; we enforce both. ``x.ai`` is the + bare apex, so we accept either exact host match or any ``.x.ai`` suffix. + """ + parsed = urlparse(url) + if parsed.scheme != "https": + raise AuthError( + f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.", + provider="xai-oauth", + code="xai_discovery_invalid", + ) + host = (parsed.hostname or "").lower() + if not host: + raise AuthError( + f"xAI OIDC discovery {field} is missing a hostname: {url!r}.", + provider="xai-oauth", + code="xai_discovery_invalid", + ) + if host != "x.ai" and not host.endswith(".x.ai"): + raise AuthError( + f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin " + f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached " + f"endpoint that may have been substituted by a MITM during initial " + f"discovery; re-authenticate with `hermes model` to re-fetch.", + provider="xai-oauth", + code="xai_discovery_invalid", + ) + return url + + +def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: + try: + response = httpx.get( + XAI_OAUTH_DISCOVERY_URL, + headers={"Accept": "application/json"}, + timeout=timeout_seconds, + ) + except Exception as exc: + raise AuthError( + f"xAI OIDC discovery failed: {exc}", + provider="xai-oauth", + code="xai_discovery_failed", + ) from exc + if response.status_code != 200: + raise AuthError( + f"xAI OIDC discovery returned status {response.status_code}.", + provider="xai-oauth", + code="xai_discovery_failed", + ) + try: + payload = response.json() + except Exception as exc: + raise AuthError( + f"xAI OIDC discovery returned invalid JSON: {exc}", + provider="xai-oauth", + code="xai_discovery_invalid_json", + ) from exc + if not isinstance(payload, dict): + raise AuthError( + "xAI OIDC discovery response was not a JSON object.", + provider="xai-oauth", + code="xai_discovery_incomplete", + ) + authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip() + token_endpoint = str(payload.get("token_endpoint", "") or "").strip() + if not authorization_endpoint or not token_endpoint: + raise AuthError( + "xAI OIDC discovery response was missing required endpoints.", + provider="xai-oauth", + code="xai_discovery_incomplete", + ) + _xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint") + _xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint") + return { + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + } + + +def refresh_xai_oauth_pure( + access_token: str, + refresh_token: str, + *, + token_endpoint: str = "", + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + del access_token + if not isinstance(refresh_token, str) or not refresh_token.strip(): + raise AuthError( + "xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.", + provider="xai-oauth", + code="xai_auth_missing_refresh_token", + relogin_required=True, + ) + endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"] + # Re-validate cached endpoints on the refresh hot path: an auth.json + # written by an older Hermes (or hand-edited) may carry a non-xAI + # token_endpoint that would receive every future refresh_token in + # plaintext if we trusted it blindly. Cheap suffix check; fast-fail + # with a clear error so the user can re-run `hermes model` to refetch. + _xai_validate_oauth_endpoint(endpoint, field="token_endpoint") + timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) + with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client: + response = client.post( + endpoint, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "client_id": XAI_OAUTH_CLIENT_ID, + "refresh_token": refresh_token, + }, + ) + if response.status_code != 200: + detail = response.text.strip() + raise AuthError( + "xAI token refresh failed." + + (f" Response: {detail}" if detail else ""), + provider="xai-oauth", + code="xai_refresh_failed", + relogin_required=(response.status_code in {400, 401, 403}), + ) + try: + payload = response.json() + except Exception as exc: + raise AuthError( + f"xAI token refresh returned invalid JSON: {exc}", + provider="xai-oauth", + code="xai_refresh_invalid_json", + ) from exc + if not isinstance(payload, dict): + raise AuthError( + "xAI token refresh response was not a JSON object.", + provider="xai-oauth", + code="xai_refresh_invalid_response", + relogin_required=True, + ) + refreshed_access = str(payload.get("access_token", "") or "").strip() + if not refreshed_access: + raise AuthError( + "xAI token refresh response was missing access_token.", + provider="xai-oauth", + code="xai_refresh_missing_access_token", + relogin_required=True, + ) + updated = { + "access_token": refreshed_access, + "refresh_token": str(payload.get("refresh_token") or refresh_token).strip(), + "id_token": str(payload.get("id_token") or "").strip(), + "expires_in": payload.get("expires_in"), + "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", + "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + return updated + + +def _refresh_xai_oauth_tokens( + tokens: Dict[str, Any], + *, + token_endpoint: str, + redirect_uri: str = "", + timeout_seconds: float, +) -> Dict[str, Any]: + refreshed = refresh_xai_oauth_pure( + str(tokens.get("access_token", "") or ""), + str(tokens.get("refresh_token", "") or ""), + token_endpoint=token_endpoint, + timeout_seconds=timeout_seconds, + ) + updated_tokens = dict(tokens) + updated_tokens["access_token"] = refreshed["access_token"] + updated_tokens["refresh_token"] = refreshed["refresh_token"] + if refreshed.get("id_token"): + updated_tokens["id_token"] = refreshed["id_token"] + if refreshed.get("expires_in") is not None: + updated_tokens["expires_in"] = refreshed["expires_in"] + if refreshed.get("token_type"): + updated_tokens["token_type"] = refreshed["token_type"] + _save_xai_oauth_tokens( + updated_tokens, + discovery={"token_endpoint": token_endpoint}, + redirect_uri=redirect_uri, + last_refresh=refreshed["last_refresh"], + ) + return updated_tokens + + +def resolve_xai_oauth_runtime_credentials( + *, + force_refresh: bool = False, + refresh_if_expiring: bool = True, + refresh_skew_seconds: int = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, +) -> Dict[str, Any]: + data = _read_xai_oauth_tokens() + tokens = dict(data["tokens"]) + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_timeout_seconds = float(os.getenv("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", "20")) + discovery = dict(data.get("discovery") or {}) + token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() + redirect_uri = str(data.get("redirect_uri", "") or "").strip() + + should_refresh = bool(force_refresh) + if (not should_refresh) and refresh_if_expiring: + should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds) + if should_refresh: + with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)): + data = _read_xai_oauth_tokens(_lock=False) + tokens = dict(data["tokens"]) + access_token = str(tokens.get("access_token", "") or "").strip() + discovery = dict(data.get("discovery") or {}) + token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() + redirect_uri = str(data.get("redirect_uri", "") or "").strip() + should_refresh = bool(force_refresh) + if (not should_refresh) and refresh_if_expiring: + should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds) + if should_refresh: + if not token_endpoint: + token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"] + tokens = _refresh_xai_oauth_tokens( + tokens, + token_endpoint=token_endpoint, + redirect_uri=redirect_uri, + timeout_seconds=refresh_timeout_seconds, + ) + access_token = str(tokens.get("access_token", "") or "").strip() + + base_url = ( + os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") + or os.getenv("XAI_BASE_URL", "").strip().rstrip("/") + or DEFAULT_XAI_OAUTH_BASE_URL + ) + return { + "provider": "xai-oauth", + "base_url": base_url, + "api_key": access_token, + "source": "hermes-auth-store", + "last_refresh": data.get("last_refresh"), + "auth_mode": "oauth_pkce", + } + + # ============================================================================= # TLS verification helper # ============================================================================= @@ -4030,6 +4551,48 @@ def get_codex_auth_status() -> Dict[str, Any]: } +def get_xai_oauth_auth_status() -> Dict[str, Any]: + try: + from agent.credential_pool import load_pool + + pool = load_pool("xai-oauth") + if pool and pool.has_credentials(): + entry = pool.select() + if entry is not None: + api_key = ( + getattr(entry, "runtime_api_key", None) + or getattr(entry, "access_token", "") + ) + if api_key and not _xai_access_token_is_expiring(api_key, 0): + return { + "logged_in": True, + "auth_store": str(_auth_file_path()), + "last_refresh": getattr(entry, "last_refresh", None), + "auth_mode": "oauth_pkce", + "source": f"pool:{getattr(entry, 'label', 'unknown')}", + "api_key": api_key, + } + except Exception: + pass + + try: + creds = resolve_xai_oauth_runtime_credentials() + return { + "logged_in": True, + "auth_store": str(_auth_file_path()), + "last_refresh": creds.get("last_refresh"), + "auth_mode": creds.get("auth_mode"), + "source": creds.get("source"), + "api_key": creds.get("api_key"), + } + except AuthError as exc: + return { + "logged_in": False, + "auth_store": str(_auth_file_path()), + "error": str(exc), + } + + def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: """Status snapshot for API-key providers (z.ai, Kimi, MiniMax).""" pconfig = PROVIDER_REGISTRY.get(provider_id) @@ -4100,6 +4663,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_nous_auth_status() if target == "openai-codex": return get_codex_auth_status() + if target == "xai-oauth": + return get_xai_oauth_auth_status() if target == "qwen-oauth": return get_qwen_auth_status() if target == "google-gemini-cli": @@ -4320,7 +4885,7 @@ def _logout_default_provider_from_config() -> Optional[str]: "No provider is currently logged in" and never reset model.provider. """ provider = _get_config_provider() - if provider in {"nous", "openai-codex"}: + if provider in {"nous", "openai-codex", "xai-oauth"}: return provider return None @@ -4619,6 +5184,245 @@ def _login_openai_codex( print(f" Config updated: {config_path} (model.provider=openai-codex)") +def _login_xai_oauth( + args, + pconfig: ProviderConfig, + *, + force_new_login: bool = False, +) -> None: + del pconfig + + if not force_new_login: + try: + existing = resolve_xai_oauth_runtime_credentials() + api_key = existing.get("api_key", "") + if isinstance(api_key, str) and api_key and not _xai_access_token_is_expiring(api_key, 60): + print("Existing xAI OAuth credentials found in Hermes auth store.") + try: + reuse = input("Use existing credentials? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + reuse = "y" + if reuse in ("", "y", "yes"): + config_path = _update_config_for_provider( + "xai-oauth", + existing.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL), + ) + print() + print("Login successful!") + print(f" Config updated: {config_path} (model.provider=xai-oauth)") + return + except AuthError: + pass + + print() + print("Signing in to xAI Grok OAuth (SuperGrok Subscription)...") + print("(Hermes creates its own local OAuth session)") + print() + + timeout_seconds = float(getattr(args, "timeout", None) or 20.0) + open_browser = not getattr(args, "no_browser", False) + if _is_remote_session(): + open_browser = False + + creds = _xai_oauth_loopback_login(timeout_seconds=timeout_seconds, open_browser=open_browser) + _save_xai_oauth_tokens( + creds["tokens"], + discovery=creds.get("discovery"), + redirect_uri=creds.get("redirect_uri", ""), + last_refresh=creds.get("last_refresh"), + ) + config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)) + print() + print("Login successful!") + from hermes_constants import display_hermes_home as _dhh + print(f" Auth state: {_dhh()}/auth.json") + print(f" Config updated: {config_path} (model.provider=xai-oauth)") + + +def _xai_oauth_build_authorize_url( + *, + authorization_endpoint: str, + redirect_uri: str, + code_challenge: str, + state: str, + nonce: str, +) -> str: + # `plan=generic` opts the consent screen into xAI's generic OAuth plan + # tier instead of falling back to the per-account default. Without it, + # accounts.x.ai rejects loopback OAuth from non-allowlisted clients. + # `referrer=hermes-agent` lets xAI attribute Hermes-originated logins + # in their OAuth server logs (we still impersonate the upstream Grok-CLI + # client_id; this is best-effort attribution until xAI mints us our own). + authorize_params = { + "response_type": "code", + "client_id": XAI_OAUTH_CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": XAI_OAUTH_SCOPE, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + "nonce": nonce, + "plan": "generic", + "referrer": "hermes-agent", + } + return f"{authorization_endpoint}?{urlencode(authorize_params)}" + + +def _xai_oauth_loopback_login( + *, + timeout_seconds: float = 20.0, + open_browser: bool = True, +) -> Dict[str, Any]: + discovery = _xai_oauth_discovery(timeout_seconds) + authorization_endpoint = discovery["authorization_endpoint"] + token_endpoint = discovery["token_endpoint"] + + server, thread, callback_result, redirect_uri = _xai_start_callback_server() + try: + _xai_validate_loopback_redirect_uri(redirect_uri) + code_verifier = _oauth_pkce_code_verifier() + code_challenge = _oauth_pkce_code_challenge(code_verifier) + state = uuid.uuid4().hex + nonce = uuid.uuid4().hex + authorize_url = _xai_oauth_build_authorize_url( + authorization_endpoint=authorization_endpoint, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + state=state, + nonce=nonce, + ) + + print("Open this URL to authorize Hermes with xAI:") + print(authorize_url) + print() + print(f"Waiting for callback on {redirect_uri}") + + if open_browser and not _is_remote_session(): + try: + opened = webbrowser.open(authorize_url) + except Exception: + opened = False + if opened: + print("Browser opened for xAI authorization.") + else: + print("Could not open the browser automatically; use the URL above.") + + callback = _xai_wait_for_callback( + server, + thread, + callback_result, + timeout_seconds=max(30.0, timeout_seconds * 9), + ) + except Exception: + try: + server.shutdown() + server.server_close() + except Exception: + pass + try: + thread.join(timeout=1.0) + except Exception: + pass + raise + + if callback.get("error"): + detail = callback.get("error_description") or callback["error"] + raise AuthError( + f"xAI authorization failed: {detail}", + provider="xai-oauth", + code="xai_authorization_failed", + ) + if callback.get("state") != state: + raise AuthError( + "xAI authorization failed: state mismatch.", + provider="xai-oauth", + code="xai_state_mismatch", + ) + code = str(callback.get("code") or "").strip() + if not code: + raise AuthError( + "xAI authorization failed: missing authorization code.", + provider="xai-oauth", + code="xai_code_missing", + ) + + try: + response = httpx.post( + token_endpoint, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": XAI_OAUTH_CLIENT_ID, + "code_verifier": code_verifier, + }, + timeout=max(20.0, timeout_seconds), + ) + except Exception as exc: + raise AuthError( + f"xAI token exchange failed: {exc}", + provider="xai-oauth", + code="xai_token_exchange_failed", + ) from exc + if response.status_code != 200: + detail = response.text.strip() + raise AuthError( + "xAI token exchange failed." + + (f" Response: {detail}" if detail else ""), + provider="xai-oauth", + code="xai_token_exchange_failed", + ) + try: + payload = response.json() + except Exception as exc: + raise AuthError( + f"xAI token exchange returned invalid JSON: {exc}", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) from exc + if not isinstance(payload, dict): + raise AuthError( + "xAI token exchange response was not a JSON object.", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) + access_token = str(payload.get("access_token", "") or "").strip() + refresh_token = str(payload.get("refresh_token", "") or "").strip() + if not access_token: + raise AuthError( + "xAI token exchange did not return an access_token.", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) + if not refresh_token: + raise AuthError( + "xAI token exchange did not return a refresh_token.", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) + + base_url = ( + os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") + or os.getenv("XAI_BASE_URL", "").strip().rstrip("/") + or DEFAULT_XAI_OAUTH_BASE_URL + ) + return { + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + "id_token": str(payload.get("id_token", "") or "").strip(), + "expires_in": payload.get("expires_in"), + "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", + }, + "discovery": discovery, + "redirect_uri": redirect_uri, + "base_url": base_url, + "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "source": "oauth-loopback", + } + + def _codex_device_code_login() -> Dict[str, Any]: """Run the OpenAI device code login flow and return credentials dict.""" import time as _time diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 65cb7ed1b85..10b040d8a1d 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} def _get_custom_provider_names() -> list: @@ -77,6 +77,8 @@ def _normalize_provider(provider: str) -> str: normalized = (provider or "").strip().lower() if normalized in {"or", "open-router"}: return "openrouter" + if normalized in {"grok-oauth", "xai-oauth", "x-ai-oauth", "xai-grok-oauth"}: + return "xai-oauth" # Check if it matches a custom provider name custom_key = _resolve_custom_provider_input(normalized) if custom_key: @@ -170,7 +172,7 @@ def auth_add_command(args) -> None: if provider.startswith(CUSTOM_POOL_PREFIX): requested_type = AUTH_TYPE_API_KEY else: - requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY + requested_type = AUTH_TYPE_OAUTH if provider in _OAUTH_CAPABLE_PROVIDERS else AUTH_TYPE_API_KEY pool = load_pool(provider) @@ -333,6 +335,31 @@ def auth_add_command(args) -> None: print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') return + if provider == "xai-oauth": + creds = auth_mod._xai_oauth_loopback_login( + timeout_seconds=getattr(args, "timeout", None) or 20.0, + open_browser=not getattr(args, "no_browser", False), + ) + label = (getattr(args, "label", None) or "").strip() or label_from_token( + creds["tokens"]["access_token"], + _oauth_default_label(provider, len(pool.entries()) + 1), + ) + entry = PooledCredential( + provider=provider, + id=uuid.uuid4().hex[:6], + label=label, + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=f"{SOURCE_MANUAL}:xai_pkce", + access_token=creds["tokens"]["access_token"], + refresh_token=creds["tokens"].get("refresh_token"), + base_url=creds.get("base_url"), + last_refresh=creds.get("last_refresh"), + ) + pool.add_entry(entry) + print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') + return + if provider == "google-gemini-cli": from agent.google_oauth import run_gemini_oauth_login_pure diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 833172a23b9..c7ac1100816 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1932,6 +1932,8 @@ def select_provider_and_model(args=None): _model_flow_nous(config, current_model, args=args) elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) + elif selected_provider == "xai-oauth": + _model_flow_xai_oauth(config, current_model) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) elif selected_provider == "minimax-oauth": @@ -2813,6 +2815,87 @@ def _model_flow_openai_codex(config, current_model=""): print("No change.") +def _model_flow_xai_oauth(_config, current_model=""): + """xAI Grok OAuth (SuperGrok Subscription) provider: ensure logged in, then pick model.""" + from hermes_cli.auth import ( + get_xai_oauth_auth_status, + _prompt_model_selection, + _save_model_choice, + _update_config_for_provider, + resolve_xai_oauth_runtime_credentials, + _login_xai_oauth, + DEFAULT_XAI_OAUTH_BASE_URL, + PROVIDER_REGISTRY, + ) + from hermes_cli.models import _PROVIDER_MODELS + + status = get_xai_oauth_auth_status() + if status.get("logged_in"): + print(" xAI Grok OAuth (SuperGrok Subscription) credentials: ✓") + print() + print(" 1. Use existing credentials") + print(" 2. Reauthenticate (new OAuth login)") + print(" 3. Cancel") + print() + try: + choice = input(" Choice [1/2/3]: ").strip() + except (KeyboardInterrupt, EOFError): + choice = "1" + + if choice == "2": + print("Starting a fresh xAI OAuth login...") + print() + try: + mock_args = argparse.Namespace() + _login_xai_oauth( + mock_args, + PROVIDER_REGISTRY["xai-oauth"], + force_new_login=True, + ) + except SystemExit: + print("Login cancelled or failed.") + return + except Exception as exc: + print(f"Login failed: {exc}") + return + elif choice == "3": + return + else: + print("Not logged into xAI Grok OAuth (SuperGrok Subscription). Starting login...") + print() + try: + mock_args = argparse.Namespace() + _login_xai_oauth(mock_args, PROVIDER_REGISTRY["xai-oauth"]) + except SystemExit: + print("Login cancelled or failed.") + return + except Exception as exc: + print(f"Login failed: {exc}") + return + + # Resolve a usable base URL. ``resolve_xai_oauth_runtime_credentials`` + # only reads from the auth.json singleton — but credentials may legitimately + # live only in the pool (e.g. after ``hermes auth add xai-oauth``). Fall + # back to the default base URL in that case so the model picker still + # completes successfully instead of bailing out with + # ``Could not resolve xAI OAuth credentials``. + base_url = DEFAULT_XAI_OAUTH_BASE_URL + try: + creds = resolve_xai_oauth_runtime_credentials() + base_url = (creds.get("base_url") or "").strip().rstrip("/") or base_url + except Exception: + pass + + models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or []) + selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-code-fast-1")) + if selected: + _save_model_choice(selected) + _update_config_for_provider("xai-oauth", base_url) + print(f"Default model set to: {selected} (via xAI Grok OAuth — SuperGrok Subscription)") + else: + print("No change.") + + _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", @@ -9400,7 +9483,7 @@ def _build_provider_choices() -> list[str]: except Exception: # Fallback: static list guarantees the CLI always works return [ - "auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", + "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "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", "novita", "xiaomi", "arcee", @@ -9931,7 +10014,7 @@ def main(): ) login_parser.add_argument( "--provider", - choices=["nous", "openai-codex"], + choices=["nous", "openai-codex", "xai-oauth"], default=None, help="Provider to authenticate with (default: nous)", ) @@ -9977,7 +10060,7 @@ def main(): ) logout_parser.add_argument( "--provider", - choices=["nous", "openai-codex", "spotify"], + choices=["nous", "openai-codex", "xai-oauth", "spotify"], default=None, help="Provider to log out from (default: active provider)", ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index bc41132f5d5..ded3f448f87 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -116,13 +116,23 @@ def _codex_curated_models() -> list[str]: # (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning}, # grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3). _XAI_STATIC_FALLBACK: list[str] = [ + "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-4.20-multi-agent-0309", - "grok-4.3", ] +_XAI_TOP_MODEL = "grok-4.3" + + +def _xai_promote_top(ids: list[str]) -> list[str]: + """Pin the headline xAI model to the top of the curated list.""" + if _XAI_TOP_MODEL in ids: + return [_XAI_TOP_MODEL] + [m for m in ids if m != _XAI_TOP_MODEL] + return ids + + def _xai_curated_models() -> list[str]: """Derive the xAI-direct curated list from models.dev disk cache. @@ -142,7 +152,7 @@ def _xai_curated_models() -> list[str]: if isinstance(models, dict) and models: ids = [mid for mid in models.keys() if isinstance(mid, str)] if ids: - return sorted(ids) + return _xai_promote_top(sorted(ids)) except Exception: # Any failure (missing file, malformed JSON, import error) # falls through to the static list. @@ -190,6 +200,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-4o-mini", ], "openai-codex": _codex_curated_models(), + "xai-oauth": _xai_curated_models(), "copilot-acp": [ "copilot-acp", ], @@ -918,6 +929,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"), ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"), ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"), + ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok Subscription)", "xAI Grok OAuth (SuperGrok Subscription)"), ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"), ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"), ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"), @@ -1036,6 +1048,10 @@ _PROVIDER_ALIASES = { "amazon-bedrock": "bedrock", "amazon": "bedrock", "grok": "xai", + "grok-oauth": "xai-oauth", + "xai-oauth": "xai-oauth", + "x-ai-oauth": "xai-oauth", + "xai-grok-oauth": "xai-oauth", "x-ai": "xai", "x.ai": "xai", "nim": "nvidia", @@ -2166,6 +2182,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) except Exception: access_token = None return get_codex_model_ids(access_token=access_token) + if normalized == "xai-oauth": + return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", []))) if normalized in {"copilot", "copilot-acp"}: try: live = _fetch_github_models(_resolve_copilot_catalog_api_key()) @@ -3444,14 +3462,14 @@ def validate_requested_model( "message": message, } - # OpenAI Codex has its own catalog path; /v1/models probing is not the right validation path. - if normalized == "openai-codex": + # Providers with non-standard catalog validation — /v1/models probing is not the right path. + if normalized in {"openai-codex", "xai-oauth"}: try: - codex_models = provider_model_ids("openai-codex") + catalog_models = provider_model_ids(normalized) except Exception: - codex_models = [] - if codex_models: - if requested_for_lookup in set(codex_models): + catalog_models = [] + if catalog_models: + if requested_for_lookup in set(catalog_models): return { "accepted": True, "persist": True, @@ -3459,7 +3477,7 @@ def validate_requested_model( "message": None, } # Auto-correct if the top match is very similar (e.g. typo) - auto = get_close_matches(requested_for_lookup, codex_models, n=1, cutoff=0.9) + auto = get_close_matches(requested_for_lookup, catalog_models, n=1, cutoff=0.9) if auto: return { "accepted": True, @@ -3468,17 +3486,18 @@ def validate_requested_model( "corrected_model": auto[0], "message": f"Auto-corrected `{requested}` → `{auto[0]}`", } - suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5) + suggestions = get_close_matches(requested_for_lookup, catalog_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) + provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok Subscription)" return { "accepted": True, "persist": True, "recognized": False, "message": ( - f"Note: `{requested}` was not found in the OpenAI Codex model listing. " - "It may still work if your ChatGPT/Codex account has access to a newer or hidden model ID." + f"Note: `{requested}` was not found in the {provider_label} model listing. " + "It may still work if your account has access to a newer or hidden model ID." f"{suggestion_text}" ), } diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 08fc173dc69..9243b3f6f84 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -60,6 +60,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { auth_type="oauth_external", base_url_override="https://chatgpt.com/backend-api/codex", ), + "xai-oauth": HermesOverlay( + transport="codex_responses", + auth_type="oauth_external", + base_url_override="https://api.x.ai/v1", + base_url_env_var="XAI_BASE_URL", + ), "qwen-oauth": HermesOverlay( transport="openai_chat", auth_type="oauth_external", @@ -244,6 +250,10 @@ ALIASES: Dict[str, str] = { "x-ai": "xai", "x.ai": "xai", "grok": "xai", + "grok-oauth": "xai-oauth", + "xai-oauth": "xai-oauth", + "x-ai-oauth": "xai-oauth", + "xai-grok-oauth": "xai-oauth", # nvidia "nim": "nvidia", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index d7c30fe5648..c0baf14db92 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -15,12 +15,14 @@ from hermes_cli.auth import ( AuthError, DEFAULT_CODEX_BASE_URL, DEFAULT_QWEN_BASE_URL, + DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY, _agent_key_is_usable, format_auth_error, resolve_provider, resolve_nous_runtime_credentials, resolve_codex_runtime_credentials, + resolve_xai_oauth_runtime_credentials, resolve_qwen_runtime_credentials, resolve_gemini_oauth_runtime_credentials, resolve_api_key_provider_credentials, @@ -238,6 +240,9 @@ def _resolve_runtime_from_pool_entry( if provider == "openai-codex": api_mode = "codex_responses" base_url = base_url or DEFAULT_CODEX_BASE_URL + elif provider == "xai-oauth": + api_mode = "codex_responses" + base_url = base_url or DEFAULT_XAI_OAUTH_BASE_URL elif provider == "qwen-oauth": api_mode = "chat_completions" base_url = base_url or DEFAULT_QWEN_BASE_URL @@ -1132,6 +1137,24 @@ def resolve_runtime_provider( logger.info("Auto-detected Codex provider but credentials failed; " "falling through to next provider.") + if provider == "xai-oauth": + try: + creds = resolve_xai_oauth_runtime_credentials() + return { + "provider": "xai-oauth", + "api_mode": "codex_responses", + "base_url": (creds.get("base_url") or "").rstrip("/") or DEFAULT_XAI_OAUTH_BASE_URL, + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "hermes-auth-store"), + "last_refresh": creds.get("last_refresh"), + "requested_provider": requested_provider, + } + except AuthError: + if requested_provider != "auto": + raise + logger.info("Auto-detected xAI OAuth provider but credentials failed; " + "falling through to next provider.") + if provider == "qwen-oauth": try: creds = resolve_qwen_runtime_credentials() diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5d635b2c464..50e198b9dc7 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1091,6 +1091,58 @@ def _install_kittentts_deps() -> bool: return False +def _xai_oauth_logged_in_for_setup() -> bool: + """True iff xAI Grok OAuth credentials are already stored locally. + + Lets TTS / STT setup skip the API-key prompt for users who logged in + through ``hermes model`` -> xAI Grok OAuth (SuperGrok Subscription). + """ + try: + from hermes_cli.auth import get_xai_oauth_auth_status + + return bool(get_xai_oauth_auth_status().get("logged_in")) + except Exception: + return False + + +def _run_xai_oauth_login_from_setup() -> bool: + """Run the xAI Grok OAuth loopback login from inside the setup wizard. + + Returns True on success, False on any failure (the caller falls back + to whatever the user picked next, e.g. Edge TTS). + """ + try: + from hermes_cli.auth import ( + DEFAULT_XAI_OAUTH_BASE_URL, + _is_remote_session, + _save_xai_oauth_tokens, + _update_config_for_provider, + _xai_oauth_loopback_login, + ) + except Exception as exc: + print_warning(f"xAI Grok OAuth helpers unavailable: {exc}") + return False + + open_browser = not _is_remote_session() + print() + print_info("Signing in to xAI Grok OAuth (SuperGrok Subscription)...") + try: + creds = _xai_oauth_loopback_login(open_browser=open_browser) + _save_xai_oauth_tokens( + creds["tokens"], + discovery=creds.get("discovery"), + redirect_uri=creds.get("redirect_uri", ""), + last_refresh=creds.get("last_refresh"), + ) + _update_config_for_provider( + "xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL) + ) + return True + except Exception as exc: + print_warning(f"xAI Grok OAuth login failed: {exc}") + return False + + def _setup_tts_provider(config: dict): """Interactive TTS provider selection with install flow for NeuTTS.""" tts_config = config.get("tts", {}) @@ -1125,7 +1177,7 @@ def _setup_tts_provider(config: dict): "Edge TTS (free, cloud-based, no setup needed)", "ElevenLabs (premium quality, needs API key)", "OpenAI TTS (good quality, needs API key)", - "xAI TTS (Grok voices, needs API key)", + "xAI TTS (Grok voices — OAuth login or API key)", "MiniMax TTS (high quality with voice cloning, needs API key)", "Mistral Voxtral TTS (multilingual, native Opus, needs API key)", "Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)", @@ -1199,21 +1251,59 @@ def _setup_tts_provider(config: dict): selected = "edge" elif selected == "xai": - existing = get_env_value("XAI_API_KEY") - if not existing: + # Resolution order: existing OAuth tokens (free for SuperGrok subscribers + # via the Hermes auth store) > existing XAI_API_KEY > prompt the user. + # When neither is configured, offer both options instead of forcing the + # API-key path — xAI TTS works fine with OAuth bearer tokens too. + oauth_logged_in = _xai_oauth_logged_in_for_setup() + existing_api_key = get_env_value("XAI_API_KEY") + + if oauth_logged_in: + print_success( + "xAI TTS will use your xAI Grok OAuth (SuperGrok Subscription) " + "credentials" + ) + elif existing_api_key: + print_success("xAI TTS will use your existing XAI_API_KEY") + else: print() - api_key = prompt("xAI API key for TTS", password=True) - if api_key: - save_env_value("XAI_API_KEY", api_key) - print_success("xAI TTS API key saved") + choice_idx = prompt_choice( + "How do you want xAI TTS to authenticate?", + choices=[ + "Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login", + "Paste an xAI API key (console.x.ai)", + "Skip → fallback to Edge TTS", + ], + default=0, + ) + if choice_idx == 0: + if _run_xai_oauth_login_from_setup(): + print_success( + "Logged in — xAI TTS will use these OAuth credentials" + ) + else: + print_warning( + "xAI Grok OAuth login did not complete. " + "Falling back to Edge TTS." + ) + selected = "edge" + elif choice_idx == 1: + api_key = prompt("xAI API key for TTS", password=True) + if api_key: + save_env_value("XAI_API_KEY", api_key) + print_success("xAI TTS API key saved") + else: + from hermes_constants import display_hermes_home as _dhh + print_warning( + "No xAI API key provided for TTS. Configure XAI_API_KEY " + f"via hermes setup model or {_dhh()}/.env to use xAI TTS. " + "Falling back to Edge TTS." + ) + selected = "edge" else: - from hermes_constants import display_hermes_home as _dhh - print_warning( - "No xAI API key provided for TTS. Configure XAI_API_KEY via " - f"hermes setup model or {_dhh()}/.env to use xAI TTS. " - "Falling back to Edge TTS." - ) + print_warning("xAI TTS skipped. Falling back to Edge TTS.") selected = "edge" + if selected == "xai": print() voice_id = prompt("xAI voice_id (Enter for 'eve', or paste a custom voice ID)") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index fc5b1acf5cf..891ffdeb05a 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -194,11 +194,10 @@ TOOL_CATEGORIES = { }, { "name": "xAI TTS", - "tag": "Grok voices - requires xAI API key", - "env_vars": [ - {"key": "XAI_API_KEY", "prompt": "xAI API key", "url": "https://console.x.ai/"}, - ], + "tag": "Grok voices — uses xAI Grok OAuth or XAI_API_KEY", + "env_vars": [], "tts_provider": "xai", + "post_setup": "xai_grok", }, { "name": "ElevenLabs", @@ -925,6 +924,73 @@ def _run_post_setup(post_setup_key: str): _print_info(" Restart Hermes for tracing to take effect.") _print_info(" Verify: hermes plugins list") + elif post_setup_key == "xai_grok": + # Shared credential bootstrap for any picker entry that talks to xAI + # (TTS, Video Gen, future Image Gen, etc.). Accepts either a + # SuperGrok-tier OAuth bearer token (preferred — billed against the + # user's existing subscription) or a raw XAI_API_KEY from + # console.x.ai. The picker entries declare empty env_vars so we + # drive the full auth UX here. + try: + from hermes_cli.auth import get_xai_oauth_auth_status + oauth_logged_in = bool(get_xai_oauth_auth_status().get("logged_in")) + except Exception: + oauth_logged_in = False + existing_api_key = get_env_value("XAI_API_KEY") + + if oauth_logged_in: + _print_success( + " xAI will use your xAI Grok OAuth (SuperGrok Subscription) credentials" + ) + return + if existing_api_key: + _print_success(" xAI will use your existing XAI_API_KEY") + return + + _print_info(" xAI needs credentials. Choose one:") + try: + from hermes_cli.setup import ( + _run_xai_oauth_login_from_setup, + prompt_choice, + prompt as _setup_prompt, + ) + from hermes_cli.config import save_env_value + except Exception as exc: + _print_warning(f" Could not load setup helpers: {exc}") + _print_info(" Run later: hermes auth add xai-oauth (or set XAI_API_KEY)") + return + + idx = prompt_choice( + " How do you want xAI to authenticate?", + choices=[ + "Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login", + "Paste an xAI API key (console.x.ai)", + "Skip — configure later via `hermes auth add xai-oauth`", + ], + default=0, + ) + if idx == 0: + if _run_xai_oauth_login_from_setup(): + _print_success( + " Logged in — xAI will use these OAuth credentials" + ) + else: + _print_warning( + " xAI Grok OAuth login did not complete. " + "Run later: hermes auth add xai-oauth" + ) + elif idx == 1: + api_key = _setup_prompt(" xAI API key", password=True) + if api_key: + save_env_value("XAI_API_KEY", api_key) + _print_success(" XAI_API_KEY saved") + else: + _print_warning( + " No API key provided. Run later: hermes auth add xai-oauth" + ) + else: + _print_info(" xAI will remain inactive until credentials are configured.") + # ─── Platform / Toolset Helpers ─────────────────────────────────────────────── diff --git a/plugins/image_gen/xai/__init__.py b/plugins/image_gen/xai/__init__.py index ea8721075d0..d5aac4eccdd 100644 --- a/plugins/image_gen/xai/__init__.py +++ b/plugins/image_gen/xai/__init__.py @@ -31,7 +31,7 @@ from agent.image_gen_provider import ( save_b64_image, success_response, ) -from tools.xai_http import hermes_xai_user_agent +from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials logger = logging.getLogger(__name__) @@ -39,14 +39,17 @@ logger = logging.getLogger(__name__) # Model catalog # --------------------------------------------------------------------------- -API_MODEL = "grok-imagine-image" - _MODELS: Dict[str, Dict[str, Any]] = { "grok-imagine-image": { "display": "Grok Imagine Image", "speed": "~5-10s", "strengths": "Fast, high-quality", }, + "grok-imagine-image-quality": { + "display": "Grok Imagine Image (Quality)", + "speed": "~10-20s", + "strengths": "Higher fidelity / detail; slower than the standard model.", + }, } DEFAULT_MODEL = "grok-imagine-image" @@ -127,7 +130,8 @@ class XAIImageGenProvider(ImageGenProvider): return "xAI (Grok)" def is_available(self) -> bool: - return bool(os.getenv("XAI_API_KEY")) + creds = resolve_xai_http_credentials() + return bool(creds.get("api_key")) def list_models(self) -> List[Dict[str, Any]]: return [ @@ -141,17 +145,16 @@ class XAIImageGenProvider(ImageGenProvider): ] def get_setup_schema(self) -> Dict[str, Any]: + # Auth resolution is delegated to the shared ``xai_grok`` post_setup + # hook (``hermes_cli/tools_config.py``); identical to the TTS / video + # gen entries so users see the same OAuth-or-API-key choice for every + # xAI service. return { - "name": "xAI (Grok)", + "name": "xAI Grok Imagine (image)", "badge": "paid", - "tag": "Native xAI image generation via grok-imagine-image", - "env_vars": [ - { - "key": "XAI_API_KEY", - "prompt": "xAI API key", - "url": "https://console.x.ai/", - }, - ], + "tag": "grok-imagine-image — text-to-image; uses xAI Grok OAuth or XAI_API_KEY", + "env_vars": [], + "post_setup": "xai_grok", } def generate( @@ -161,12 +164,14 @@ class XAIImageGenProvider(ImageGenProvider): **kwargs: Any, ) -> Dict[str, Any]: """Generate an image using xAI's grok-imagine-image.""" - api_key = os.getenv("XAI_API_KEY", "").strip() + creds = resolve_xai_http_credentials() + api_key = str(creds.get("api_key") or "").strip() + provider_name = str(creds.get("provider") or "xai").strip() or "xai" if not api_key: return error_response( - error="XAI_API_KEY not set. Get one at https://console.x.ai/", + error="No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.", error_type="missing_api_key", - provider="xai", + provider=provider_name, aspect_ratio=aspect_ratio, ) @@ -177,7 +182,7 @@ class XAIImageGenProvider(ImageGenProvider): xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION payload: Dict[str, Any] = { - "model": API_MODEL, + "model": model_id, "prompt": prompt, "aspect_ratio": xai_ar, "resolution": xai_res, @@ -189,7 +194,7 @@ class XAIImageGenProvider(ImageGenProvider): "User-Agent": hermes_xai_user_agent(), } - base_url = (os.getenv("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") + base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/") try: response = requests.post( @@ -210,7 +215,7 @@ class XAIImageGenProvider(ImageGenProvider): return error_response( error=f"xAI image generation failed ({status}): {err_msg}", error_type="api_error", - provider="xai", + provider=provider_name, model=model_id, prompt=prompt, aspect_ratio=aspect, @@ -219,7 +224,7 @@ class XAIImageGenProvider(ImageGenProvider): return error_response( error="xAI image generation timed out (120s)", error_type="timeout", - provider="xai", + provider=provider_name, model=model_id, prompt=prompt, aspect_ratio=aspect, @@ -228,7 +233,7 @@ class XAIImageGenProvider(ImageGenProvider): return error_response( error=f"xAI connection error: {exc}", error_type="connection_error", - provider="xai", + provider=provider_name, model=model_id, prompt=prompt, aspect_ratio=aspect, @@ -240,7 +245,7 @@ class XAIImageGenProvider(ImageGenProvider): return error_response( error=f"xAI returned invalid JSON: {exc}", error_type="invalid_response", - provider="xai", + provider=provider_name, model=model_id, prompt=prompt, aspect_ratio=aspect, @@ -252,7 +257,7 @@ class XAIImageGenProvider(ImageGenProvider): return error_response( error="xAI returned no image data", error_type="empty_response", - provider="xai", + provider=provider_name, model=model_id, prompt=prompt, aspect_ratio=aspect, diff --git a/plugins/video_gen/xai/__init__.py b/plugins/video_gen/xai/__init__.py index b7421799044..d6fe9d04a7b 100644 --- a/plugins/video_gen/xai/__init__.py +++ b/plugins/video_gen/xai/__init__.py @@ -10,8 +10,12 @@ 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. +Authentication: xAI Grok OAuth tokens (preferred — billed against the +user's SuperGrok subscription) or ``XAI_API_KEY``. Both routes are +resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a +single login covers chat + TTS + image gen + video gen + transcription. +Output is an HTTPS URL from xAI's CDN; the gateway downloads and +delivers it. """ from __future__ import annotations @@ -20,7 +24,7 @@ import asyncio import logging import os import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import httpx @@ -66,24 +70,44 @@ _MODELS: Dict[str, Dict[str, Any]] = { # --------------------------------------------------------------------------- -def _xai_base_url() -> str: - return (os.getenv("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL).strip().rstrip("/") +def _resolve_xai_credentials() -> Tuple[str, str]: + """Return ``(api_key, base_url)`` from the shared xAI credential resolver. + + Order: runtime provider (xai-oauth pool entry) → singleton ``auth.json`` + OAuth tokens → ``XAI_API_KEY`` env var. ``api_key`` is empty when no + credential source is available; callers must check before using it. + """ + try: + from tools.xai_http import resolve_xai_http_credentials + + creds = resolve_xai_http_credentials() or {} + except Exception as exc: + logger.debug("xAI credential resolver failed: %s", exc) + creds = {} + + api_key = str(creds.get("api_key") or os.getenv("XAI_API_KEY", "")).strip() + base_url = str( + creds.get("base_url") + or os.getenv("XAI_BASE_URL") + or DEFAULT_XAI_BASE_URL + ).strip().rstrip("/") + return api_key, base_url -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/") +def _xai_user_agent() -> str: try: from tools.xai_http import hermes_xai_user_agent - ua = hermes_xai_user_agent() + return hermes_xai_user_agent() except Exception: - ua = "hermes-agent/video_gen" + return "hermes-agent/video_gen" + + +def _xai_headers(api_key: str) -> Dict[str, str]: return { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - "User-Agent": ua, + "User-Agent": _xai_user_agent(), } @@ -110,12 +134,15 @@ def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int: async def _submit( client: httpx.AsyncClient, payload: Dict[str, Any], + *, + api_key: str, + base_url: str, ) -> 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())}, + f"{base_url}/videos/generations", + headers={**_xai_headers(api_key), "x-idempotency-key": str(uuid.uuid4())}, json=payload, timeout=60, ) @@ -131,6 +158,8 @@ async def _poll( client: httpx.AsyncClient, request_id: str, *, + api_key: str, + base_url: str, timeout_seconds: int, poll_interval: int, ) -> Dict[str, Any]: @@ -138,8 +167,8 @@ async def _poll( last_status = "queued" while elapsed < timeout_seconds: response = await client.get( - f"{_xai_base_url()}/videos/{request_id}", - headers=_xai_headers(), + f"{base_url}/videos/{request_id}", + headers=_xai_headers(api_key), timeout=30, ) response.raise_for_status() @@ -174,7 +203,8 @@ class XAIVideoGenProvider(VideoGenProvider): return "xAI" def is_available(self) -> bool: - return bool(os.environ.get("XAI_API_KEY", "").strip()) + api_key, _ = _resolve_xai_credentials() + return bool(api_key) def list_models(self) -> List[Dict[str, Any]]: return [{"id": mid, **meta} for mid, meta in _MODELS.items()] @@ -183,17 +213,18 @@ class XAIVideoGenProvider(VideoGenProvider): return DEFAULT_MODEL def get_setup_schema(self) -> Dict[str, Any]: + # Auth resolution lives entirely in the shared ``xai_grok`` post_setup + # hook (``hermes_cli/tools_config.py``) so the picker doesn't blindly + # prompt for an API key when the user is already signed in via xAI + # Grok OAuth (SuperGrok Subscription) — TTS / image gen / video gen + # all share the same credential resolver. The hook offers an + # OAuth-vs-API-key choice when neither is configured. return { - "name": "xAI", + "name": "xAI Grok Imagine", "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/", - }, - ], + "tag": "grok-imagine-video — text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY", + "env_vars": [], + "post_setup": "xai_grok", } def capabilities(self) -> Dict[str, Any]: @@ -259,9 +290,14 @@ class XAIVideoGenProvider(VideoGenProvider): aspect_ratio: str, resolution: str, ) -> Dict[str, Any]: - if not os.environ.get("XAI_API_KEY", "").strip(): + api_key, base_url = _resolve_xai_credentials() + if not api_key: return error_response( - error="XAI_API_KEY not set. Get one at https://console.x.ai/", + error=( + "No xAI credentials found. Sign in via `hermes auth add xai-oauth` " + "(SuperGrok subscription) or set XAI_API_KEY from " + "https://console.x.ai/." + ), error_type="auth_required", provider="xai", prompt=prompt, ) @@ -317,7 +353,9 @@ class XAIVideoGenProvider(VideoGenProvider): async with httpx.AsyncClient() as client: try: - request_id = await _submit(client, payload) + request_id = await _submit( + client, payload, api_key=api_key, base_url=base_url + ) except httpx.HTTPStatusError as exc: detail = "" try: @@ -334,6 +372,7 @@ class XAIVideoGenProvider(VideoGenProvider): poll_result = await _poll( client, request_id, + api_key=api_key, base_url=base_url, timeout_seconds=DEFAULT_TIMEOUT_SECONDS, poll_interval=DEFAULT_POLL_INTERVAL_SECONDS, ) diff --git a/run_agent.py b/run_agent.py index a4df8749777..a82c6417ae1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1275,7 +1275,7 @@ class AIAgent: self.api_mode = api_mode elif self.provider == "openai-codex": self.api_mode = "codex_responses" - elif self.provider == "xai": + elif self.provider in {"xai", "xai-oauth"}: self.api_mode = "codex_responses" elif (provider_name is None) and ( self._base_url_hostname == "chatgpt.com" @@ -7139,15 +7139,60 @@ class AIAgent: raise RuntimeError("Responses create(stream=True) fallback did not emit a terminal response.") def _try_refresh_codex_client_credentials(self, *, force: bool = True) -> bool: - if self.api_mode != "codex_responses" or self.provider != "openai-codex": + if self.api_mode != "codex_responses" or self.provider not in {"openai-codex", "xai-oauth"}: + return False + + # Guard against silent account swap. + # + # When an agent is using a non-singleton credential — e.g. a manual + # pool entry (``hermes auth add xai-oauth``) whose tokens belong to + # a different account than the loopback_pkce singleton, or an agent + # constructed with an explicit ``api_key=`` arg — force-refreshing + # the singleton here and adopting its tokens silently re-routes the + # rest of the conversation onto the singleton's account. The + # credential pool's reactive recovery (``_recover_with_credential_pool``) + # is the right channel for that case; this path is the + # singleton-only fallback used when the pool can't recover, and + # MUST only fire when the agent really is on singleton tokens. + try: + if self.provider == "openai-codex": + from hermes_cli.auth import resolve_codex_runtime_credentials + + singleton_now = resolve_codex_runtime_credentials( + refresh_if_expiring=False, + ) + else: + from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + + singleton_now = resolve_xai_oauth_runtime_credentials( + refresh_if_expiring=False, + ) + except Exception as exc: + logger.debug("%s singleton read failed: %s", self.provider, exc) + return False + + singleton_key = str(singleton_now.get("api_key") or "").strip() + active_key = str(self.api_key or "").strip() + if singleton_key and active_key and singleton_key != active_key: + logger.debug( + "%s singleton tokens differ from the active api_key; " + "skipping singleton force-refresh to avoid silent account swap. " + "Reactive credential rotation should go through the pool.", + self.provider, + ) return False try: - from hermes_cli.auth import resolve_codex_runtime_credentials + if self.provider == "openai-codex": + from hermes_cli.auth import resolve_codex_runtime_credentials - creds = resolve_codex_runtime_credentials(force_refresh=force) + creds = resolve_codex_runtime_credentials(force_refresh=force) + else: + from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + + creds = resolve_xai_oauth_runtime_credentials(force_refresh=force) except Exception as exc: - logger.debug("Codex credential refresh failed: %s", exc) + logger.debug("%s credential refresh failed: %s", self.provider, exc) return False api_key = creds.get("api_key") @@ -7162,7 +7207,7 @@ class AIAgent: self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url - if not self._replace_primary_openai_client(reason="codex_credential_refresh"): + if not self._replace_primary_openai_client(reason=f"{self.provider}_credential_refresh"): return False return True @@ -9631,7 +9676,7 @@ class AIAgent: and "/backend-api/codex" in self._base_url_lower ) ) - is_xai_responses = self.provider == "xai" or self._base_url_hostname == "api.x.ai" + is_xai_responses = self.provider in {"xai", "xai-oauth"} or self._base_url_hostname == "api.x.ai" _msgs_for_codex = self._prepare_messages_for_non_vision_model(api_messages) return _ct.build_kwargs( model=self.model, @@ -13700,13 +13745,14 @@ class AIAgent: if ( self.api_mode == "codex_responses" - and self.provider == "openai-codex" + and self.provider in {"openai-codex", "xai-oauth"} and status_code == 401 and not codex_auth_retry_attempted ): codex_auth_retry_attempted = True if self._try_refresh_codex_client_credentials(force=True): - self._vprint(f"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...") + _label = "xAI OAuth" if self.provider == "xai-oauth" else "Codex" + self._vprint(f"{self.log_prefix}🔐 {_label} auth refreshed after 401. Retrying request...") continue if ( self.api_mode == "chat_completions" @@ -14346,11 +14392,15 @@ class AIAgent: self._vprint(f"{self.log_prefix} 🌐 Endpoint: {_base}", force=True) # Actionable guidance for common auth errors if classified.is_auth or classified.reason == FailoverReason.billing: - if _provider == "openai-codex" and status_code == 401: - self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True) - self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True) - self._vprint(f"{self.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True) - self._vprint(f"{self.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True) + if _provider in {"openai-codex", "xai-oauth"} and status_code == 401: + if _provider == "openai-codex": + self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True) + self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True) + self._vprint(f"{self.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True) + self._vprint(f"{self.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True) + else: + self._vprint(f"{self.log_prefix} 💡 xAI OAuth token was rejected (HTTP 401). To fix:", force=True) + self._vprint(f"{self.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok Subscription) from `hermes model`.", force=True) else: self._vprint(f"{self.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True) self._vprint(f"{self.log_prefix} • Is the key valid? Run: hermes setup", force=True) diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6a4cda173ad..7100e8ac17d 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -100,6 +100,49 @@ class TestCodexBuildKwargs: ) assert "prompt_cache_key" not in kw + def test_xai_responses_sends_cache_key_via_extra_body(self, transport): + """xAI's Responses API documents ``prompt_cache_key`` as the + body-level cache-routing key (the ``x-grok-conv-id`` header is + Chat-Completions-only). Passing it via ``extra_body`` is robust + against openai SDK builds whose ``Responses.stream()`` kwarg + signature ever drops the field — the body field still serializes + and reaches xAI either way. The ``x-grok-conv-id`` header is kept + as a belt-and-braces fallback so cache routing survives even + when the body field would be stripped by an intermediate proxy. + Ref: https://docs.x.ai/developers/advanced-api-usage/prompt-caching/maximizing-cache-hits + """ + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-4.3", messages=messages, tools=[], + session_id="conv-xai-1", + is_xai_responses=True, + ) + # Top-level prompt_cache_key must NOT be set for xAI — the SDK + # signature drop is what motivated the extra_body indirection in + # the first place. The cache-routing field must travel in the + # body via extra_body. + assert "prompt_cache_key" not in kw + assert kw.get("extra_body", {}).get("prompt_cache_key") == "conv-xai-1" + # Header kept as belt-and-braces. + assert kw.get("extra_headers", {}).get("x-grok-conv-id") == "conv-xai-1" + + def test_xai_responses_extra_body_preserves_caller_fields(self, transport): + """When the caller already supplies ``extra_body`` (e.g. via + request_overrides), the xAI cache-key injection must merge into + the existing dict instead of overwriting it. Caller-supplied + ``prompt_cache_key`` wins (setdefault semantics) so user overrides + aren't silently clobbered by the transport.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-4.3", messages=messages, tools=[], + session_id="conv-xai-1", + is_xai_responses=True, + request_overrides={"extra_body": {"prompt_cache_key": "caller-override", "other_field": 42}}, + ) + eb = kw.get("extra_body", {}) + assert eb.get("prompt_cache_key") == "caller-override" + assert eb.get("other_field") == 42 + def test_max_tokens(self, transport): messages = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs( diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py new file mode 100644 index 00000000000..9f1cc55f57e --- /dev/null +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -0,0 +1,1605 @@ +"""Tests for xAI Grok OAuth — tokens stored in Hermes auth store (~/.hermes/auth.json).""" + +import base64 +import json +import time +from pathlib import Path + +import pytest + +from hermes_cli.auth import ( + AuthError, + DEFAULT_XAI_OAUTH_BASE_URL, + PROVIDER_REGISTRY, + XAI_OAUTH_CLIENT_ID, + XAI_OAUTH_REDIRECT_HOST, + XAI_OAUTH_REDIRECT_PATH, + XAI_OAUTH_SCOPE, + _read_xai_oauth_tokens, + _save_xai_oauth_tokens, + _xai_access_token_is_expiring, + _xai_callback_cors_origin, + _xai_oauth_build_authorize_url, + _xai_validate_loopback_redirect_uri, + get_xai_oauth_auth_status, + refresh_xai_oauth_pure, + resolve_provider, + resolve_xai_oauth_runtime_credentials, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_hermes_auth( + hermes_home: Path, + *, + access_token: str = "access", + refresh_token: str = "refresh", + discovery: dict | None = None, +): + """Write xAI OAuth tokens into the Hermes auth store at the given root.""" + hermes_home.mkdir(parents=True, exist_ok=True) + state = { + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + }, + "last_refresh": "2026-05-14T00:00:00Z", + "auth_mode": "oauth_pkce", + } + if discovery is not None: + state["discovery"] = discovery + auth_store = { + "version": 1, + "active_provider": "xai-oauth", + "providers": {"xai-oauth": state}, + } + auth_file = hermes_home / "auth.json" + auth_file.write_text(json.dumps(auth_store, indent=2)) + return auth_file + + +def _jwt_with_exp(exp_epoch: int) -> str: + """Build a minimal JWT-shaped string with the given exp claim.""" + payload = {"exp": exp_epoch} + encoded = ( + base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")) + .rstrip(b"=") + .decode("utf-8") + ) + return f"h.{encoded}.s" + + +class _StubHTTPResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload) + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _StubHTTPClient: + def __init__(self, response): + self._response = response + self.last_call = None + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, *args, **kwargs): + self.last_call = ("post", args, kwargs) + return self._response + + +def _patch_httpx_client(monkeypatch, response): + holder = {"client": None} + + def _factory(*args, **kwargs): + client = _StubHTTPClient(response) + holder["client"] = client + return client + + monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory) + return holder + + +# --------------------------------------------------------------------------- +# Constants and registry +# --------------------------------------------------------------------------- + + +def test_xai_oauth_provider_registered(): + assert "xai-oauth" in PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["xai-oauth"] + assert pconfig.id == "xai-oauth" + assert pconfig.auth_type == "oauth_external" + assert pconfig.inference_base_url == DEFAULT_XAI_OAUTH_BASE_URL + + +def test_resolve_provider_normalizes_xai_oauth_aliases(): + assert resolve_provider("xai-oauth") == "xai-oauth" + assert resolve_provider("grok-oauth") == "xai-oauth" + assert resolve_provider("x-ai-oauth") == "xai-oauth" + assert resolve_provider("xai-grok-oauth") == "xai-oauth" + + +# --------------------------------------------------------------------------- +# JWT expiry detection +# --------------------------------------------------------------------------- + + +def test_xai_access_token_is_expiring_returns_true_for_expired_jwt(): + expired = _jwt_with_exp(int(time.time()) - 60) + assert _xai_access_token_is_expiring(expired, 0) is True + + +def test_xai_access_token_is_expiring_returns_false_for_fresh_jwt(): + fresh = _jwt_with_exp(int(time.time()) + 3600) + assert _xai_access_token_is_expiring(fresh, 0) is False + + +def test_xai_access_token_is_expiring_honors_skew_window(): + near = _jwt_with_exp(int(time.time()) + 30) + assert _xai_access_token_is_expiring(near, 60) is True + assert _xai_access_token_is_expiring(near, 0) is False + + +def test_xai_access_token_is_expiring_returns_false_for_non_jwt(): + assert _xai_access_token_is_expiring("not.a.jwt.but.has.dots", 0) is False + assert _xai_access_token_is_expiring("opaque-token-no-dots", 0) is False + assert _xai_access_token_is_expiring("", 0) is False + assert _xai_access_token_is_expiring(None, 0) is False # type: ignore[arg-type] + + +def test_xai_access_token_is_expiring_returns_false_for_jwt_without_exp(): + payload = {"sub": "user"} + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).rstrip(b"=").decode() + token = f"h.{encoded}.s" + assert _xai_access_token_is_expiring(token, 0) is False + + +# --------------------------------------------------------------------------- +# Loopback redirect URI validation +# --------------------------------------------------------------------------- + + +def test_xai_validate_loopback_redirect_uri_accepts_localhost_with_port(): + host, port, path = _xai_validate_loopback_redirect_uri( + "http://127.0.0.1:56121/callback" + ) + assert host == XAI_OAUTH_REDIRECT_HOST + assert port == 56121 + assert path == XAI_OAUTH_REDIRECT_PATH + + +def test_xai_validate_loopback_redirect_uri_rejects_https(): + with pytest.raises(AuthError) as exc: + _xai_validate_loopback_redirect_uri("https://127.0.0.1:56121/callback") + assert exc.value.code == "xai_redirect_invalid" + + +def test_xai_validate_loopback_redirect_uri_rejects_non_loopback(): + with pytest.raises(AuthError) as exc: + _xai_validate_loopback_redirect_uri("http://example.com:56121/callback") + assert exc.value.code == "xai_redirect_invalid" + + +def test_xai_validate_loopback_redirect_uri_rejects_missing_port(): + with pytest.raises(AuthError) as exc: + _xai_validate_loopback_redirect_uri("http://127.0.0.1/callback") + assert exc.value.code == "xai_redirect_invalid" + + +# --------------------------------------------------------------------------- +# Authorize URL construction +# --------------------------------------------------------------------------- + + +def _parse_authorize_url(url: str) -> dict: + from urllib.parse import urlparse, parse_qs + + parsed = urlparse(url) + return {k: v[0] for k, v in parse_qs(parsed.query).items()} + + +def test_xai_oauth_authorize_url_includes_plan_generic(): + """Regression: accounts.x.ai requires `plan=generic` for loopback OAuth on + non-allowlisted clients. Must always be present on the authorize URL.""" + url = _xai_oauth_build_authorize_url( + authorization_endpoint="https://auth.x.ai/oauth2/authorize", + redirect_uri="http://127.0.0.1:56121/callback", + code_challenge="challenge-xyz", + state="state-abc", + nonce="nonce-def", + ) + params = _parse_authorize_url(url) + assert params["plan"] == "generic" + + +def test_xai_oauth_authorize_url_includes_referrer_hermes_agent(): + """Attribution: xAI's OAuth server can identify Hermes-originated logins + via the referrer query param. Must always be present on the authorize URL.""" + url = _xai_oauth_build_authorize_url( + authorization_endpoint="https://auth.x.ai/oauth2/authorize", + redirect_uri="http://127.0.0.1:56121/callback", + code_challenge="challenge-xyz", + state="state-abc", + nonce="nonce-def", + ) + params = _parse_authorize_url(url) + assert params["referrer"] == "hermes-agent" + + +def test_xai_oauth_authorize_url_includes_pkce_and_oidc_params(): + url = _xai_oauth_build_authorize_url( + authorization_endpoint="https://auth.x.ai/oauth2/authorize", + redirect_uri="http://127.0.0.1:56121/callback", + code_challenge="challenge-xyz", + state="state-abc", + nonce="nonce-def", + ) + params = _parse_authorize_url(url) + assert params["response_type"] == "code" + assert params["client_id"] == XAI_OAUTH_CLIENT_ID + assert params["redirect_uri"] == "http://127.0.0.1:56121/callback" + assert params["scope"] == XAI_OAUTH_SCOPE + assert params["code_challenge"] == "challenge-xyz" + assert params["code_challenge_method"] == "S256" + assert params["state"] == "state-abc" + assert params["nonce"] == "nonce-def" + + +# --------------------------------------------------------------------------- +# CORS allowlist +# --------------------------------------------------------------------------- + + +def test_xai_callback_cors_origin_allowlist(): + assert _xai_callback_cors_origin("https://accounts.x.ai") == "https://accounts.x.ai" + assert _xai_callback_cors_origin("https://auth.x.ai") == "https://auth.x.ai" + + +def test_xai_callback_cors_origin_rejects_unknown_origin(): + assert _xai_callback_cors_origin("https://attacker.example.com") == "" + assert _xai_callback_cors_origin(None) == "" + assert _xai_callback_cors_origin("") == "" + + +# --------------------------------------------------------------------------- +# Token roundtrip + reads +# --------------------------------------------------------------------------- + + +def test_save_and_read_xai_oauth_tokens_roundtrip(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_xai_oauth_tokens( + { + "access_token": "at-1", + "refresh_token": "rt-1", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + }, + discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"}, + redirect_uri="http://127.0.0.1:56121/callback", + ) + data = _read_xai_oauth_tokens() + assert data["tokens"]["access_token"] == "at-1" + assert data["tokens"]["refresh_token"] == "rt-1" + assert data["redirect_uri"] == "http://127.0.0.1:56121/callback" + assert data["discovery"]["token_endpoint"] == "https://auth.x.ai/oauth2/token" + + +def test_read_xai_oauth_tokens_missing(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with pytest.raises(AuthError) as exc: + _read_xai_oauth_tokens() + assert exc.value.code == "xai_auth_missing" + assert exc.value.relogin_required is True + + +def test_read_xai_oauth_tokens_missing_access_token(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + _setup_hermes_auth(hermes_home, access_token="") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with pytest.raises(AuthError) as exc: + _read_xai_oauth_tokens() + assert exc.value.code == "xai_auth_missing_access_token" + assert exc.value.relogin_required is True + + +def test_read_xai_oauth_tokens_missing_refresh_token(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + _setup_hermes_auth(hermes_home, refresh_token="") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with pytest.raises(AuthError) as exc: + _read_xai_oauth_tokens() + assert exc.value.code == "xai_auth_missing_refresh_token" + assert exc.value.relogin_required is True + + +# --------------------------------------------------------------------------- +# Runtime credential resolution +# --------------------------------------------------------------------------- + + +def test_resolve_xai_runtime_credentials_returns_singleton_state(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) + monkeypatch.delenv("XAI_BASE_URL", raising=False) + + creds = resolve_xai_oauth_runtime_credentials() + assert creds["provider"] == "xai-oauth" + assert creds["api_key"] == fresh + assert creds["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL + assert creds["source"] == "hermes-auth-store" + assert creds["auth_mode"] == "oauth_pkce" + + +def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + expiring = _jwt_with_exp(int(time.time()) - 10) + _setup_hermes_auth( + hermes_home, + access_token=expiring, + refresh_token="rt-old", + discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"}, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + new_access = _jwt_with_exp(int(time.time()) + 3600) + called = {"count": 0} + + def _fake_refresh(tokens, **kwargs): + called["count"] += 1 + updated = dict(tokens) + updated["access_token"] = new_access + updated["refresh_token"] = "rt-new" + return updated + + monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _fake_refresh) + + creds = resolve_xai_oauth_runtime_credentials() + assert called["count"] == 1 + assert creds["api_key"] == new_access + + +def test_resolve_xai_runtime_credentials_force_refresh(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth( + hermes_home, + access_token=fresh, + discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"}, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + forced = _jwt_with_exp(int(time.time()) + 7200) + called = {"count": 0} + + def _fake_refresh(tokens, **kwargs): + called["count"] += 1 + updated = dict(tokens) + updated["access_token"] = forced + return updated + + monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _fake_refresh) + + creds = resolve_xai_oauth_runtime_credentials(force_refresh=True, refresh_if_expiring=False) + assert called["count"] == 1 + assert creds["api_key"] == forced + + +def test_resolve_xai_runtime_credentials_honours_env_base_url(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://custom.x.ai/v1/") + + creds = resolve_xai_oauth_runtime_credentials() + assert creds["base_url"] == "https://custom.x.ai/v1" + + +# --------------------------------------------------------------------------- +# Auth status surface +# --------------------------------------------------------------------------- + + +def test_get_xai_oauth_auth_status_logged_in_via_singleton(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + status = get_xai_oauth_auth_status() + assert status["logged_in"] is True + assert status["api_key"] == fresh + assert status["auth_mode"] == "oauth_pkce" + + +def test_get_xai_oauth_auth_status_logged_out(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + status = get_xai_oauth_auth_status() + assert status["logged_in"] is False + assert "error" in status + + +# --------------------------------------------------------------------------- +# refresh_xai_oauth_pure error handling +# --------------------------------------------------------------------------- + + +def test_refresh_xai_oauth_pure_requires_refresh_token(): + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure("at", "") + assert exc.value.code == "xai_auth_missing_refresh_token" + assert exc.value.relogin_required is True + + +def test_refresh_xai_oauth_pure_relogin_on_400(monkeypatch): + response = _StubHTTPResponse(400, {"error": "invalid_grant"}) + _patch_httpx_client(monkeypatch, response) + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_refresh_failed" + assert exc.value.relogin_required is True + + +def test_refresh_xai_oauth_pure_no_relogin_on_500(monkeypatch): + response = _StubHTTPResponse(503, "service unavailable") + _patch_httpx_client(monkeypatch, response) + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_refresh_failed" + assert exc.value.relogin_required is False + + +def test_refresh_xai_oauth_pure_returns_updated_tokens(monkeypatch): + new_access = _jwt_with_exp(int(time.time()) + 3600) + response = _StubHTTPResponse( + 200, + { + "access_token": new_access, + "refresh_token": "rt-rotated", + "id_token": "id-1", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + holder = _patch_httpx_client(monkeypatch, response) + + updated = refresh_xai_oauth_pure( + "at", "rt-old", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert updated["access_token"] == new_access + assert updated["refresh_token"] == "rt-rotated" + assert updated["id_token"] == "id-1" + assert updated["token_type"] == "Bearer" + assert updated["last_refresh"].endswith("Z") + client = holder["client"] + assert client is not None + _method, _args, kwargs = client.last_call + assert kwargs["data"]["grant_type"] == "refresh_token" + assert kwargs["data"]["refresh_token"] == "rt-old" + assert kwargs["data"]["client_id"] == XAI_OAUTH_CLIENT_ID + + +def test_refresh_xai_oauth_pure_keeps_refresh_token_when_response_omits_it(monkeypatch): + """Some OAuth providers don't rotate refresh tokens — preserve the old one.""" + new_access = _jwt_with_exp(int(time.time()) + 3600) + response = _StubHTTPResponse( + 200, + { + "access_token": new_access, + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + _patch_httpx_client(monkeypatch, response) + + updated = refresh_xai_oauth_pure( + "at", "rt-stable", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert updated["access_token"] == new_access + assert updated["refresh_token"] == "rt-stable" + + +def test_refresh_xai_oauth_pure_rejects_response_without_access_token(monkeypatch): + response = _StubHTTPResponse( + 200, + {"refresh_token": "rt-new", "expires_in": 3600}, + ) + _patch_httpx_client(monkeypatch, response) + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_refresh_missing_access_token" + assert exc.value.relogin_required is True + + +def test_refresh_xai_oauth_pure_raises_typed_error_on_malformed_json(monkeypatch): + """xAI returning HTTP 200 with a non-JSON body (captive portal, proxy + error page, etc.) must surface a typed AuthError, not a raw + ``json.JSONDecodeError`` traceback. Matches the qwen-oauth precedent + so the upstream UX layer (``format_auth_error``) can map the failure.""" + response = _StubHTTPResponse(200, ValueError("not json")) + response.text = "captive portal" + _patch_httpx_client(monkeypatch, response) + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_refresh_invalid_json" + + +def test_xai_oauth_discovery_raises_typed_error_on_malformed_json(monkeypatch): + """Discovery is a cold-start, one-time fetch. If the response is HTTP + 200 with a non-JSON body (corporate proxy / captive portal returning + HTML), surface a typed AuthError rather than letting the + ``json.JSONDecodeError`` escape — so the message reads as an auth + problem instead of an internal parsing crash.""" + from hermes_cli.auth import _xai_oauth_discovery + + class _BadJSON: + status_code = 200 + + def json(self): + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + monkeypatch.setattr( + "hermes_cli.auth.httpx.get", + lambda *a, **kw: _BadJSON(), + ) + with pytest.raises(AuthError) as exc: + _xai_oauth_discovery() + assert exc.value.code == "xai_discovery_invalid_json" + + +def test_xai_oauth_discovery_raises_typed_error_on_non_object_payload(monkeypatch): + """A discovery body that decodes as JSON but isn't an object (e.g. a + bare string or array) must not slip through and trigger an + ``AttributeError`` on ``payload.get(...)`` later. Reject loudly + with the same incomplete-response code the missing-endpoint path uses.""" + from hermes_cli.auth import _xai_oauth_discovery + + class _StubResponse: + status_code = 200 + + def json(self): + return ["not", "an", "object"] + + monkeypatch.setattr( + "hermes_cli.auth.httpx.get", + lambda *a, **kw: _StubResponse(), + ) + with pytest.raises(AuthError) as exc: + _xai_oauth_discovery() + assert exc.value.code == "xai_discovery_incomplete" + + +# --------------------------------------------------------------------------- +# OIDC discovery endpoint origin/scheme validation (MITM hardening) +# --------------------------------------------------------------------------- + + +def test_refresh_xai_oauth_pure_rejects_non_https_token_endpoint(monkeypatch): + """A poisoned auth.json (from MITM during initial discovery, or an older + Hermes that didn't validate) must not be silently honored on the refresh + hot path. A non-HTTPS ``token_endpoint`` would leak the refresh_token in + cleartext on every refresh; refuse before the POST.""" + # No HTTP stub installed — refresh must fail at validation, not at POST. + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="http://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_discovery_invalid" + + +def test_refresh_xai_oauth_pure_rejects_off_origin_token_endpoint(monkeypatch): + """Pin the cached token_endpoint host to the xAI origin. A one-time MITM + during discovery could persist a token_endpoint on attacker-controlled + infrastructure — every subsequent refresh would silently leak the + refresh_token to that attacker. Refuse off-origin endpoints loudly so + the user can re-run discovery.""" + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://evil.example.com/token" + ) + assert exc.value.code == "xai_discovery_invalid" + + +def test_refresh_xai_oauth_pure_rejects_lookalike_suffix(monkeypatch): + """Substring confusion: ``evil-x.ai`` ends in ``x.ai`` but is NOT a + ``.x.ai`` subdomain. The validator must enforce the leading-dot suffix + so attacker-registered apex lookalikes can't slip through.""" + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://evilx.ai/token" + ) + assert exc.value.code == "xai_discovery_invalid" + + +def test_refresh_xai_oauth_pure_accepts_apex_and_subdomain_endpoints(monkeypatch): + """The validator must accept BOTH the bare xAI apex (``x.ai``) and any + ``*.x.ai`` subdomain (e.g. ``auth.x.ai`` today, future migrations to + ``accounts.x.ai`` etc.). Without subdomain support we'd lock the + integration to whatever xAI happens to use today.""" + new_access = _jwt_with_exp(int(time.time()) + 3600) + response = _StubHTTPResponse( + 200, + {"access_token": new_access, "expires_in": 3600, "token_type": "Bearer"}, + ) + _patch_httpx_client(monkeypatch, response) + # auth.x.ai (current production) + updated = refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert updated["access_token"] == new_access + # hypothetical migration to accounts.x.ai + _patch_httpx_client(monkeypatch, response) + updated2 = refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://accounts.x.ai/token" + ) + assert updated2["access_token"] == new_access + + +def test_xai_oauth_discovery_validates_endpoints(monkeypatch): + """The discovery response itself goes through endpoint validation, so a + one-time MITM during initial login cannot poison ``auth.json`` with an + attacker-controlled ``token_endpoint``. (The persistence is what makes + this attack worth defending against — one MITM = forever credential + leak.)""" + from hermes_cli.auth import _xai_oauth_discovery + + class _StubGetResponse: + status_code = 200 + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + def _fake_get(url, headers=None, timeout=None): + return _StubGetResponse({ + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://evil.example.com/token", # poisoned + }) + + monkeypatch.setattr("hermes_cli.auth.httpx.get", _fake_get) + with pytest.raises(AuthError) as exc: + _xai_oauth_discovery() + assert exc.value.code == "xai_discovery_invalid" + + +def test_xai_oauth_discovery_validates_authorization_endpoint(monkeypatch): + """A poisoned ``authorization_endpoint`` is just as dangerous as a + poisoned ``token_endpoint``: it sends the user's browser (with their + logged-in xAI session cookies) to attacker infrastructure that can + phish the consent screen and exchange a stolen authorization code. + + Both endpoints must be validated independently. This test pins the + parity so nobody can later "optimise" by validating only the token + endpoint and silently lose authorization-endpoint defense.""" + from hermes_cli.auth import _xai_oauth_discovery + + class _StubGetResponse: + status_code = 200 + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + def _fake_get(url, headers=None, timeout=None): + return _StubGetResponse({ + "authorization_endpoint": "https://evil.example.com/authorize", # poisoned + "token_endpoint": "https://auth.x.ai/oauth2/token", + }) + + monkeypatch.setattr("hermes_cli.auth.httpx.get", _fake_get) + with pytest.raises(AuthError) as exc: + _xai_oauth_discovery() + assert exc.value.code == "xai_discovery_invalid" + + +# --------------------------------------------------------------------------- +# Pool seeding from singleton +# --------------------------------------------------------------------------- + + +def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch): + """After `hermes model` -> xai-oauth, the singleton holds tokens. load_pool + must surface that as a pool entry so `hermes auth list` reflects truth and + refreshes route through the pool consistently with codex.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + pool = load_pool("xai-oauth") + assert pool.has_credentials() + entries = pool.entries() + assert len(entries) == 1 + entry = entries[0] + assert entry.access_token == fresh + assert entry.refresh_token == "rt-1" + assert entry.source == "loopback_pkce" + assert entry.base_url == DEFAULT_XAI_OAUTH_BASE_URL + + +def test_credential_pool_does_not_seed_when_singleton_missing_access_token(tmp_path, monkeypatch): + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + auth_store = { + "version": 1, + "providers": { + "xai-oauth": { + "tokens": {"access_token": "", "refresh_token": "rt"}, + "auth_mode": "oauth_pkce", + } + }, + } + (hermes_home / "auth.json").write_text(json.dumps(auth_store)) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + pool = load_pool("xai-oauth") + assert not pool.has_credentials() + + +def test_credential_pool_seed_respects_suppression(tmp_path, monkeypatch): + """`hermes auth remove xai-oauth ` for the seeded entry suppresses + further re-seeding so the removal is stable across load_pool calls.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Suppress the source — mimic `hermes auth remove`. + from hermes_cli.auth import suppress_credential_source + + suppress_credential_source("xai-oauth", "loopback_pkce") + + pool = load_pool("xai-oauth") + assert not pool.has_credentials() + + +def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch): + """End-to-end regression: ``hermes auth remove xai-oauth 1`` for a + singleton-seeded entry must clear auth.json providers.xai-oauth AND + suppress further re-seeding — otherwise the next ``load_pool`` call + silently resurrects the entry from the still-present singleton, making + the user-facing removal a no-op (the entry reappears on the next + invocation with no warning). + + The bug pre-fix: there was no RemovalStep registered for + (xai-oauth, loopback_pkce), so ``find_removal_step`` returned None + and ``auth_remove_command`` fell through to the "unregistered source — + nothing to clean up" branch. That branch is correct for ``manual`` + entries (pool-only) but wrong for singleton-seeded loopback_pkce + entries (auth.json singleton survives the in-memory removal).""" + from agent.credential_pool import load_pool + from hermes_cli.auth_commands import auth_remove_command + from types import SimpleNamespace + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Confirm pre-state: pool sees the seeded entry, auth.json has the singleton. + pool = load_pool("xai-oauth") + assert pool.has_credentials() + raw = json.loads((hermes_home / "auth.json").read_text()) + assert "xai-oauth" in raw.get("providers", {}) + + # Act: the user runs `hermes auth remove xai-oauth 1`. + auth_remove_command(SimpleNamespace(provider="xai-oauth", target="1")) + + # Post-state: auth.json singleton must be cleared so a re-seed has + # nothing to import. + raw_after = json.loads((hermes_home / "auth.json").read_text()) + assert "xai-oauth" not in raw_after.get("providers", {}), ( + "auth.json providers.xai-oauth must be cleared — otherwise the " + "next load_pool() reseeds the removed entry from the surviving " + "singleton, silently undoing the user's removal." + ) + + # And the next load must not reseed the entry from anywhere. + pool_after = load_pool("xai-oauth") + assert not pool_after.has_credentials(), ( + "Removal must stick across load_pool() calls — without the " + "loopback_pkce RemovalStep, the seed function reads the singleton " + "and rebuilds the entry on every Hermes invocation." + ) + + +# --------------------------------------------------------------------------- +# Pool sync-back to singleton after refresh +# --------------------------------------------------------------------------- + + +def test_pool_sync_back_writes_to_singleton(tmp_path, monkeypatch): + """When the pool refreshes a singleton-seeded xAI entry, the new tokens + must be written back to providers["xai-oauth"] so that + resolve_xai_oauth_runtime_credentials() (which reads the singleton) + doesn't keep using the consumed refresh token.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + expired = _jwt_with_exp(int(time.time()) - 10) + _setup_hermes_auth(hermes_home, access_token=expired, refresh_token="rt-old") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + new_access = _jwt_with_exp(int(time.time()) + 3600) + + def _fake_refresh(access_token, refresh_token, **kwargs): + assert refresh_token == "rt-old" + return { + "access_token": new_access, + "refresh_token": "rt-new", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T01:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + selected = pool.select() + assert selected is not None + assert selected.access_token == new_access + assert selected.refresh_token == "rt-new" + + # Singleton must reflect refreshed tokens — otherwise the next process + # to load credentials would re-seed the consumed refresh token. + auth_path = hermes_home / "auth.json" + raw = json.loads(auth_path.read_text()) + state = raw["providers"]["xai-oauth"] + assert state["tokens"]["access_token"] == new_access + assert state["tokens"]["refresh_token"] == "rt-new" + assert state["last_refresh"] == "2026-05-15T01:00:00Z" + + +# --------------------------------------------------------------------------- +# Runtime provider routing +# --------------------------------------------------------------------------- + + +def test_runtime_provider_uses_pool_entry_for_xai_oauth(tmp_path, monkeypatch): + from hermes_cli.runtime_provider import resolve_runtime_provider + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) + monkeypatch.delenv("XAI_BASE_URL", raising=False) + + runtime = resolve_runtime_provider(requested="xai-oauth") + assert runtime["provider"] == "xai-oauth" + assert runtime["api_mode"] == "codex_responses" + assert runtime["api_key"] == fresh + assert runtime["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL + + +def test_runtime_provider_default_base_url_when_pool_entry_missing_url(tmp_path, monkeypatch): + """Edge case: a pool entry that somehow has an empty base_url should still + surface the default xAI inference base URL instead of an empty string.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) + monkeypatch.delenv("XAI_BASE_URL", raising=False) + + fresh = _jwt_with_exp(int(time.time()) + 3600) + pool = load_pool("xai-oauth") + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=fresh, + refresh_token="rt", + base_url="", + ) + ) + + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime = resolve_runtime_provider(requested="xai-oauth") + assert runtime["provider"] == "xai-oauth" + assert runtime["api_mode"] == "codex_responses" + assert runtime["api_key"] == fresh + assert runtime["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL + + +# --------------------------------------------------------------------------- +# Token-expiry behavior on the pool path +# --------------------------------------------------------------------------- + + +def test_pool_entry_needs_refresh_when_jwt_within_skew(tmp_path, monkeypatch): + """The pool's proactive-refresh gate must trigger when the JWT exp claim + is within the XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS window — otherwise a + near-expired token will hit the API and 401 unnecessarily. Mirrors the + Codex skew-window behavior.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + from hermes_cli.auth import XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Token expires in 30s — well inside the 120s skew window. + near_expiry = _jwt_with_exp(int(time.time()) + 30) + pool = load_pool("xai-oauth") + entry = PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=near_expiry, + refresh_token="rt", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + pool.add_entry(entry) + assert XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS > 30 + assert pool._entry_needs_refresh(entry) is True + + +def test_pool_entry_no_refresh_for_fresh_jwt(tmp_path, monkeypatch): + """A fresh JWT beyond the skew window must NOT trigger proactive refresh.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + fresh = _jwt_with_exp(int(time.time()) + 3600) + pool = load_pool("xai-oauth") + entry = PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=fresh, + refresh_token="rt", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + pool.add_entry(entry) + assert pool._entry_needs_refresh(entry) is False + + +def test_pool_select_proactively_refreshes_expiring_token(tmp_path, monkeypatch): + """End-to-end: pool.select() with refresh=True on an expiring entry must + return the refreshed token. This is the proactive path that runs BEFORE + the API call — separate from the 401-reactive path.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + near_expiry = _jwt_with_exp(int(time.time()) + 30) + new_access = _jwt_with_exp(int(time.time()) + 3600) + + refresh_calls = {"count": 0} + + def _fake_refresh(access_token, refresh_token, **kwargs): + refresh_calls["count"] += 1 + assert refresh_token == "rt-old" + return { + "access_token": new_access, + "refresh_token": "rt-new", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T01:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=near_expiry, + refresh_token="rt-old", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + ) + + selected = pool.select() + assert refresh_calls["count"] == 1 + assert selected is not None + assert selected.access_token == new_access + assert selected.refresh_token == "rt-new" + + +def test_pool_try_refresh_current_handles_xai_oauth(tmp_path, monkeypatch): + """The reactive 401-recovery path uses pool.try_refresh_current(). This + must work for xai-oauth alongside openai-codex — otherwise mid-call + expirations get propagated as hard failures instead of being retried with + fresh tokens.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Even a "fresh-looking" token gets force-refreshed via try_refresh_current. + # We simulate the scenario where the server rejected the token (401) + # despite client-side expiry math saying it's still valid (e.g. clock + # skew, server-side revocation, token bound to a session that expired). + seemingly_fresh = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 7200) + + def _fake_refresh(access_token, refresh_token, **kwargs): + return { + "access_token": new_access, + "refresh_token": "rt-rotated", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T02:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=seemingly_fresh, + refresh_token="rt-old", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + ) + pool.select() + refreshed = pool.try_refresh_current() + assert refreshed is not None + assert refreshed.access_token == new_access + assert refreshed.refresh_token == "rt-rotated" + + +def test_pool_refresh_marks_entry_exhausted_on_failure(tmp_path, monkeypatch): + """When the xAI refresh endpoint rejects the refresh_token (e.g. consumed + by another process, revoked), the pool must surface the failure cleanly + rather than silently retaining stale tokens. This is critical for the + failover path — _recover_with_credential_pool rotates to the next entry + only if try_refresh_current returns None.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + from hermes_cli.auth import AuthError + import uuid + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _fake_refresh_fail(*args, **kwargs): + raise AuthError("refresh_token_reused", code="xai_refresh_failed", relogin_required=True) + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh_fail) + + pool = load_pool("xai-oauth") + seemingly_fresh = _jwt_with_exp(int(time.time()) + 3600) + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="test", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=seemingly_fresh, + refresh_token="rt-revoked", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + ) + pool.select() + refreshed = pool.try_refresh_current() + # Refresh failure must return None so the caller falls through to + # credential rotation / friendly error display. + assert refreshed is None + + +def test_pool_seeded_entry_sync_back_after_refresh(tmp_path, monkeypatch): + """When an entry seeded from the singleton (source='loopback_pkce') + is refreshed by the pool, the new tokens must be written back so a + fresh process load doesn't re-seed the now-consumed refresh token.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + near_expiry = _jwt_with_exp(int(time.time()) + 30) + _setup_hermes_auth(hermes_home, access_token=near_expiry, refresh_token="rt-singleton") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + new_access = _jwt_with_exp(int(time.time()) + 3600) + + def _fake_refresh(access_token, refresh_token, **kwargs): + assert refresh_token == "rt-singleton" + return { + "access_token": new_access, + "refresh_token": "rt-rotated", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T03:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + selected = pool.select() + assert selected is not None + assert selected.access_token == new_access + + raw = json.loads((hermes_home / "auth.json").read_text()) + tokens = raw["providers"]["xai-oauth"]["tokens"] + assert tokens["access_token"] == new_access + assert tokens["refresh_token"] == "rt-rotated" + + +def test_pool_refresh_adopts_singleton_tokens_when_consumed_elsewhere(tmp_path, monkeypatch): + """Multi-process race: another Hermes process refreshed the singleton + (rotating the refresh_token) while this process held a stale in-memory + pool entry. ``_refresh_entry`` must adopt the fresher singleton tokens + BEFORE spending its own (now-consumed) refresh_token, otherwise the + refresh POST would replay the consumed token and fail with + ``refresh_token_reused``. + + Mirrors the proactive sync codex/nous already perform for the same + reason, and is what makes the pool actually safe to share across + profiles + Hermes processes.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + in_memory_at = _jwt_with_exp(int(time.time()) + 30) # near-expiry + _setup_hermes_auth(hermes_home, access_token=in_memory_at, refresh_token="rt-stale") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Load the pool once so the in-memory entry is seeded with rt-stale. + pool = load_pool("xai-oauth") + + # Now simulate "another process refreshed the tokens" by overwriting + # the singleton on disk WITHOUT touching this process's pool object. + other_process_at = _jwt_with_exp(int(time.time()) + 3600) + raw = json.loads((hermes_home / "auth.json").read_text()) + raw["providers"]["xai-oauth"]["tokens"] = { + "access_token": other_process_at, + "refresh_token": "rt-rotated-by-other-process", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + } + (hermes_home / "auth.json").write_text(json.dumps(raw)) + + refresh_calls = {"refresh_token_seen": None} + final_at = _jwt_with_exp(int(time.time()) + 7200) + + def _fake_refresh(access_token, refresh_token, **kwargs): + # The pool MUST have adopted the rotated token from auth.json before + # POSTing the refresh — otherwise it would replay the stale one. + refresh_calls["refresh_token_seen"] = refresh_token + return { + "access_token": final_at, + "refresh_token": "rt-final", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T05:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + selected = pool.select() + assert selected is not None + assert refresh_calls["refresh_token_seen"] == "rt-rotated-by-other-process" + assert selected.access_token == final_at + + +def test_pool_refresh_recovers_when_other_process_already_refreshed(tmp_path, monkeypatch): + """Variant of the multi-process race where the other process refreshes + BETWEEN our proactive sync and the HTTP POST. Our refresh fails with a + consumed-token error; we must re-check auth.json, find the fresh pair + (written by the racing process), and adopt it instead of marking the + entry exhausted.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + in_memory_at = _jwt_with_exp(int(time.time()) + 30) + _setup_hermes_auth(hermes_home, access_token=in_memory_at, refresh_token="rt-shared") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + pool = load_pool("xai-oauth") + + other_process_at = _jwt_with_exp(int(time.time()) + 3600) + + def _fake_refresh(access_token, refresh_token, **kwargs): + # Simulate the racing process winning at the auth server right + # before our POST: by the time we reach this call, auth.json + # already holds the fresher pair, but we POSTed with rt-shared. + raw = json.loads((hermes_home / "auth.json").read_text()) + raw["providers"]["xai-oauth"]["tokens"] = { + "access_token": other_process_at, + "refresh_token": "rt-rotated", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + } + (hermes_home / "auth.json").write_text(json.dumps(raw)) + raise AuthError( + "refresh_token_reused", + provider="xai-oauth", + code="xai_refresh_failed", + relogin_required=True, + ) + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + selected = pool.select() + # Even though refresh_xai_oauth_pure raised, the post-failure + # recovery path should adopt the fresher singleton tokens. + assert selected is not None + assert selected.access_token == other_process_at + assert selected.refresh_token == "rt-rotated" + + +def test_pool_exhausted_xai_entry_recovers_after_singleton_refresh(tmp_path, monkeypatch): + """When a singleton-seeded entry is parked as STATUS_EXHAUSTED and the + user runs ``hermes model`` -> xAI Grok OAuth (or another process + refreshes), the next ``_available_entries`` pass must adopt the fresh + auth.json tokens instead of leaving the entry frozen until the + cooldown elapses. Mirrors the codex/nous self-heal pattern.""" + from agent.credential_pool import load_pool, STATUS_EXHAUSTED + from dataclasses import replace + + hermes_home = tmp_path / "hermes" + stale_at = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=stale_at, refresh_token="rt-stale") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + pool = load_pool("xai-oauth") + seeded = pool.entries()[0] + assert seeded.source == "loopback_pkce" + + # Park the seeded entry as exhausted with a far-future cooldown so + # without resync it would never be selectable. + exhausted = replace( + seeded, + last_status=STATUS_EXHAUSTED, + last_status_at=time.time(), + last_error_code=401, + last_error_reset_at=time.time() + 3600, # 1h cooldown + ) + pool._replace_entry(seeded, exhausted) + pool._persist() + assert pool.has_credentials() + assert not pool.has_available() # cooldown blocks everything + + # Simulate the user re-running `hermes model` -> xAI Grok OAuth: the + # singleton now has fresh tokens. + fresh_at = _jwt_with_exp(int(time.time()) + 7200) + raw = json.loads((hermes_home / "auth.json").read_text()) + raw["providers"]["xai-oauth"]["tokens"] = { + "access_token": fresh_at, + "refresh_token": "rt-fresh", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + } + (hermes_home / "auth.json").write_text(json.dumps(raw)) + + # _available_entries must sync from the singleton, lifting the + # exhausted state for the seeded entry. + available = pool._available_entries(clear_expired=True, refresh=False) + assert len(available) == 1 + assert available[0].access_token == fresh_at + assert available[0].refresh_token == "rt-fresh" + assert available[0].last_status != STATUS_EXHAUSTED + + +def test_pool_manual_xai_entry_not_synced_from_singleton(tmp_path, monkeypatch): + """Sync from the singleton must apply ONLY to the singleton-seeded + entry (source='loopback_pkce'). Manually added entries (e.g. via + ``hermes auth add xai-oauth``) own their own refresh-token lifecycle + and must not be silently overwritten when the user logs in via + ``hermes model``.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + singleton_at = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + pool = load_pool("xai-oauth") + + manual_at_old = _jwt_with_exp(int(time.time()) + 30) + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="manual", + auth_type=AUTH_TYPE_OAUTH, + priority=1, + source="manual:xai_pkce", + access_token=manual_at_old, + refresh_token="rt-manual", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + ) + manual_entry = next(e for e in pool.entries() if e.source == "manual:xai_pkce") + synced = pool._sync_xai_oauth_entry_from_auth_store(manual_entry) + # Same object — no sync happened. + assert synced is manual_entry + assert synced.access_token == manual_at_old + assert synced.refresh_token == "rt-manual" + + +def test_pool_manual_entry_does_not_sync_back_to_singleton(tmp_path, monkeypatch): + """`hermes auth add xai-oauth` entries (source='manual:xai_pkce') are + independent credentials and must NOT write to the singleton. Sync-back + is restricted to entries seeded from the singleton. Otherwise adding a + second pool credential would silently overwrite the user's main login.""" + from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential + import uuid + + hermes_home = tmp_path / "hermes" + # Singleton has its own tokens (separate login). + singleton_at = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + manual_at_old = _jwt_with_exp(int(time.time()) + 30) + manual_at_new = _jwt_with_exp(int(time.time()) + 7200) + + def _fake_refresh(access_token, refresh_token, **kwargs): + assert refresh_token == "rt-manual" + return { + "access_token": manual_at_new, + "refresh_token": "rt-manual-new", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T04:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + pool.add_entry( + PooledCredential( + provider="xai-oauth", + id=uuid.uuid4().hex[:6], + label="manual", + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source="manual:xai_pkce", + access_token=manual_at_old, + refresh_token="rt-manual", + base_url=DEFAULT_XAI_OAUTH_BASE_URL, + ) + ) + # Refresh the manual entry — singleton must be left alone. + manual_entries = [e for e in pool.entries() if e.source == "manual:xai_pkce"] + assert len(manual_entries) == 1 + pool._refresh_entry(manual_entries[0], force=True) + + raw = json.loads((hermes_home / "auth.json").read_text()) + tokens = raw["providers"]["xai-oauth"]["tokens"] + # Singleton must be untouched — manual refresh shouldn't leak across. + assert tokens["access_token"] == singleton_at + assert tokens["refresh_token"] == "rt-singleton" + + +# --------------------------------------------------------------------------- +# Auxiliary client routing +# --------------------------------------------------------------------------- + + +def test_auxiliary_client_routes_xai_oauth_through_responses_api(tmp_path, monkeypatch): + """Without explicit xai-oauth handling in ``resolve_provider_client``, an + xai-oauth main provider falls through to the generic ``oauth_external`` + arm and returns ``(None, None)`` — silently re-routing every auxiliary + task (compression, curator, web extract, session search, ...) to + whatever Step-2 fallback chain the user has configured (OpenRouter, + Nous, etc.). Users on xAI Grok OAuth would then see surprise charges + on those side providers for side tasks they thought were running on + their xAI subscription. + + Pin the routing contract: ``resolve_provider_client("xai-oauth", model)`` + must return a non-None client wrapping the xAI Responses API.""" + from agent.auxiliary_client import ( + CodexAuxiliaryClient, + resolve_provider_client, + ) + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) + monkeypatch.delenv("XAI_BASE_URL", raising=False) + + client, model = resolve_provider_client("xai-oauth", model="grok-4") + assert client is not None, ( + "xai-oauth must route to a Responses-API client; falling through to " + "the generic oauth_external branch silently swaps providers for " + "every auxiliary task." + ) + assert isinstance(client, CodexAuxiliaryClient) + assert model == "grok-4" + # The wrapper preserves base_url + api_key so async wrappers and cache + # eviction can introspect them. Pin both to the live xAI runtime. + assert str(client.base_url).rstrip("/") == DEFAULT_XAI_OAUTH_BASE_URL + assert client.api_key == fresh + + +def test_auxiliary_client_xai_oauth_returns_none_when_unauthenticated(tmp_path, monkeypatch): + """No xAI OAuth tokens in the auth store → ``resolve_provider_client`` + must return ``(None, None)`` so ``_resolve_auto`` falls through to the + next provider in the chain instead of crashing or constructing a + misconfigured client.""" + from agent.auxiliary_client import resolve_provider_client + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + client, model = resolve_provider_client("xai-oauth", model="grok-4") + assert client is None + assert model is None + + +def test_auxiliary_client_xai_oauth_requires_explicit_model(tmp_path, monkeypatch): + """xAI's Responses API has no safe "cheap aux model" default — + pinning one would silently rot the same way Codex's did. Callers + must pass an explicit model (auxiliary..model in config.yaml).""" + from agent.auxiliary_client import resolve_provider_client + + hermes_home = tmp_path / "hermes" + fresh = _jwt_with_exp(int(time.time()) + 3600) + _setup_hermes_auth(hermes_home, access_token=fresh) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + client, model = resolve_provider_client("xai-oauth", model=None) + assert client is None + assert model is None + + +# --------------------------------------------------------------------------- +# active_provider preservation on pool sync-back +# --------------------------------------------------------------------------- + + +def test_pool_sync_back_preserves_active_provider(tmp_path, monkeypatch): + """A token-rotation sync-back is a side effect of refresh, not the user + picking a provider. ``_save_provider_state`` flips ``active_provider``; + using it on the sync-back path means every xAI/Codex/Nous refresh in a + multi-provider setup silently overrides the user's chosen active + provider (visible to ``hermes auth status``, ``hermes setup``, and the + ``hermes`` no-arg dispatcher). Pin the ``set_active=False`` contract so + no future refactor regresses to the legacy semantic.""" + from agent.credential_pool import load_pool + + hermes_home = tmp_path / "hermes" + near_expiry = _jwt_with_exp(int(time.time()) + 30) + _setup_hermes_auth(hermes_home, access_token=near_expiry, refresh_token="rt-xai") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Simulate a multi-provider user whose actual chosen provider is + # OpenRouter — xai-oauth tokens exist in the singleton but are NOT + # the active provider. + raw = json.loads((hermes_home / "auth.json").read_text()) + raw["active_provider"] = "openrouter" + (hermes_home / "auth.json").write_text(json.dumps(raw)) + + new_access = _jwt_with_exp(int(time.time()) + 3600) + + def _fake_refresh(access_token, refresh_token, **kwargs): + return { + "access_token": new_access, + "refresh_token": "rt-rotated", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + "last_refresh": "2026-05-15T10:00:00Z", + } + + monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh) + + pool = load_pool("xai-oauth") + selected = pool.select() + assert selected is not None + assert selected.access_token == new_access + + # The refresh wrote new tokens back into the singleton — the user's + # prior ``active_provider`` choice (openrouter) MUST survive. + raw_after = json.loads((hermes_home / "auth.json").read_text()) + assert raw_after["active_provider"] == "openrouter", ( + "pool sync-back must not flip active_provider; otherwise xAI/Codex/" + "Nous token rotations silently take over multi-provider users' " + "auth.json `active_provider` flag." + ) + # Tokens were actually written so the next process won't replay the + # consumed refresh_token (preserves the original sync-back fix). + state = raw_after["providers"]["xai-oauth"]["tokens"] + assert state["access_token"] == new_access + assert state["refresh_token"] == "rt-rotated" diff --git a/tests/plugins/image_gen/test_xai_provider.py b/tests/plugins/image_gen/test_xai_provider.py index b5cfdf16a9b..88ce31813e4 100644 --- a/tests/plugins/image_gen/test_xai_provider.py +++ b/tests/plugins/image_gen/test_xai_provider.py @@ -72,10 +72,13 @@ class TestXAIImageGenProvider: provider = XAIImageGenProvider() schema = provider.get_setup_schema() - assert schema["name"] == "xAI (Grok)" + assert schema["name"] == "xAI Grok Imagine (image)" assert schema["badge"] == "paid" - assert len(schema["env_vars"]) == 1 - assert schema["env_vars"][0]["key"] == "XAI_API_KEY" + # Auth resolution is delegated to the shared "xai_grok" post_setup + # hook so the picker doesn't blindly prompt for XAI_API_KEY when the + # user is already signed in via xAI Grok OAuth. + assert schema["env_vars"] == [] + assert schema["post_setup"] == "xai_grok" # --------------------------------------------------------------------------- diff --git a/tests/plugins/video_gen/test_xai_plugin.py b/tests/plugins/video_gen/test_xai_plugin.py index 25695d852e5..bd7a880fdee 100644 --- a/tests/plugins/video_gen/test_xai_plugin.py +++ b/tests/plugins/video_gen/test_xai_plugin.py @@ -54,6 +54,50 @@ def test_xai_generate_requires_xai_key(monkeypatch): assert result["error_type"] == "auth_required" +def test_xai_available_with_oauth_only(monkeypatch): + """The plugin must honour xAI Grok OAuth credentials, not just + XAI_API_KEY. Otherwise the agent's tool-availability check filters + ``video_generate`` out of the toolbelt and the agent silently falls + back to whatever skill advertises video generation (e.g. comfyui). + """ + import plugins.video_gen.xai as xai_plugin + + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.setattr( + "tools.xai_http.resolve_xai_http_credentials", + lambda: { + "provider": "xai-oauth", + "api_key": "oauth-bearer-token", + "base_url": "https://api.x.ai/v1", + }, + ) + + assert xai_plugin.XAIVideoGenProvider().is_available() is True + + +def test_xai_resolved_credentials_threaded_through_request(monkeypatch): + """OAuth-resolved creds must reach the HTTP layer — bug class where + ``is_available()`` says yes but the request still hits with no key. + """ + import plugins.video_gen.xai as xai_plugin + + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.setattr( + "tools.xai_http.resolve_xai_http_credentials", + lambda: { + "provider": "xai-oauth", + "api_key": "oauth-bearer-token", + "base_url": "https://api.x.ai/v1", + }, + ) + + api_key, base_url = xai_plugin._resolve_xai_credentials() + assert api_key == "oauth-bearer-token" + assert base_url == "https://api.x.ai/v1" + headers = xai_plugin._xai_headers(api_key) + assert headers["Authorization"] == "Bearer oauth-bearer-token" + + def test_xai_no_operation_kwarg(): """The ABC's generate() signature no longer accepts 'operation'. Passing it through **kwargs should be ignored (forward-compat).""" diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 47c491c441c..8cc02629523 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -578,6 +578,197 @@ def test_run_conversation_codex_refreshes_after_401_and_retries(monkeypatch): assert result["final_response"] == "Recovered after refresh" +def _build_xai_oauth_agent(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="grok-code-fast-1", + provider="xai-oauth", + api_mode="codex_responses", + base_url="https://api.x.ai/v1", + api_key="xai-oauth-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + ) + agent._cleanup_task_resources = lambda task_id: None + agent._persist_session = lambda messages, history=None: None + agent._save_trajectory = lambda messages, user_message, completed: None + agent._save_session_log = lambda messages: None + return agent + + +def test_build_api_kwargs_xai_oauth_sends_cache_key_via_extra_body(monkeypatch): + """xai-oauth + codex_responses must route prompt caching via the + ``prompt_cache_key`` body field on /v1/responses (xAI's documented + Responses-API cache key — see docs.x.ai prompt-caching/maximizing- + cache-hits). + + We pass it through ``extra_body`` rather than as a top-level kwarg so + the body field is serialized into JSON regardless of whether the + installed openai SDK build still accepts ``prompt_cache_key`` on + ``Responses.stream()``. Older or trimmed SDK builds drop it from the + signature and would otherwise raise ``TypeError`` before the request + reaches api.x.ai. The ``x-grok-conv-id`` header is retained as a + belt-and-braces fallback for clients/proxies that route on headers.""" + agent = _build_xai_oauth_agent(monkeypatch) + kwargs = agent._build_api_kwargs( + [ + {"role": "system", "content": "You are Hermes."}, + {"role": "user", "content": "Ping"}, + ] + ) + + assert kwargs.get("model") == "grok-code-fast-1" + # Top-level kwarg must NOT be set — that's the openai SDK + # incompatibility this whole indirection exists to dodge. + assert "prompt_cache_key" not in kwargs + extra_body = kwargs.get("extra_body") or {} + assert extra_body.get("prompt_cache_key"), ( + "xAI prompt-cache routing must travel via extra_body.prompt_cache_key " + "for /v1/responses — body field is the documented surface." + ) + headers = kwargs.get("extra_headers") or {} + assert "x-grok-conv-id" in headers, ( + "x-grok-conv-id header kept as belt-and-braces fallback for clients " + "that route on headers." + ) + + +def test_run_conversation_xai_oauth_refreshes_after_401_and_retries(monkeypatch): + """xai-oauth speaks the Responses API just like codex. When the access + token is rejected mid-call (401), the same proactive refresh-and-retry + handler that fires for openai-codex must also fire for xai-oauth — the + bug it caught: the gating condition checked only ``provider == "openai-codex"``, + so xai-oauth 401s leaked straight to non-retryable abort path with no + chance to swap in a freshly refreshed access token.""" + agent = _build_xai_oauth_agent(monkeypatch) + calls = {"api": 0, "refresh": 0} + + class _UnauthorizedError(RuntimeError): + def __init__(self): + super().__init__("Error code: 401 - unauthorized") + self.status_code = 401 + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _UnauthorizedError() + return _codex_message_response("Recovered after xAI refresh") + + def _fake_refresh(*, force=True): + calls["refresh"] += 1 + assert force is True + return True + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + monkeypatch.setattr(agent, "_try_refresh_codex_client_credentials", _fake_refresh) + + result = agent.run_conversation("Say OK") + + assert calls["api"] == 2 + assert calls["refresh"] == 1 + assert result["completed"] is True + assert result["final_response"] == "Recovered after xAI refresh" + + +def test_try_refresh_codex_client_credentials_handles_xai_oauth(monkeypatch): + """``_try_refresh_codex_client_credentials`` must rebuild the OpenAI + client with freshly resolved xAI OAuth credentials when the active + provider is xai-oauth. The function name is shared between codex and + xai-oauth (both speak codex_responses) — covering both cases prevents + silent regressions where the function gets gated to a single provider.""" + agent = _build_xai_oauth_agent(monkeypatch) + closed = {"value": False} + rebuilt = {"kwargs": None} + + class _ExistingClient: + def close(self): + closed["value"] = True + + class _RebuiltClient: + pass + + def _fake_openai(**kwargs): + rebuilt["kwargs"] = kwargs + return _RebuiltClient() + + def _fake_resolve(force_refresh=False, refresh_if_expiring=True, **_): + # The pre-refresh guard reads the singleton with refresh_if_expiring=False + # to verify that the agent's active key still matches; the actual + # refresh later passes force_refresh=True. Both calls must succeed. + return { + "api_key": "fresh-xai-token" if force_refresh else agent.api_key, + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "hermes_cli.auth.resolve_xai_oauth_runtime_credentials", + _fake_resolve, + ) + monkeypatch.setattr(run_agent, "OpenAI", _fake_openai) + + agent.client = _ExistingClient() + ok = agent._try_refresh_codex_client_credentials(force=True) + + assert ok is True + assert closed["value"] is True + assert rebuilt["kwargs"]["api_key"] == "fresh-xai-token" + assert rebuilt["kwargs"]["base_url"] == "https://api.x.ai/v1" + assert isinstance(agent.client, _RebuiltClient) + assert agent.api_key == "fresh-xai-token" + + +def test_try_refresh_codex_client_credentials_skips_xai_oauth_when_singleton_differs(monkeypatch): + """An xai-oauth agent constructed with a non-singleton credential + (e.g. a manual pool entry whose tokens belong to a different account + than the loopback_pkce singleton, or an explicit ``api_key=`` arg) + MUST NOT silently adopt the singleton's tokens on a 401 reactive + refresh. Otherwise a 401 mid-conversation would re-route the rest + of the conversation onto a different account, with no user feedback. + + The credential pool's reactive recovery is the right channel for + pool-managed credentials; this fallback path is for the singleton- + only case and must short-circuit when the active key differs.""" + agent = _build_xai_oauth_agent(monkeypatch) + # Agent is using "xai-oauth-token" (per the builder); singleton holds + # a *different* account's token. No force_refresh should fire. + refresh_calls = {"count": 0} + + def _fake_resolve(force_refresh=False, refresh_if_expiring=True, **_): + if force_refresh: + refresh_calls["count"] += 1 + return { + "api_key": "singleton-account-token", + "base_url": "https://api.x.ai/v1", + } + # The pre-refresh guard read — return the singleton's view of the + # singleton's token, which is NOT what the agent is currently using. + return { + "api_key": "singleton-account-token", + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "hermes_cli.auth.resolve_xai_oauth_runtime_credentials", + _fake_resolve, + ) + + pre_refresh_key = agent.api_key + ok = agent._try_refresh_codex_client_credentials(force=True) + + assert ok is False, ( + "must not refresh when the active credential isn't the singleton; " + "otherwise the conversation silently swaps accounts mid-flight." + ) + assert refresh_calls["count"] == 0, ( + "force_refresh must not run — that would mutate the singleton's " + "tokens on disk and consume its single-use refresh_token for an " + "agent that wasn't even using the singleton." + ) + assert agent.api_key == pre_refresh_key + + def test_run_conversation_copilot_refreshes_after_401_and_retries(monkeypatch): agent = _build_copilot_agent(monkeypatch) calls = {"api": 0, "refresh": 0} @@ -624,12 +815,18 @@ def test_try_refresh_codex_client_credentials_rebuilds_client(monkeypatch): rebuilt["kwargs"] = kwargs return _RebuiltClient() + def _fake_resolve(force_refresh=False, refresh_if_expiring=True, **_): + # Pre-refresh guard reads the singleton (refresh_if_expiring=False). + # It must report the agent's current api_key so the equality check + # passes; only then does the actual force_refresh run. + return { + "api_key": "new-codex-token" if force_refresh else agent.api_key, + "base_url": "https://chatgpt.com/backend-api/codex", + } + monkeypatch.setattr( "hermes_cli.auth.resolve_codex_runtime_credentials", - lambda force_refresh=True: { - "api_key": "new-codex-token", - "base_url": "https://chatgpt.com/backend-api/codex", - }, + _fake_resolve, ) monkeypatch.setattr(run_agent, "OpenAI", _fake_openai) diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 942fba01120..6f6d2f8c2a3 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -266,10 +266,12 @@ def _get_provider(stt_config: dict) -> str: return "none" if provider == "xai": - if get_env_value("XAI_API_KEY"): + from tools.xai_http import resolve_xai_http_credentials + + if resolve_xai_http_credentials().get("api_key"): return "xai" logger.warning( - "STT provider 'xai' configured but XAI_API_KEY not set" + "STT provider 'xai' configured but no xAI credentials are available" ) return "none" @@ -289,9 +291,14 @@ def _get_provider(stt_config: dict) -> str: if _HAS_OPENAI and _has_openai_audio_backend(): logger.info("No local STT available, using OpenAI Whisper API") return "openai" - if get_env_value("XAI_API_KEY"): - logger.info("No local STT available, using xAI Grok STT API") - return "xai" + try: + from tools.xai_http import resolve_xai_http_credentials + + if resolve_xai_http_credentials().get("api_key"): + logger.info("No local STT available, using xAI Grok STT API") + return "xai" + except Exception: + pass return "none" # --------------------------------------------------------------------------- @@ -704,14 +711,22 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: Supports Inverse Text Normalization, diarization, and word-level timestamps. Requires ``XAI_API_KEY`` environment variable. """ - api_key = get_env_value("XAI_API_KEY") + from tools.xai_http import resolve_xai_http_credentials + + creds = resolve_xai_http_credentials() + api_key = str(creds.get("api_key") or "").strip() if not api_key: - return {"success": False, "transcript": "", "error": "XAI_API_KEY not set"} + return { + "success": False, + "transcript": "", + "error": "No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY", + } stt_config = _load_stt_config() xai_config = stt_config.get("xai", {}) base_url = str( xai_config.get("base_url") + or creds.get("base_url") or get_env_value("XAI_STT_BASE_URL") or XAI_STT_BASE_URL ).strip().rstrip("/") @@ -872,7 +887,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A "No STT provider available. Install faster-whisper for free local " f"transcription, configure {LOCAL_STT_COMMAND_ENV} or install a local whisper CLI, " "set GROQ_API_KEY for free Groq Whisper, set MISTRAL_API_KEY for Mistral " - "Voxtral Transcribe, set XAI_API_KEY for xAI Grok STT, or set VOICE_TOOLS_OPENAI_KEY " + "Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, or set VOICE_TOOLS_OPENAI_KEY " "or OPENAI_API_KEY for the OpenAI Whisper API." ), } diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 9f0d272dac0..57907f76833 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -9,7 +9,7 @@ Built-in TTS providers: - MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY - Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY - Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY -- xAI TTS: Grok voices, needs XAI_API_KEY +- xAI TTS: Grok voices, uses xAI Grok OAuth credentials or XAI_API_KEY - NeuTTS (local, free, no API key): On-device TTS via neutts - KittenTTS (local, free, no API key): On-device 25MB model - Piper (local, free, no API key): OHF-Voice/piper1-gpl neural VITS, 44 languages @@ -902,9 +902,12 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - """ import requests - api_key = (get_env_value("XAI_API_KEY") or "").strip() + from tools.xai_http import resolve_xai_http_credentials + + creds = resolve_xai_http_credentials() + api_key = str(creds.get("api_key") or "").strip() if not api_key: - raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/") + raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.") xai_config = tts_config.get("xai", {}) voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID @@ -913,6 +916,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE)) base_url = str( xai_config.get("base_url") + or creds.get("base_url") or get_env_value("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL ).strip().rstrip("/") @@ -1917,8 +1921,13 @@ def check_tts_requirements() -> bool: pass if get_env_value("MINIMAX_API_KEY"): return True - if get_env_value("XAI_API_KEY"): - return True + try: + from tools.xai_http import resolve_xai_http_credentials + + if resolve_xai_http_credentials().get("api_key"): + return True + except Exception: + pass if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"): return True try: diff --git a/tools/xai_http.py b/tools/xai_http.py index b5bce97c2f4..fbb7961d244 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +from typing import Dict + def hermes_xai_user_agent() -> str: """Return a stable Hermes-specific User-Agent for xAI HTTP calls.""" @@ -10,3 +13,49 @@ def hermes_xai_user_agent() -> str: except Exception: __version__ = "unknown" return f"Hermes-Agent/{__version__}" + + +def resolve_xai_http_credentials() -> Dict[str, str]: + """Resolve bearer credentials for direct xAI HTTP endpoints. + + Prefers Hermes-managed xAI OAuth credentials when available, then falls back + to ``XAI_API_KEY`` from the environment. This keeps direct xAI endpoints + (images, TTS, STT, etc.) aligned with the main runtime auth model. + """ + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime = resolve_runtime_provider(requested="xai-oauth") + access_token = str(runtime.get("api_key") or "").strip() + base_url = str(runtime.get("base_url") or "").strip().rstrip("/") + if access_token: + return { + "provider": "xai-oauth", + "api_key": access_token, + "base_url": base_url or "https://api.x.ai/v1", + } + except Exception: + pass + + try: + from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + + creds = resolve_xai_oauth_runtime_credentials() + access_token = str(creds.get("api_key") or "").strip() + base_url = str(creds.get("base_url") or "").strip().rstrip("/") + if access_token: + return { + "provider": "xai-oauth", + "api_key": access_token, + "base_url": base_url or "https://api.x.ai/v1", + } + except Exception: + pass + + api_key = os.getenv("XAI_API_KEY", "").strip() + base_url = (os.getenv("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") + return { + "provider": "xai", + "api_key": api_key, + "base_url": base_url, + } diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md new file mode 100644 index 00000000000..49c7087621a --- /dev/null +++ b/website/docs/guides/xai-grok-oauth.md @@ -0,0 +1,214 @@ +--- +sidebar_position: 16 +title: "xAI Grok OAuth (SuperGrok Subscription)" +description: "Sign in with your SuperGrok subscription to use Grok models in Hermes Agent — no API key required" +--- + +# xAI Grok OAuth (SuperGrok Subscription) + +Hermes Agent supports xAI Grok through a browser-based OAuth login flow against [accounts.x.ai](https://accounts.x.ai), using your existing **SuperGrok subscription**. No `XAI_API_KEY` is required — log in once and Hermes automatically refreshes your session in the background. + +The transport reuses the `codex_responses` adapter (xAI exposes a Responses-style endpoint), so reasoning, tool-calling, streaming, and prompt caching work without any adapter changes. + +The same OAuth bearer token is also reused by every direct-to-xAI surface in Hermes — TTS, image generation, video generation, and transcription — so a single login covers all four. + +## Overview + +| Item | Value | +|------|-------| +| Provider ID | `xai-oauth` | +| Display name | xAI Grok OAuth (SuperGrok Subscription) | +| Auth type | Browser OAuth 2.0 PKCE (loopback callback) | +| Transport | xAI Responses API (`codex_responses`) | +| Default model | `grok-4.3` | +| Endpoint | `https://api.x.ai/v1` | +| Auth server | `https://accounts.x.ai` | +| Requires env var | No (`XAI_API_KEY` is **not** used for this provider) | +| Subscription | [SuperGrok](https://x.ai/grok) (any active tier) | + +## Prerequisites + +- Python 3.9+ +- Hermes Agent installed +- An active SuperGrok subscription on your xAI account +- A browser available on the local machine (or use `--no-browser` for remote sessions) + +## Quick Start + +```bash +# Launch the provider and model picker +hermes model +# → Select "xAI Grok OAuth (SuperGrok Subscription)" from the provider list +# → Hermes opens your browser to accounts.x.ai +# → Approve access in the browser +# → Pick a model (grok-4.3 is at the top) +# → Start chatting + +hermes +``` + +After the first login, credentials are stored under `~/.hermes/auth.json` and refreshed automatically before they expire. + +## Logging In Manually + +You can trigger a login without going through the model picker: + +```bash +hermes auth add xai-oauth +``` + +### Remote / headless sessions + +On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser. Open the URL on any device with a browser, complete the consent flow, and Hermes finishes the loopback exchange when the redirect comes back. + +If you need to force this behaviour explicitly: + +```bash +hermes auth add xai-oauth --no-browser +``` + +## How the Login Works + +1. Hermes opens your browser to `accounts.x.ai`. +2. You sign in (or confirm your existing session) and approve access. +3. xAI redirects back to Hermes and the tokens are saved to `~/.hermes/auth.json`. +4. From then on, Hermes refreshes the access token in the background — you stay signed in until you `hermes auth remove xai-oauth` or revoke access from your xAI account settings. + +## Checking Login Status + +```bash +hermes doctor +``` + +The `◆ Auth Providers` section will show the current state of every provider, including `xai-oauth`. + +## Switching Models + +```bash +hermes model +# → Select "xAI Grok OAuth (SuperGrok Subscription)" +# → Pick from the model list (grok-4.3 is pinned to the top) +``` + +Or set the model directly: + +```bash +hermes config set model.default grok-4.3 +hermes config set model.provider xai-oauth +``` + +## Configuration Reference + +After login, `~/.hermes/config.yaml` will contain: + +```yaml +model: + default: grok-4.3 + provider: xai-oauth + base_url: https://api.x.ai/v1 +``` + +### Provider aliases + +All of the following resolve to `xai-oauth`: + +```bash +hermes --provider xai-oauth # canonical +hermes --provider grok-oauth # alias +hermes --provider x-ai-oauth # alias +hermes --provider xai-grok-oauth # alias +``` + +## Direct-to-xAI Tools (TTS / Image / Video / Transcription) + +Once you're logged in via OAuth, every direct-to-xAI tool reuses the same bearer token automatically — there is **no separate setup** unless you'd rather use an API key. + +To pick a backend for each tool: + +```bash +hermes tools +# → Text-to-Speech → "xAI TTS" +# → Image Generation → "xAI Grok Imagine (image)" +# → Video Generation → "xAI Grok Imagine" +``` + +If OAuth tokens are already stored, the picker confirms it and skips the credential prompt. If neither OAuth nor `XAI_API_KEY` is set, the picker offers a 3-choice menu: OAuth login, paste API key, or skip. + +:::note Video generation is off by default +The `video_gen` toolset is disabled by default. Enable it in `hermes tools` → `🎬 Video Generation` (press space) before the agent can call `video_generate`. Otherwise the agent may fall back to the bundled ComfyUI skill, which is also tagged for video generation. +::: + +### Models + +| Tool | Model | Notes | +|------|-------|-------| +| Chat | `grok-4.3` | Default; auto-selected when you log in via OAuth | +| Chat | `grok-4.20-0309-reasoning` | Reasoning variant | +| Chat | `grok-4.20-0309-non-reasoning` | Non-reasoning variant | +| Chat | `grok-4.20-multi-agent-0309` | Multi-agent variant | +| Image | `grok-imagine-image` | Default; ~5–10 s | +| Image | `grok-imagine-image-quality` | Higher fidelity; ~10–20 s | +| Video | `grok-imagine-video` | Text-to-video and image-to-video; up to 7 reference images | +| TTS | (default voice) | xAI `/v1/tts` endpoint | + +The chat catalog is derived live from the on-disk `models.dev` cache; new xAI releases appear automatically once that cache refreshes. `grok-4.3` is always pinned to the top of the list. + +## Environment Variables + +| Variable | Effect | +|----------|--------| +| `XAI_BASE_URL` | Override the default `https://api.x.ai/v1` endpoint (rarely needed). | +| `HERMES_INFERENCE_PROVIDER` | Force the active provider at runtime, e.g. `HERMES_INFERENCE_PROVIDER=xai-oauth hermes`. | + +## Troubleshooting + +### Token expired — not re-logging in automatically + +Hermes refreshes the token before each session and again reactively on a 401. If refresh fails with `invalid_grant` (the refresh token was revoked, or the account was rotated), Hermes surfaces a typed re-auth message instead of crashing. + +**Fix:** run `hermes auth add xai-oauth` again to start a fresh login. + +### Authorization timed out + +The loopback listener has a finite expiry window (default 180 s). If you don't approve the login in time, Hermes raises a timeout error. + +**Fix:** re-run `hermes auth add xai-oauth` (or `hermes model`). The flow starts fresh. + +### State mismatch (possible CSRF) + +Hermes detected that the `state` value returned by the authorization server doesn't match what it sent. + +**Fix:** re-run the login. If it persists, check for a proxy or redirect that is modifying the OAuth response. + +### Logging in from a remote server + +On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. Open the URL on any device with a browser and complete the consent there — the loopback callback comes back to your remote host. + +You can also force this behaviour: + +```bash +hermes auth add xai-oauth --no-browser +``` + +### "No xAI credentials found" error at runtime + +The auth store has no `xai-oauth` entry and no `XAI_API_KEY` is set. You haven't logged in yet, or the credential file was deleted. + +**Fix:** run `hermes model` and pick the xAI Grok OAuth provider, or run `hermes auth add xai-oauth`. + +## Logging Out + +To remove stored xAI Grok OAuth credentials: + +```bash +hermes auth remove xai-oauth +``` + +This clears both the singleton `loopback_pkce` entry in `auth.json` and any matching credential-pool rows. + +## See Also + +- [AI Providers reference](../integrations/providers.md) +- [Environment Variables](../reference/environment-variables.md) +- [Configuration](../user-guide/configuration.md) +- [Voice & TTS](../user-guide/features/tts.md) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index af9e07814d7..e7b2e5ab86d 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -331,6 +331,8 @@ When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoin xAI is wired through the Responses API (`codex_responses` transport) for automatic reasoning support on Grok 4 models — no `reasoning_effort` parameter needed, the server reasons by default. Set `XAI_API_KEY` in `~/.hermes/.env` and pick xAI in `hermes model`, or drop `grok` as a shortcut into `/model grok-4-1-fast-reasoning`. +SuperGrok subscribers can sign in with browser OAuth instead of using an API key — pick **xAI Grok OAuth (SuperGrok Subscription)** in `hermes model`, or run `hermes auth add xai-oauth`. The same OAuth bearer token is automatically reused by direct-to-xAI tools (TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md) for the full flow. + When using xAI as a provider (any base URL containing `x.ai`), Hermes automatically enables prompt caching by sending the `x-grok-conv-id` header with every API request. This routes requests to the same server within a conversation session, allowing xAI's infrastructure to reuse cached system prompts and conversation history. No configuration is needed — caching activates automatically when an xAI endpoint is detected and a session ID is available. This reduces latency and cost for multi-turn conversations. @@ -1444,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`, `xai-oauth`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `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/sidebars.ts b/website/sidebars.ts index a8d893d6e72..a0fb24b8c50 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -191,6 +191,7 @@ const sidebars: SidebarsConfig = { 'guides/migrate-from-openclaw', 'guides/aws-bedrock', 'guides/azure-foundry', + 'guides/xai-grok-oauth', 'guides/microsoft-graph-app-registration', 'guides/operate-teams-meeting-pipeline', ], From e4d7a5dffaa18676b8567469825c2082658d8557 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 17:43:51 +0100 Subject: [PATCH 205/214] fix(tools): video_gen picker reflects active xAI selection and runs xai_grok post_setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the `hermes tools` reconfigure flow caused picking xAI Grok Imagine for video_gen (or image_gen) to feel like a no-op: 1. `_is_provider_active()` had a branch for `image_gen_plugin_name` but none for `video_gen_plugin_name`, so a row marked as the active xAI video provider was never recognized as active. The picker fell through to the env-var fallback in `_detect_active_provider_index()`, which matched the FAL row (because `FAL_KEY` is set), so the picker visually defaulted to FAL even though the user had selected xAI. 2. `_plugin_video_gen_providers()` and `_plugin_image_gen_providers()` built picker rows from the plugin's `get_setup_schema()` but only copied `name`, `badge`, `tag`, `env_vars`. The xAI plugins declare `post_setup: "xai_grok"` so the picker should run the OAuth / API-key prompt hook after selection — that key was silently dropped, so the hook never fired from the picker rows. Adds the missing `video_gen_plugin_name` branch (placed before the `managed_nous_feature` block, mirroring the existing image_gen branch) and propagates `post_setup` from the plugin schema into both picker-row builders. Adds focused tests in `test_video_gen_picker.py` and `test_image_gen_picker.py`. --- hermes_cli/tools_config.py | 43 ++++++----- tests/hermes_cli/test_image_gen_picker.py | 27 +++++++ tests/hermes_cli/test_video_gen_picker.py | 89 +++++++++++++++++++++++ 3 files changed, 141 insertions(+), 18 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 891ffdeb05a..377194589ea 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1505,15 +1505,16 @@ def _plugin_image_gen_providers() -> list[dict]: 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", []), - "image_gen_plugin_name": provider.name, - } - ) + row = { + "name": schema.get("name", provider.display_name), + "badge": schema.get("badge", ""), + "tag": schema.get("tag", ""), + "env_vars": schema.get("env_vars", []), + "image_gen_plugin_name": provider.name, + } + if schema.get("post_setup"): + row["post_setup"] = schema["post_setup"] + rows.append(row) return rows @@ -1542,15 +1543,16 @@ def _plugin_video_gen_providers() -> list[dict]: 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, - } - ) + row = { + "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, + } + if schema.get("post_setup"): + row["post_setup"] = schema["post_setup"] + rows.append(row) return rows @@ -1814,6 +1816,11 @@ def _is_provider_active(provider: dict, config: dict) -> bool: image_cfg = config.get("image_gen", {}) return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name + video_plugin_name = provider.get("video_gen_plugin_name") + if video_plugin_name: + video_cfg = config.get("video_gen", {}) + return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name + managed_feature = provider.get("managed_nous_feature") if managed_feature: features = get_nous_subscription_features(config) diff --git a/tests/hermes_cli/test_image_gen_picker.py b/tests/hermes_cli/test_image_gen_picker.py index 6da847691a7..51eafd6da67 100644 --- a/tests/hermes_cli/test_image_gen_picker.py +++ b/tests/hermes_cli/test_image_gen_picker.py @@ -103,6 +103,33 @@ class TestPluginPickerInjection: visible = tools_config._visible_providers(browser, {}) assert all(p.get("image_gen_plugin_name") is None for p in visible) + def test_post_setup_propagated_when_declared(self, monkeypatch): + from hermes_cli import tools_config + + image_gen_registry.register_provider(_FakeProvider( + "xai_img", + schema={ + "name": "xAI Grok Imagine", + "badge": "paid", + "tag": "grok image", + "env_vars": [], + "post_setup": "xai_grok", + }, + )) + + rows = tools_config._plugin_image_gen_providers() + match = next(r for r in rows if r.get("image_gen_plugin_name") == "xai_img") + assert match["post_setup"] == "xai_grok" + + def test_post_setup_omitted_when_not_declared(self, monkeypatch): + from hermes_cli import tools_config + + image_gen_registry.register_provider(_FakeProvider("plain_img")) + + rows = tools_config._plugin_image_gen_providers() + match = next(r for r in rows if r.get("image_gen_plugin_name") == "plain_img") + assert "post_setup" not in match + class TestPluginCatalog: def test_plugin_catalog_returns_models(self): diff --git a/tests/hermes_cli/test_video_gen_picker.py b/tests/hermes_cli/test_video_gen_picker.py index 85350947c96..c06e2ea2096 100644 --- a/tests/hermes_cli/test_video_gen_picker.py +++ b/tests/hermes_cli/test_video_gen_picker.py @@ -146,3 +146,92 @@ class TestReconfigureWritesProvider: assert config["video_gen"]["provider"] == "noenv_video" assert config["video_gen"]["model"] == "noenv_video-video-v1" assert config["video_gen"]["use_gateway"] is False + + +class TestPluginVideoProvidersRow: + """Tests for _plugin_video_gen_providers row contents.""" + + def test_post_setup_propagated_when_declared(self, monkeypatch): + from hermes_cli import tools_config + + video_gen_registry.register_provider(_FakeVideoProvider( + "xai_video", + schema={ + "name": "xAI Grok Imagine", + "badge": "paid", + "tag": "grok video", + "env_vars": [], + "post_setup": "xai_grok", + }, + )) + + rows = tools_config._plugin_video_gen_providers() + match = next(r for r in rows if r.get("video_gen_plugin_name") == "xai_video") + assert match["post_setup"] == "xai_grok" + + def test_post_setup_omitted_when_not_declared(self, monkeypatch): + from hermes_cli import tools_config + + video_gen_registry.register_provider(_FakeVideoProvider("plain_video")) + + rows = tools_config._plugin_video_gen_providers() + match = next(r for r in rows if r.get("video_gen_plugin_name") == "plain_video") + assert "post_setup" not in match + + +class TestVideoPluginProviderActive: + """Tests for _is_provider_active recognizing video_gen_plugin_name.""" + + def test_active_when_video_gen_provider_matches(self): + from hermes_cli import tools_config + + config = {"video_gen": {"provider": "xai"}} + row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"} + + assert tools_config._is_provider_active(row, config) is True + + def test_inactive_when_video_gen_provider_differs(self): + from hermes_cli import tools_config + + config = {"video_gen": {"provider": "fal"}} + row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"} + + assert tools_config._is_provider_active(row, config) is False + + def test_inactive_when_video_gen_section_missing(self): + from hermes_cli import tools_config + + row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"} + assert tools_config._is_provider_active(row, {}) is False + + def test_detect_active_index_picks_video_plugin_match(self, monkeypatch): + """When xAI is the configured video_gen provider, the picker should + default to the xAI row even if FAL_KEY happens to be set in env. + + Regression: previously _detect_active_provider_index() saw + _is_provider_active(xai) return False (no video_gen branch), + skipped xAI (empty env_vars), and matched the FAL row via the + env-var fallback — so the picker visually defaulted to FAL even + though the user picked xAI. The xAI row uses empty env_vars + because authentication is handled via xAI Grok OAuth (post_setup + hook). + """ + from hermes_cli import tools_config + + monkeypatch.setattr( + tools_config, + "get_env_value", + lambda key: "fal-key" if key == "FAL_KEY" else "", + ) + + config = {"video_gen": {"provider": "xai"}} + providers = [ + {"name": "xAI Grok Imagine", "env_vars": [], "video_gen_plugin_name": "xai"}, + { + "name": "FAL.ai", + "env_vars": [{"key": "FAL_KEY", "prompt": "FAL"}], + "video_gen_plugin_name": "fal", + }, + ] + + assert tools_config._detect_active_provider_index(providers, config) == 0 From 9eef53b9605410ddc4fe1dfa79214a137787141c Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 17:44:27 +0100 Subject: [PATCH 206/214] chore(release): map Jaaneek@users.noreply.github.com to Jaaneek The contributor's commit author email is the legacy GitHub noreply form (no leading numeric "id+"), so it doesn't match the check-attribution workflow's auto-resolve regex (\+.*@users\.noreply\.github\.com). Register it explicitly in AUTHOR_MAP so the PR #26457 attribution check passes. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index f3df43c3fe1..740b79091b1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1074,6 +1074,7 @@ AUTHOR_MAP = { "16034932+Arkmusn@users.noreply.github.com": "Arkmusn", # PR #25559 salvage (approvals.timeout from config) "nidhi2894@gmail.com": "nidhi-singh02", # PR #2752 salvage (slack whitespace-only IndexError guard) "38173192+nidhi-singh02@users.noreply.github.com": "nidhi-singh02", + "Jaaneek@users.noreply.github.com": "Jaaneek", # PR #26457 (xAI Grok OAuth provider) } From e13c1b806018427aaf5fbe4b0ff2c6ca6821d6db Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 18:27:54 +0100 Subject: [PATCH 207/214] fix(xai-http): preserve ~/.hermes/.env fallback and XAI_STT_BASE_URL precedence The new resolve_xai_http_credentials() resolver was using os.getenv() for the XAI_API_KEY/XAI_BASE_URL fallback path, which dropped the ~/.hermes/.env contract guarded by PR #17140 / #17163. Users with XAI_API_KEY in dotenv only would see "No xAI credentials found" even though the key was configured. Separately, _transcribe_xai started consulting creds["base_url"] (which always returns at least the default https://api.x.ai/v1) ahead of the public XAI_STT_BASE_URL env override, so the per-tool override stopped working. - tools/xai_http.py: add module-level get_env_value() wrapper that reads ~/.hermes/.env first (via hermes_cli.config.get_env_value), then os.environ. Resolver uses it for the API-key/base-url fallback. - tools/transcription_tools.py: restore precedence so XAI_STT_BASE_URL wins over creds["base_url"]. - tests/tools/test_transcription_dotenv_fallback.py + tests/tools/test_tts_dotenv_fallback.py: repoint the per-call-site patches at the new resolution point (tools.xai_http.get_env_value). The end-to-end regression-guard test (which patches load_env) is unchanged and still passes. --- .../test_transcription_dotenv_fallback.py | 13 +++++--- tests/tools/test_tts_dotenv_fallback.py | 7 ++++- tools/transcription_tools.py | 2 +- tools/xai_http.py | 30 ++++++++++++++++--- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 73e7a42a59b..a28c777a8f1 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -170,7 +170,15 @@ class TestTranscribeCallSitesReadDotenv: assert seen_keys == ["mistral-dotenv-key"] def test_transcribe_xai_forwards_dotenv_key(self): + """xAI STT now resolves credentials through ``tools.xai_http`` so the + OAuth bearer wins when present and ``XAI_API_KEY`` is the fallback. + Patch the resolver's ``get_env_value`` to simulate a dotenv-only key + and confirm it reaches the HTTP call. The per-call-site + ``transcription_tools.get_env_value`` is still consulted for the + ``XAI_STT_BASE_URL`` override (covered by ``test_custom_base_url``). + """ from tools import transcription_tools as tt + from tools import xai_http captured: dict = {} @@ -183,15 +191,12 @@ class TestTranscribeCallSitesReadDotenv: response.json.return_value = {"text": "hello"} return response - # get_env_value is consulted for both XAI_API_KEY and XAI_STT_BASE_URL. - # Return the key for the first call, None for base-url override - # (so it defaults to the module-level XAI_STT_BASE_URL). def fake_get_env_value(name, default=None): if name == "XAI_API_KEY": return "xai-dotenv-key" return None - with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \ + with patch.object(xai_http, "get_env_value", side_effect=fake_get_env_value), \ patch("requests.post", side_effect=fake_post), \ patch("builtins.open", MagicMock()): result = tt._transcribe_xai("/tmp/fake.mp3", "grok-stt") diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 05083208709..0a4ea5a8ac2 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -57,7 +57,12 @@ class TestDotenvFallbackPerProvider: mock_import.return_value.assert_called_once_with(api_key="el-dotenv-key") def test_xai_reads_dotenv_key(self, tmp_path): + """xAI TTS now resolves credentials through ``tools.xai_http``; the + dotenv fallback contract from #17140 is preserved by patching the + resolver's ``get_env_value`` rather than ``tts_tool.get_env_value``. + """ from tools import tts_tool + from tools import xai_http captured: dict = {} @@ -69,7 +74,7 @@ class TestDotenvFallbackPerProvider: response.raise_for_status = MagicMock() return response - with patch.object(tts_tool, "get_env_value", return_value="xai-dotenv-key"), \ + with patch.object(xai_http, "get_env_value", return_value="xai-dotenv-key"), \ patch("requests.post", side_effect=fake_post): tts_tool._generate_xai_tts("hi", str(tmp_path / "out.mp3"), {}) diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 6f6d2f8c2a3..d741530d358 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -726,8 +726,8 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: xai_config = stt_config.get("xai", {}) base_url = str( xai_config.get("base_url") - or creds.get("base_url") or get_env_value("XAI_STT_BASE_URL") + or creds.get("base_url") or XAI_STT_BASE_URL ).strip().rstrip("/") language = str( diff --git a/tools/xai_http.py b/tools/xai_http.py index fbb7961d244..216a51ff10d 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -5,6 +5,25 @@ from __future__ import annotations import os from typing import Dict +try: + from hermes_cli.config import get_env_value as _hermes_get_env_value +except Exception: + _hermes_get_env_value = None + + +def get_env_value(name: str, default=None): + """Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``. + + Wraps :func:`hermes_cli.config.get_env_value` so tests can patch + ``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the + xAI credential resolver. + """ + if _hermes_get_env_value is not None: + value = _hermes_get_env_value(name) + if value is not None: + return value + return os.environ.get(name, default) + def hermes_xai_user_agent() -> str: """Return a stable Hermes-specific User-Agent for xAI HTTP calls.""" @@ -19,8 +38,11 @@ def resolve_xai_http_credentials() -> Dict[str, str]: """Resolve bearer credentials for direct xAI HTTP endpoints. Prefers Hermes-managed xAI OAuth credentials when available, then falls back - to ``XAI_API_KEY`` from the environment. This keeps direct xAI endpoints - (images, TTS, STT, etc.) aligned with the main runtime auth model. + to ``XAI_API_KEY`` resolved via ``hermes_cli.config.get_env_value`` so keys + stored in ``~/.hermes/.env`` (the standard Hermes location) are honored — + not just ones already exported into ``os.environ``. This keeps direct xAI + endpoints (images, TTS, STT, etc.) aligned with the main runtime auth model + and preserves the regression contract from PR #17140 / #17163. """ try: from hermes_cli.runtime_provider import resolve_runtime_provider @@ -52,8 +74,8 @@ def resolve_xai_http_credentials() -> Dict[str, str]: except Exception: pass - api_key = os.getenv("XAI_API_KEY", "").strip() - base_url = (os.getenv("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") + api_key = str(get_env_value("XAI_API_KEY") or "").strip() + base_url = str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/") return { "provider": "xai", "api_key": api_key, From 7fdc16dd4a281dad84a245ab9eed3be2f4a94264 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 18:28:01 +0100 Subject: [PATCH 208/214] refactor(transports/codex): trim duplicated cache-key comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xAI prompt_cache_key block carried two long comment paragraphs that either restated setdefault semantics, narrated the SDK type-validation mechanism, or recapped the historical motivation for the extra_body indirection — all already covered by the test docstring at test_xai_responses_sends_cache_key_via_extra_body (which links to the xAI docs). Also restored the truncated link in the body-injection comment. No behavior change. --- agent/transports/codex.py | 20 ++++++------------- .../agent/transports/test_codex_transport.py | 5 ----- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 46169e971ba..cfd9f128778 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -102,11 +102,8 @@ class ResponsesApiTransport(ProviderTransport): kwargs["parallel_tool_calls"] = True session_id = params.get("session_id") - # xAI's Responses API uses `prompt_cache_key` (body-level) as the - # cache-routing key, not a top-level kwarg — the body-field - # injection below survives openai SDK builds whose - # Responses.stream() signature drops the kwarg. Everything else - # that ISN'T github/xAI keeps using the typed kwarg. + # xAI Responses takes prompt_cache_key in extra_body (set further + # down); GitHub Models opts out of cache-key routing entirely. if not is_github_responses and not is_xai_responses and session_id: kwargs["prompt_cache_key"] = session_id @@ -172,15 +169,10 @@ class ResponsesApiTransport(ProviderTransport): merged_extra_headers["x-grok-conv-id"] = session_id kwargs["extra_headers"] = merged_extra_headers - # xAI Responses cache-routing field. Lives in the request body - # (per https://docs.x.ai/.../prompt-caching/maximizing-cache-hits), - # so we ship it via extra_body — the openai SDK serializes - # extra_body fields into the JSON body without per-field type - # validation, sidestepping the TypeError that fires on - # Responses.stream() builds whose `prompt_cache_key` kwarg has - # been dropped. Setdefault preserves a caller-supplied value - # (e.g. request_overrides.extra_body.prompt_cache_key) over - # the auto-derived session_id. + # xAI Responses cache-routing — body-level field per + # https://docs.x.ai/developers/advanced-api-usage/prompt-caching/maximizing-cache-hits. + # Sent via extra_body (not the typed kwarg) so it survives openai + # SDK builds whose Responses.stream() signature has dropped the field. existing_extra_body = kwargs.get("extra_body") merged_extra_body: Dict[str, Any] = {} if isinstance(existing_extra_body, dict): diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 7100e8ac17d..ad70167b09f 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -117,13 +117,8 @@ class TestCodexBuildKwargs: session_id="conv-xai-1", is_xai_responses=True, ) - # Top-level prompt_cache_key must NOT be set for xAI — the SDK - # signature drop is what motivated the extra_body indirection in - # the first place. The cache-routing field must travel in the - # body via extra_body. assert "prompt_cache_key" not in kw assert kw.get("extra_body", {}).get("prompt_cache_key") == "conv-xai-1" - # Header kept as belt-and-braces. assert kw.get("extra_headers", {}).get("x-grok-conv-id") == "conv-xai-1" def test_xai_responses_extra_body_preserves_caller_fields(self, transport): From 1e4801b8d0c27c1d6f6f8ed14ace0d3045a0d695 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 18:46:45 +0100 Subject: [PATCH 209/214] docs(xai-oauth): correct logout command (was hermes auth remove) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "Logging Out" section showed `hermes auth remove xai-oauth` with no positional target — argparse rejects that and the command does not clear the singleton OAuth state anyway. The correct command for the "clear everything" intent is `hermes auth logout xai-oauth`. Also point users at `hermes auth remove xai-oauth ` for single-pool-row deletion. --- website/docs/guides/xai-grok-oauth.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 49c7087621a..5afccb6d881 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -198,13 +198,13 @@ The auth store has no `xai-oauth` entry and no `XAI_API_KEY` is set. You haven't ## Logging Out -To remove stored xAI Grok OAuth credentials: +To remove all stored xAI Grok OAuth credentials: ```bash -hermes auth remove xai-oauth +hermes auth logout xai-oauth ``` -This clears both the singleton `loopback_pkce` entry in `auth.json` and any matching credential-pool rows. +This clears both the singleton OAuth entry in `auth.json` and any credential-pool rows for `xai-oauth`. Use `hermes auth remove xai-oauth ` if you only want to drop a single pool entry (run `hermes auth list xai-oauth` to see them). ## See Also From 7d7cdd48e06b9bbf0fd4e030f6745e8b033e1adc Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 15 May 2026 19:04:14 +0100 Subject: [PATCH 210/214] test(xai-oauth): use grok-4.3 instead of retiring grok-code-fast-1 Per @mark-xai's review on PR #26457 and the xAI model retirement on 2026-05-15: grok-code-fast-1 is being retired today and aliases redirect to grok-4.3 (already pinned to the top of the xAI model list by this PR). Update the two xAI Responses-API test fixtures Mark flagged plus the picker fallback default in hermes_cli/main.py that uses the same literal. --- hermes_cli/main.py | 2 +- tests/run_agent/test_run_agent_codex_responses.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c7ac1100816..c2c8a6880d2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2887,7 +2887,7 @@ def _model_flow_xai_oauth(_config, current_model=""): pass models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or []) - selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-code-fast-1")) + selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.3")) if selected: _save_model_choice(selected) _update_config_for_provider("xai-oauth", base_url) diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 8cc02629523..5652281eb42 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -581,7 +581,7 @@ def test_run_conversation_codex_refreshes_after_401_and_retries(monkeypatch): def _build_xai_oauth_agent(monkeypatch): _patch_agent_bootstrap(monkeypatch) agent = run_agent.AIAgent( - model="grok-code-fast-1", + model="grok-4.3", provider="xai-oauth", api_mode="codex_responses", base_url="https://api.x.ai/v1", @@ -619,7 +619,7 @@ def test_build_api_kwargs_xai_oauth_sends_cache_key_via_extra_body(monkeypatch): ] ) - assert kwargs.get("model") == "grok-code-fast-1" + assert kwargs.get("model") == "grok-4.3" # Top-level kwarg must NOT be set — that's the openai SDK # incompatibility this whole indirection exists to dodge. assert "prompt_cache_key" not in kwargs From aac6d97a143759731431ade9a098b4baa55fc53d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 12:11:08 -0700 Subject: [PATCH 211/214] chore(xai-oauth): trim CORS allowlist to xAI auth origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop accounts.mouseion.dev and localhost:20000 / 127.0.0.1:20000 from the loopback callback CORS allowlist — leftover dev origins. The redirect_uri is bound to 127.0.0.1 and gated by PKCE + state, so only xAI's own auth origins are needed. Co-Authored-By: Jaaneek --- hermes_cli/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8749cd9461c..c6dce709384 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2081,12 +2081,12 @@ def _xai_validate_loopback_redirect_uri(redirect_uri: str) -> tuple[str, int, st def _xai_callback_cors_origin(origin: Optional[str]) -> str: + # CORS allowlist for the loopback callback. Only xAI's own auth origins + # are accepted; the redirect_uri itself is bound to 127.0.0.1 and gated by + # PKCE+state, so additional dev/3p origins are not needed here. allowed = { "https://accounts.x.ai", "https://auth.x.ai", - "https://accounts.mouseion.dev", - "http://localhost:20000", - "http://127.0.0.1:20000", } return origin if origin in allowed else "" From 4ad5fa702f6c04a2032be876a8d4d0b37a88459d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 12:33:12 -0700 Subject: [PATCH 212/214] docs(xai-oauth): add xai-oauth to provider enumeration pages (#26542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #26534 (xai-oauth provider). The new guide and integrations page were shipped with the salvage, but four reference/enumeration pages still listed every other OAuth provider without xai-oauth: - reference/cli-commands.md — `--provider` choices list - reference/environment-variables.md — HERMES_INFERENCE_PROVIDER values - user-guide/configuration.md — auxiliary-task provider list, OAuth tip block (mirrored from MiniMax OAuth), and provider table row - user-guide/features/fallback-providers.md — provider table --- website/docs/reference/cli-commands.md | 2 +- website/docs/reference/environment-variables.md | 2 +- website/docs/user-guide/configuration.md | 7 ++++++- website/docs/user-guide/features/fallback-providers.md | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index a895e1efa74..aa12f431b62 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -92,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`, `novita`, `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`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `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 93107fba147..56fe8a13715 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -105,7 +105,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`, `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`, `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`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (browser OAuth login for SuperGrok subscribers — no API key required; see [xAI Grok OAuth guide](../guides/xai-grok-oauth.md)), `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) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 89bdb234146..d529c8af687 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -813,12 +813,16 @@ 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`, `xai-oauth`, `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"`). :::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). ::: +:::tip xAI Grok OAuth +`xai-oauth` logs in via browser OAuth for SuperGrok subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok Subscription)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md). +::: + :::warning `"main"` is for auxiliary tasks only The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and `fallback_model:` configs. It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](/docs/integrations/providers) for all main model provider options. ::: @@ -980,6 +984,7 @@ These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`, | `"nous"` | Force Nous Portal | `hermes auth` | | `"codex"` | Force Codex OAuth (ChatGPT account). Supports vision (gpt-5.3-codex). | `hermes model` → Codex | | `"minimax-oauth"` | Force MiniMax OAuth (browser login, no API key). Uses MiniMax-M2.7-highspeed for auxiliary tasks. | `hermes model` → MiniMax (OAuth) | +| `"xai-oauth"` | Force xAI Grok OAuth (browser login for SuperGrok subscribers, no API key). Same OAuth token covers chat, TTS, image, video, and transcription. | `hermes model` → xAI Grok OAuth (SuperGrok Subscription) | | `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL | Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. `gmi` is valid once `GMI_API_KEY` is configured: diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index cd002ae689e..72528796d55 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -66,6 +66,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) | | Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) | | xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) | +| xAI Grok OAuth (SuperGrok) | `xai-oauth` (alias `grok-oauth`) | `hermes model` → xAI Grok OAuth (browser login; SuperGrok subscription) | | AWS Bedrock | `bedrock` | Standard boto3 auth (`AWS_REGION` + `AWS_PROFILE` or `AWS_ACCESS_KEY_ID`) | | Qwen Portal (OAuth) | `qwen-oauth` | `hermes model` (Qwen Portal OAuth; optional: `HERMES_QWEN_BASE_URL`) | | MiniMax (OAuth) | `minimax-oauth` | `hermes model` (MiniMax portal OAuth) | From 734aa0f367a5ace259e4c35d7b002b634a3149ae Mon Sep 17 00:00:00 2001 From: aydnOktay Date: Tue, 24 Mar 2026 13:50:11 +0300 Subject: [PATCH 213/214] fix(cronjob): require explicit truthy session env values --- tests/tools/test_cronjob_tools.py | 7 +++++++ tools/cronjob_tools.py | 14 +++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 3e1f85c370a..34c5fede560 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -122,6 +122,13 @@ class TestCronjobRequirements: assert check_cronjob_requirements() is False + @pytest.mark.parametrize("false_like_value", ["0", "false", "no", "off"]) + def test_rejects_false_like_interactive_env(self, monkeypatch, false_like_value): + monkeypatch.setenv("HERMES_INTERACTIVE", false_like_value) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + assert check_cronjob_requirements() is False + class TestUnifiedCronjobTool: @pytest.fixture(autouse=True) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3c29431484d..698aab2cfc2 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -662,6 +662,14 @@ Important safety rule: cron-run sessions should not recursively schedule more cr } +def _is_truthy_env(var_name: str) -> bool: + """Return True only for explicit truthy env values.""" + value = os.getenv(var_name) + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + def check_cronjob_requirements() -> bool: """ Check if cronjob tools can be used. @@ -671,9 +679,9 @@ def check_cronjob_requirements() -> bool: so no external crontab executable is required. """ return bool( - os.getenv("HERMES_INTERACTIVE") - or os.getenv("HERMES_GATEWAY_SESSION") - or os.getenv("HERMES_EXEC_ASK") + _is_truthy_env("HERMES_INTERACTIVE") + or _is_truthy_env("HERMES_GATEWAY_SESSION") + or _is_truthy_env("HERMES_EXEC_ASK") ) From 931caf2b2d42d6e76b8c470e5d44ca20704c41dc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 02:03:49 -0700 Subject: [PATCH 214/214] fix(env-flags): widen truthy-only session env checks to sibling sites Build on @aydnOktay's cronjob fix by routing the cronjob check through the shared 'env_var_enabled' helper in utils.py (same truthy set: 1/true/yes/on) and applying the same semantics to the 8 sibling call sites that read HERMES_INTERACTIVE / HERMES_GATEWAY_SESSION / HERMES_EXEC_ASK / HERMES_CRON_SESSION with bare os.getenv() truthy checks: - tools/approval.py: _is_gateway_approval_context (2), check_command_safety (2), check_all_command_guards (3) -- 7 sites total - tools/terminal_tool.py: _handle_sudo_failure, sudo password prompt -- 2 sites - tools/skills_tool.py: _is_gateway_surface -- 1 site Without this, a user who exports HERMES_INTERACTIVE=0 in their shell still gets interactive sudo prompts, approval prompts, and gateway skill-install paths -- only the cronjob tool was hardened. Now all consumers agree on the same false-like values. Also drops the duplicate _is_truthy_env helper from cronjob_tools.py in favour of the existing canonical utils.env_var_enabled. Tests: extend the parametrized regression coverage to all three session env vars (HERMES_INTERACTIVE / HERMES_GATEWAY_SESSION / HERMES_EXEC_ASK) symmetrically. tests/tools/test_cronjob_tools.py: 60/60 pass; tests/tools/{approval,terminal_tool,skills_tool, cron_approval_mode,hardline_blocklist}.py: 378/378 pass. --- tests/tools/test_cronjob_tools.py | 14 ++++++++++++++ tools/approval.py | 18 +++++++++--------- tools/cronjob_tools.py | 23 +++++++++++------------ tools/skills_tool.py | 3 ++- tools/terminal_tool.py | 6 ++++-- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 34c5fede560..6280b71d29f 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -129,6 +129,20 @@ class TestCronjobRequirements: monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) assert check_cronjob_requirements() is False + @pytest.mark.parametrize( + "var_name", + ["HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK"], + ) + @pytest.mark.parametrize("false_like_value", ["0", "false", "no", "off"]) + def test_rejects_false_like_any_session_env( + self, monkeypatch, var_name, false_like_value + ): + """All three session env vars share the same truthy semantics.""" + for v in ("HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv(var_name, false_like_value) + assert check_cronjob_requirements() is False + class TestUnifiedCronjobTool: @pytest.fixture(autouse=True) diff --git a/tools/approval.py b/tools/approval.py index dbb3810886f..84d02cc6a98 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -19,7 +19,7 @@ import unicodedata from typing import Optional from hermes_cli.config import cfg_get -from utils import is_truthy_value +from utils import env_var_enabled, is_truthy_value logger = logging.getLogger(__name__) @@ -108,9 +108,9 @@ def _is_gateway_approval_context() -> bool: fall through to the gateway branch would submit a pending approval with no listener and block the job indefinitely. """ - if os.getenv("HERMES_CRON_SESSION"): + if env_var_enabled("HERMES_CRON_SESSION"): return False - if os.getenv("HERMES_GATEWAY_SESSION"): + if env_var_enabled("HERMES_GATEWAY_SESSION"): return True return bool(_get_session_platform()) @@ -928,12 +928,12 @@ def check_dangerous_command(command: str, env_type: str, if is_approved(session_key, pattern_key): return {"approved": True, "message": None} - is_cli = os.getenv("HERMES_INTERACTIVE") + is_cli = env_var_enabled("HERMES_INTERACTIVE") is_gateway = _is_gateway_approval_context() if not is_cli and not is_gateway: # Cron sessions: respect cron_mode config - if os.getenv("HERMES_CRON_SESSION"): + if env_var_enabled("HERMES_CRON_SESSION"): if _get_cron_approval_mode() == "deny": return { "approved": False, @@ -947,7 +947,7 @@ def check_dangerous_command(command: str, env_type: str, } return {"approved": True, "message": None} - if is_gateway or os.getenv("HERMES_EXEC_ASK"): + if is_gateway or env_var_enabled("HERMES_EXEC_ASK"): submit_pending(session_key, { "command": command, "pattern_key": pattern_key, @@ -1056,15 +1056,15 @@ def check_all_command_guards(command: str, env_type: str, if is_truthy_value(os.getenv("HERMES_YOLO_MODE")) or is_current_session_yolo_enabled() or approval_mode == "off": return {"approved": True, "message": None} - is_cli = os.getenv("HERMES_INTERACTIVE") + is_cli = env_var_enabled("HERMES_INTERACTIVE") is_gateway = _is_gateway_approval_context() - is_ask = os.getenv("HERMES_EXEC_ASK") + is_ask = env_var_enabled("HERMES_EXEC_ASK") # Preserve the existing non-interactive behavior: outside CLI/gateway/ask # flows, we do not block on approvals and we skip external guard work. if not is_cli and not is_gateway and not is_ask: # Cron sessions: respect cron_mode config - if os.getenv("HERMES_CRON_SESSION"): + if env_var_enabled("HERMES_CRON_SESSION"): if _get_cron_approval_mode() == "deny": # Run detection to get a description for the block message is_dangerous, _pk, description = detect_dangerous_command(command) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 698aab2cfc2..a7a8a0feab9 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -662,14 +662,6 @@ Important safety rule: cron-run sessions should not recursively schedule more cr } -def _is_truthy_env(var_name: str) -> bool: - """Return True only for explicit truthy env values.""" - value = os.getenv(var_name) - if value is None: - return False - return value.strip().lower() in {"1", "true", "yes", "on"} - - def check_cronjob_requirements() -> bool: """ Check if cronjob tools can be used. @@ -677,11 +669,18 @@ def check_cronjob_requirements() -> bool: Available in interactive CLI mode and gateway/messaging platforms. The cron system is internal (JSON file-based scheduler ticked by the gateway), so no external crontab executable is required. + + Session env vars must hold an explicit truthy string (``1``, ``true``, + ``yes``, ``on``) — false-like values (``0``, ``false``, ``no``, ``off``) + leave the tool disabled. Uses the shared ``env_var_enabled`` helper so + every consumer of these flags agrees on the truthy set. """ - return bool( - _is_truthy_env("HERMES_INTERACTIVE") - or _is_truthy_env("HERMES_GATEWAY_SESSION") - or _is_truthy_env("HERMES_EXEC_ASK") + from utils import env_var_enabled + + return ( + env_var_enabled("HERMES_INTERACTIVE") + or env_var_enabled("HERMES_GATEWAY_SESSION") + or env_var_enabled("HERMES_EXEC_ASK") ) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 0fcd449b80b..df6361ba59a 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -78,6 +78,7 @@ from typing import Dict, Any, List, Optional, Set, Tuple from tools.registry import registry, tool_error from hermes_cli.config import cfg_get +from utils import env_var_enabled logger = logging.getLogger(__name__) @@ -365,7 +366,7 @@ def _capture_required_environment_variables( def _is_gateway_surface() -> bool: - if os.getenv("HERMES_GATEWAY_SESSION"): + if env_var_enabled("HERMES_GATEWAY_SESSION"): return True from gateway.session_context import get_session_env return bool(get_session_env("HERMES_SESSION_PLATFORM")) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index e0d07e80f6e..31a1c6fa078 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -47,6 +47,8 @@ import subprocess from pathlib import Path from typing import Optional, Dict, Any, List +from utils import env_var_enabled + logger = logging.getLogger(__name__) @@ -360,7 +362,7 @@ def _handle_sudo_failure(output: str, env_type: str) -> str: Returns enhanced output if sudo failed in messaging context, else original. """ - is_gateway = os.getenv("HERMES_GATEWAY_SESSION") + is_gateway = env_var_enabled("HERMES_GATEWAY_SESSION") if not is_gateway: return output @@ -868,7 +870,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None if not has_configured_password and not sudo_password and _sudo_nopasswd_works(): return command, None - if not has_configured_password and not sudo_password and os.getenv("HERMES_INTERACTIVE"): + if not has_configured_password and not sudo_password and env_var_enabled("HERMES_INTERACTIVE"): sudo_password = _prompt_for_sudo_password(timeout_seconds=45) if sudo_password: _set_cached_sudo_password(sudo_password)