diff --git a/.env.example b/.env.example index 7641af6ae72..68335e72985 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,14 @@ # LLM_MODEL is no longer read from .env — this line is kept for reference only. # LLM_MODEL=anthropic/claude-opus-4.6 +# ============================================================================= +# LLM PROVIDER (NovitaAI) +# ============================================================================= +# NovitaAI — 90+ models, pay-per-use +# Get your key at: https://novita.ai/settings/key-management +# NOVITA_API_KEY= +# NOVITA_BASE_URL=https://api.novita.ai/openai/v1 # Override default base URL + # ============================================================================= # LLM PROVIDER (Google AI Studio / Gemini) # ============================================================================= @@ -273,6 +281,13 @@ BROWSER_SESSION_TIMEOUT=300 # Browser sessions are automatically closed after this period of no activity BROWSER_INACTIVITY_TIMEOUT=120 +# Extra Chromium launch flags passed to agent-browser, comma- or newline-separated. +# Hermes auto-injects "--no-sandbox,--disable-dev-shm-usage" when it detects root +# or AppArmor-restricted unprivileged user namespaces (Ubuntu 23.10+, DGX Spark, +# many container images), so leave this unset unless you need extra flags. +# Setting this disables the auto-injection. +# AGENT_BROWSER_ARGS=--no-sandbox + # Camofox local anti-detection browser (Camoufox-based Firefox). # Set CAMOFOX_URL to route the browser tools through a local Camofox server # instead of agent-browser/Browserbase. See docs/user-guide/features/browser.md. @@ -379,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/.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}" diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 417e7b21f84..69a9a115c87 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -11,6 +11,7 @@ on: - '**/sitecustomize.py' - '**/usercustomize.py' - '**/__init__.pth' + - 'pyproject.toml' permissions: pull-requests: write @@ -137,3 +138,68 @@ jobs: run: | echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." exit 1 + + dep-bounds: + name: Check PyPI dependency upper bounds + runs-on: ubuntu-latest + if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Check for unbounded PyPI deps + id: bounds + run: | + set -euo pipefail + + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + + # Only check added lines in pyproject.toml + ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true) + + if [ -z "$ADDED" ]; then + echo "found=false" >> "$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 /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/.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 1aee027386d..623bb9f6c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,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) @@ -334,6 +333,29 @@ The registry handles schema collection, dispatch, availability checking, and err --- +## Dependency Pinning Policy + +All dependencies must have upper bounds to limit supply-chain attack surface. +This policy was established after the litellm compromise (PR #2796, #2810) and +reinforced after the Mini Shai-Hulud worm campaign (May 2026). + +| Source type | Treatment | Example | +|---|---|---| +| PyPI package | `>=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: @@ -538,6 +560,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, …) @@ -605,6 +638,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..36b1e9df2d5 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 @@ -73,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 ``` @@ -178,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) │ @@ -461,6 +475,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`). @@ -734,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 diff --git a/README.md b/README.md index 8b8a078b250..efe5515f4d8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ **The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM. -Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. +Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NovitaAI](https://novita.ai) (AI-native cloud for Model API, Agent Sandbox, and GPU Cloud), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in. @@ -23,7 +23,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open - +
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.
Scheduled automationsBuilt-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/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/environments/benchmarks/__init__.py b/acp_adapter/bootstrap/__init__.py similarity index 100% rename from environments/benchmarks/__init__.py rename to acp_adapter/bootstrap/__init__.py 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 cc7f835f7e0..cf5c2ba9cfb 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,150 @@ 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", + ) + 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) + + +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 + + # 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.""" + args = _parse_args(argv) + if args.version: + _print_version() + return + if args.check: + _run_check() + return + 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/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/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..b94a48e089f 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,12 +1,16 @@ { - "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"] + "uvx": { + "package": "hermes-agent[acp]==0.13.0", + "args": ["hermes-acp"] + } } } 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/agent/auxiliary_client.py b/agent/auxiliary_client.py index de7b6db2b1d..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. @@ -1407,6 +1431,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 +1440,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 +1472,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,7 +1480,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") @@ -1727,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. @@ -2834,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: @@ -3184,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) @@ -4432,7 +4521,7 @@ def extract_content_or_reasoning(response) -> str: 1. ``message.content`` — strip inline think/reasoning blocks, check for remaining non-whitespace text. 2. ``message.reasoning`` / ``message.reasoning_content`` — direct - structured reasoning fields (DeepSeek, Moonshot, Novita, etc.). + structured reasoning fields (DeepSeek, Moonshot, NovitaAI, etc.). 3. ``message.reasoning_details`` — OpenRouter unified array format. Returns the best available text, or ``""`` if nothing found. diff --git a/agent/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/context_compressor.py b/agent/context_compressor.py index d16236737c4..e7a14faf51b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -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 @@ -1409,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/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/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/display.py b/agent/display.py index e9a19ff6192..cdfc88f46a3 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" @@ -239,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"): @@ -810,6 +796,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) @@ -978,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/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/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/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/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/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/agent/transports/codex.py b/agent/transports/codex.py index 6738ed3220c..cfd9f128778 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -89,18 +89,22 @@ 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 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 if reasoning_enabled and is_xai_responses: @@ -165,6 +169,17 @@ class ResponsesApiTransport(ProviderTransport): merged_extra_headers["x-grok-conv-id"] = session_id kwargs["extra_headers"] = merged_extra_headers + # 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): + 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/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..f0cd0a196c4 --- /dev/null +++ b/agent/transports/codex_app_server_session.py @@ -0,0 +1,810 @@ +"""Session adapter for codex app-server runtime. + +Owns one Codex thread per Hermes session. Drives `turn/start`, consumes +streaming notifications via CodexEventProjector, handles server-initiated +approval requests (apply_patch, exec command), translates cancellation, +and returns a clean turn result that AIAgent.run_conversation() can splice +into its `messages` list. + +Lifecycle: + session = CodexAppServerSession(cwd="/home/x/proj") + session.ensure_started() # spawns + handshake + thread/start + result = session.run_turn(user_input="hello") # blocks until turn/completed + # result.final_text → assistant text returned to caller + # result.projected_messages → list of {role, content, ...} for messages list + # result.tool_iterations → how many tool-shaped items completed (skill nudge counter) + # result.interrupted → True if Ctrl+C / interrupt_requested fired mid-turn + session.close() # tears down subprocess + +Threading model: the adapter is single-threaded from the caller's perspective. +The underlying CodexAppServerClient owns its own reader threads but exposes +blocking-with-timeout queues that this adapter polls in a loop, so the run_turn +call is synchronous and behaves like AIAgent's existing chat_completions loop. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from agent.redact import redact_sensitive_text +from agent.transports.codex_app_server import ( + CodexAppServerClient, + CodexAppServerError, +) +from agent.transports.codex_event_projector import CodexEventProjector + +logger = logging.getLogger(__name__) + + +# How many tailing stderr lines from the codex subprocess to attach to a +# user-facing error when we don't have a more specific classification (OAuth, +# wedge watchdog, etc.). Small enough to keep error messages legible, large +# enough to surface a config/provider/auth diagnostic. +_STDERR_TAIL_LINES = 12 + + +# Permission profile mapping mirrors the docstring in PR proposal: +# Hermes' tools.terminal.security_mode → Codex's permissions profile id. +# Defaults if config is missing → workspace-write (matches Codex's own default). +_HERMES_TO_CODEX_PERMISSION_PROFILE = { + "auto": "workspace-write", + "approval-required": "read-only-with-approval", + "unrestricted": "full-access", + # Backstop alias used by some skills/tests. + "yolo": "full-access", +} + + +@dataclass +class TurnResult: + """Result of one user→assistant→tool turn through the codex app-server.""" + + final_text: str = "" + projected_messages: list[dict] = field(default_factory=list) + tool_iterations: int = 0 + interrupted: bool = False + error: Optional[str] = None # Set if turn ended in a non-recoverable error + turn_id: Optional[str] = None + thread_id: Optional[str] = None + # Hint to the caller that the underlying codex subprocess is likely + # wedged (turn-level timeout fired, post-tool watchdog tripped, or + # token-refresh failure killed the child). The caller should retire + # the session so the next turn respawns codex from scratch instead + # of riding a CPU-spinning or auth-broken process. Mirrors openclaw + # beta.8's "retire timed-out app-server clients" fix. + should_retire: bool = False + + +# Markers we accept as terminal even when codex never emits turn/completed. +# Some codex versions stream `` as raw text in agentMessage +# items when an interrupt or upstream error tears the turn down before the +# normal completion path fires. Mirrors openclaw beta.8 fix. +_TURN_ABORTED_MARKERS = ("", "") + + +# Substrings in codex stderr / JSON-RPC error messages that signal the +# subprocess died because its OAuth credentials are no longer valid. +# Kept conservative: we only redirect users to `codex login` when we're +# reasonably sure that's the actual failure, otherwise we surface the +# original error verbatim. Mirrors openclaw beta.8's auth-refresh +# classification. +_OAUTH_REFRESH_FAILURE_HINTS = ( + "invalid_grant", + "invalid grant", + "refresh token", + "refresh_token", + "token refresh", + "token_refresh", + "token has expired", + "expired_token", + "expired token", + "not authenticated", + "unauthenticated", + "unauthorized", + "401 unauthorized", + "re-authenticate", + "reauthenticate", + "please log in", + "please login", + "auth profile", + "no auth profile", + "oauth", +) + + +def _classify_oauth_failure(*parts: str) -> Optional[str]: + """Return a user-friendly re-auth hint if any of the provided strings + look like a codex OAuth/token-refresh failure; otherwise None. + + Used for both `turn/start` JSON-RPC errors and post-mortem stderr + inspection when the subprocess exits unexpectedly. Conservative on + purpose — we only redirect users to `codex login` when the signal + is strong, so unrelated runtime failures still surface verbatim. + """ + haystack = " ".join(p for p in parts if p).lower() + if not haystack: + return None + for needle in _OAUTH_REFRESH_FAILURE_HINTS: + if needle in haystack: + return ( + "Codex authentication failed — your ChatGPT/Codex login " + "looks expired or invalid. Run `codex login` to refresh, " + "then retry. (Fall back to default runtime with " + "`/codex-runtime auto` if the issue persists.)" + ) + return None + + +@dataclass +class _ServerRequestRouting: + """Default policies for codex-side approval requests when no interactive + callback is wired in. These are only used by tests + cron / non-interactive + contexts; the live CLI path passes an approval_callback that defers to + tools.approval.prompt_dangerous_approval().""" + + auto_approve_exec: bool = False + auto_approve_apply_patch: bool = False + + +class CodexAppServerSession: + """One Codex thread per Hermes session, lifetime owned by AIAgent. + + Not thread-safe — one caller drives it at a time, matching how AIAgent's + run_conversation() loop is structured today. The codex client itself can + handle interleaved reads/writes via its own threads, but the adapter's + state (projector, thread_id, turn counter) is owned by the caller thread. + """ + + def __init__( + self, + *, + cwd: Optional[str] = None, + codex_bin: str = "codex", + codex_home: Optional[str] = None, + permission_profile: Optional[str] = None, + approval_callback: Optional[Callable[..., str]] = None, + on_event: Optional[Callable[[dict], None]] = None, + request_routing: Optional[_ServerRequestRouting] = None, + client_factory: Optional[Callable[..., CodexAppServerClient]] = None, + ) -> None: + self._cwd = cwd or os.getcwd() + self._codex_bin = codex_bin + self._codex_home = codex_home + self._permission_profile = ( + permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get( + os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"), + "workspace-write", + ) + ) + self._approval_callback = approval_callback + self._on_event = on_event # Display hook (kawaii spinner ticks etc.) + self._routing = request_routing or _ServerRequestRouting() + self._client_factory = client_factory or CodexAppServerClient + + self._client: Optional[CodexAppServerClient] = None + self._thread_id: Optional[str] = None + self._interrupt_event = threading.Event() + # Pending file-change items, keyed by item id. Populated on + # item/started for fileChange items; consumed by the approval + # bridge when codex sends item/fileChange/requestApproval. The + # approval params don't carry the changeset, so we cache here + # to surface a real summary in the approval prompt (quirk #4). + self._pending_file_changes: dict[str, str] = {} + self._closed = False + + # ---------- lifecycle ---------- + + def ensure_started(self) -> str: + """Spawn the subprocess, do the initialize handshake, and start a + thread. Returns the codex thread id. Idempotent — repeated calls + return the same thread id.""" + if self._thread_id is not None: + return self._thread_id + if self._client is None: + self._client = self._client_factory( + codex_bin=self._codex_bin, codex_home=self._codex_home + ) + self._client.initialize( + client_name="hermes", + client_title="Hermes Agent", + client_version=_get_hermes_version(), + ) + # Permission selection is intentionally NOT sent on thread/start. + # Two reasons (live-tested against codex 0.130.0): + # 1. `thread/start.permissions` is gated behind the experimentalApi + # capability on this codex version — we'd have to opt in during + # initialize and accept the unstable surface. + # 2. Even with experimentalApi declared and the correct shape + # (`{"type": "profile", "id": "..."}`, not `{"profileId": ...}`), + # codex requires a matching `[permissions]` table in + # ~/.codex/config.toml or it fails the request with + # 'default_permissions requires a [permissions] table'. + # Letting codex pick its default (`:read-only` unless the user has + # configured otherwise in their codex config.toml) is the standard + # codex CLI workflow and avoids fighting codex's own validation. + # Users who want a write-capable profile configure it in their + # ~/.codex/config.toml the same way they would for any codex usage. + params: dict[str, Any] = {"cwd": self._cwd} + result = self._client.request("thread/start", params, timeout=15) + # Cross-fill thread.id/sessionId — different codex versions have + # serialized this under either key. Mirrors openclaw beta.8's + # tolerance fix so future codex drops/renames don't KeyError us + # at handshake time. + thread_obj = result.get("thread") or {} + thread_id = ( + thread_obj.get("id") + or thread_obj.get("sessionId") + or result.get("sessionId") + or result.get("threadId") + ) + if not thread_id: + raise CodexAppServerError( + code=-32603, + message=( + "codex thread/start returned no thread id " + f"(payload keys: {sorted(result.keys())})" + ), + ) + self._thread_id = thread_id + logger.info( + "codex app-server thread started: id=%s profile=%s cwd=%s", + self._thread_id[:8], + self._permission_profile, + self._cwd, + ) + return self._thread_id + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._client is not None: + try: + self._client.close() + except Exception: # pragma: no cover - best-effort cleanup + pass + self._client = None + self._thread_id = None + + def __enter__(self) -> "CodexAppServerSession": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + # ---------- interrupt ---------- + + def request_interrupt(self) -> None: + """Idempotent: signal the active turn loop to issue turn/interrupt + and unwind. Called by AIAgent's _interrupt_requested path.""" + self._interrupt_event.set() + + # ---------- diagnostics ---------- + + def _format_error_with_stderr( + self, + prefix: str, + exc: Any = "", + *, + tail_lines: int = _STDERR_TAIL_LINES, + ) -> str: + """Build a user-facing error string for codex failures. + + Appends the last few lines of codex's stderr buffer when available, + passed through agent.redact with force=True so secrets in provider + error responses (auth headers, query-string tokens, sk-* keys) never + leak into chat output or trajectories. The codex CLI's own error + text ('Internal error', 'turn/start failed: ...') is otherwise + opaque and forces users to re-run with verbose flags to diagnose + config / provider / auth-bridge problems. + + Use this for the generic / catch-all branches. Specific + classifications (OAuth via _classify_oauth_failure, post-tool wedge + watchdog) already produce a clean hint and should be used instead. + """ + exc_str = str(exc) if exc != "" and exc is not None else "" + base = f"{prefix}: {exc_str}" if exc_str else prefix + if self._client is None: + return base + try: + tail = self._client.stderr_tail(tail_lines) + except Exception: # pragma: no cover - diagnostic best-effort + return base + if not tail: + return base + joined = "\n".join(line.rstrip() for line in tail if line) + if not joined.strip(): + return base + redacted = redact_sensitive_text(joined, force=True) + return f"{base}\ncodex stderr (last {len(tail)} lines):\n{redacted}" + + # ---------- per-turn ---------- + + def run_turn( + self, + user_input: str, + *, + turn_timeout: float = 600.0, + notification_poll_timeout: float = 0.25, + post_tool_quiet_timeout: float = 90.0, + ) -> TurnResult: + """Send a user message and block until turn/completed, while + forwarding server-initiated approval requests and projecting items + into Hermes' messages shape. + + post_tool_quiet_timeout: if codex emits a tool completion and then + goes quiet for this many seconds without emitting another item or + `turn/completed`, fast-fail and mark the session for retirement. + Mirrors openclaw beta.8's post-tool completion watchdog (#81697) + so a wedged codex doesn't burn the full turn deadline. + """ + # Pre-create the result so startup failures (codex subprocess can't + # spawn, initialize handshake rejects, thread/start blows up) surface + # the same way per-turn failures do — with a TurnResult.error string + # the caller can render — instead of bubbling raw codex exceptions + # up to AIAgent.run_conversation. + result = TurnResult() + try: + self.ensure_started() + except (CodexAppServerError, TimeoutError) as exc: + result.error = self._format_error_with_stderr( + "codex app-server startup failed", exc + ) + # Subprocess almost certainly unhealthy — retire so the next + # turn re-spawns cleanly. + result.should_retire = True + return result + assert self._client is not None and self._thread_id is not None + result.thread_id = self._thread_id + + self._interrupt_event.clear() + projector = CodexEventProjector() + + # Send turn/start with the user input. Text-only for now (codex + # supports rich content but Hermes' text path is the common case). + try: + ts = self._client.request( + "turn/start", + { + "threadId": self._thread_id, + "input": [{"type": "text", "text": user_input}], + }, + timeout=10, + ) + except CodexAppServerError as exc: + # Classify auth/refresh failures so the user gets a clear + # `codex login` pointer instead of a raw RPC error string. + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(exc.message, stderr_blob) + if hint is not None: + result.error = hint + # Subprocess is fine on a JSON-RPC level here, but the + # token store is broken — retire so the next turn does a + # clean handshake (and the user has a chance to re-auth + # via `codex login` between turns). + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + "turn/start failed", exc + ) + return result + except TimeoutError as exc: + # turn/start hanging is a strong signal the subprocess is wedged. + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(stderr_blob) + result.error = hint or self._format_error_with_stderr( + "turn/start timed out", exc + ) + result.should_retire = True + return result + + result.turn_id = (ts.get("turn") or {}).get("id") + deadline = time.time() + turn_timeout + turn_complete = False + # Post-tool watchdog state. last_tool_completion_at is set whenever + # a tool-shaped item completes; if no further notification arrives + # within post_tool_quiet_timeout and the turn hasn't completed, we + # fast-fail and retire the session. + last_tool_completion_at: Optional[float] = None + + while time.time() < deadline and not turn_complete: + if self._interrupt_event.is_set(): + self._issue_interrupt(result.turn_id) + result.interrupted = True + break + + # Detect a dead subprocess between iterations. If codex exited + # (e.g. crashed, segfaulted, or its auth refresh thread killed + # the process), we won't get any more notifications — bail out + # rather than waiting for the full turn deadline. + if not self._client.is_alive(): + stderr_blob = "\n".join(self._client.stderr_tail(60)) + hint = _classify_oauth_failure(stderr_blob) + if hint is not None: + result.error = hint + else: + result.error = self._format_error_with_stderr( + "codex app-server subprocess exited unexpectedly", + tail_lines=20, + ) + result.should_retire = True + break + + # Post-tool watchdog: if a tool completion was the most recent + # signal and codex has been silent past the quiet timeout, give + # up on this turn instead of waiting for the outer deadline. + if ( + last_tool_completion_at is not None + and (time.time() - last_tool_completion_at) + > post_tool_quiet_timeout + ): + self._issue_interrupt(result.turn_id) + result.interrupted = True + result.error = ( + f"codex went silent for " + f"{post_tool_quiet_timeout:.0f}s after a tool result; " + f"retiring app-server session." + ) + result.should_retire = True + break + + # Drain any server-initiated requests (approvals) before + # reading notifications, so the codex side isn't blocked. + sreq = self._client.take_server_request(timeout=0) + if sreq is not None: + # Drain any pending notifications first so per-turn state + # (e.g. _pending_file_changes for fileChange approvals) is + # up to date when we make the approval decision. Bounded + # to avoid starving the server-request response. + for _ in range(8): + pending = self._client.take_notification(timeout=0) + if pending is None: + break + self._track_pending_file_change(pending) + proj = projector.project(pending) + if proj.messages: + result.projected_messages.extend(proj.messages) + if proj.is_tool_iteration: + result.tool_iterations += 1 + last_tool_completion_at = time.time() + if proj.final_text is not None: + result.final_text = proj.final_text + if _has_turn_aborted_marker(proj.final_text): + turn_complete = True + result.interrupted = True + result.error = ( + result.error + or "codex reported turn_aborted" + ) + self._handle_server_request(sreq) + # Activity counts as live signal — reset the post-tool + # quiet timer so an approval round-trip doesn't trip it. + last_tool_completion_at = None + continue + + note = self._client.take_notification( + timeout=notification_poll_timeout + ) + if note is None: + continue + + method = note.get("method", "") + if self._on_event is not None: + try: + self._on_event(note) + except Exception: # pragma: no cover - display callback + logger.debug("on_event callback raised", exc_info=True) + + # Track in-progress fileChange items so the approval bridge + # can surface a real change summary when codex requests + # approval (the approval params themselves don't carry the + # changeset). Quirk #4 fix. + self._track_pending_file_change(note) + + # Project into messages + projection = projector.project(note) + if projection.messages: + result.projected_messages.extend(projection.messages) + if projection.is_tool_iteration: + result.tool_iterations += 1 + # Arm/refresh the post-tool quiet watchdog whenever a + # tool-shaped item completes. + last_tool_completion_at = time.time() + else: + # Any non-tool projected activity (assistant message, + # status update, etc.) means codex is still producing + # output — clear the quiet timer so we don't fast-fail. + if projection.messages or projection.final_text is not None: + last_tool_completion_at = None + if projection.final_text is not None: + # Codex can emit multiple agentMessage items in one turn + # (e.g. partial then final). Take the last one as canonical. + result.final_text = projection.final_text + # Some codex builds tear a turn down by emitting a + # `` marker in the agent message text and + # never sending turn/completed. Treat the marker itself + # as terminal so we don't burn the full deadline. + if _has_turn_aborted_marker(projection.final_text): + turn_complete = True + result.interrupted = True + result.error = ( + result.error or "codex reported turn_aborted" + ) + + if method == "turn/completed": + turn_complete = True + turn_status = ( + (note.get("params") or {}).get("turn") or {} + ).get("status") + if turn_status and turn_status not in ("completed", "interrupted"): + err_obj = ( + (note.get("params") or {}).get("turn") or {} + ).get("error") + if err_obj: + err_msg = err_obj.get("message") or str(err_obj) + # If the turn failed for an auth/refresh reason, + # rewrite the error into a re-auth hint AND mark + # the session for retirement. + stderr_blob = "\n".join( + self._client.stderr_tail(40) + ) + hint = _classify_oauth_failure(err_msg, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + f"turn ended status={turn_status}", err_msg + ) + + if not turn_complete and not result.interrupted: + # Hit the deadline. Issue interrupt to stop wasted compute, and + # tell the caller to retire the session — a turn that never + # finished is a strong sign codex is wedged in a way the next + # turn shouldn't inherit. + self._issue_interrupt(result.turn_id) + result.interrupted = True + if not result.error: + result.error = self._format_error_with_stderr( + f"turn timed out after {turn_timeout}s" + ) + result.should_retire = True + + return result + + # ---------- internals ---------- + + def _issue_interrupt(self, turn_id: Optional[str]) -> None: + if self._client is None or self._thread_id is None or turn_id is None: + return + try: + self._client.request( + "turn/interrupt", + {"threadId": self._thread_id, "turnId": turn_id}, + timeout=5, + ) + except CodexAppServerError as exc: + # "no active turn to interrupt" is fine — already done. + logger.debug("turn/interrupt non-fatal: %s", exc) + except TimeoutError: + logger.warning("turn/interrupt timed out") + + def _handle_server_request(self, req: dict) -> None: + """Translate a codex server request (approval) into Hermes' approval + flow, then send the response. + + Method names verified live against codex 0.130.0 (Apr 2026): + item/commandExecution/requestApproval — exec approvals + item/fileChange/requestApproval — apply_patch approvals + item/permissions/requestApproval — permissions changes + (we decline; user controls + permission profile in + ~/.codex/config.toml). + """ + if self._client is None: + return + method = req.get("method", "") + rid = req.get("id") + params = req.get("params") or {} + + if method == "item/commandExecution/requestApproval": + decision = self._decide_exec_approval(params) + self._client.respond(rid, {"decision": decision}) + elif method == "item/fileChange/requestApproval": + decision = self._decide_apply_patch_approval(params) + self._client.respond(rid, {"decision": decision}) + elif method == "item/permissions/requestApproval": + # Codex sometimes asks to escalate permissions mid-turn. We + # always decline — the user already chose their permission + # profile in ~/.codex/config.toml and surprise escalations + # shouldn't be silently accepted. + self._client.respond(rid, {"decision": "decline"}) + elif method == "mcpServer/elicitation/request": + # Codex's MCP layer asks the user for structured input on + # behalf of an MCP server (e.g. tool-call confirmation, + # OAuth, form data). For our own hermes-tools callback we + # auto-accept — the user already approved Hermes' tools + # by enabling the runtime, and we never expose anything + # codex's built-in shell can't already do. For other MCP + # servers we decline so the user explicitly opts in via + # codex's own auth flow. + server_name = params.get("serverName") or "" + if server_name == "hermes-tools": + self._client.respond( + rid, + {"action": "accept", "content": None, "_meta": None}, + ) + else: + self._client.respond( + rid, + {"action": "decline", "content": None, "_meta": None}, + ) + else: + # Unknown server request — codex can extend this surface. Reject + # cleanly so codex doesn't hang waiting for us. + logger.warning("Unknown codex server request: %s", method) + self._client.respond_error( + rid, code=-32601, message=f"Unsupported method: {method}" + ) + + def _decide_exec_approval(self, params: dict) -> str: + if self._routing.auto_approve_exec: + return "accept" + command = params.get("command") or "" + # Codex's CommandExecutionRequestApprovalParams has cwd as Optional — + # fall back to the session's cwd when codex doesn't include it so the + # approval prompt is never empty (quirk #10 fix). + cwd = params.get("cwd") or self._cwd or "" + reason = params.get("reason") + description = f"Codex requests exec in {cwd}" + if reason: + description += f" — {reason}" + if self._approval_callback is not None: + try: + choice = self._approval_callback( + command, description, allow_permanent=False + ) + return _approval_choice_to_codex_decision(choice) + except Exception: + logger.exception("approval_callback raised on exec request") + return "decline" + return "decline" # fail-closed when no callback wired + + def _decide_apply_patch_approval(self, params: dict) -> str: + if self._routing.auto_approve_apply_patch: + return "accept" + if self._approval_callback is not None: + # FileChangeRequestApprovalParams gives us reason + grantRoot. + # The actual changeset lives on the corresponding fileChange + # item which the projector has already cached for us — look it + # up by item_id so the user sees what's actually changing. + reason = params.get("reason") + grant_root = params.get("grantRoot") + item_id = params.get("itemId") or "" + change_summary = self._lookup_pending_file_change(item_id) + description_parts = [] + if reason: + description_parts.append(reason) + if change_summary: + description_parts.append(change_summary) + if grant_root: + description_parts.append(f"grants write to {grant_root}") + description = ( + "; ".join(description_parts) + if description_parts + else "Codex requests to apply a patch" + ) + command_label = ( + f"apply_patch: {change_summary}" if change_summary + else f"apply_patch: {reason}" if reason + else "apply_patch" + ) + try: + choice = self._approval_callback( + command_label, + description, + allow_permanent=False, + ) + return _approval_choice_to_codex_decision(choice) + except Exception: + logger.exception("approval_callback raised on apply_patch") + return "decline" + return "decline" + + def _track_pending_file_change(self, note: dict) -> None: + """Maintain self._pending_file_changes from item/started + item/completed + notifications. Lets the apply_patch approval prompt show what's + actually changing — codex's approval params don't carry the data.""" + method = note.get("method", "") + params = note.get("params") or {} + item = params.get("item") or {} + if item.get("type") != "fileChange": + return + item_id = item.get("id") or "" + if not item_id: + return + if method == "item/started": + changes = item.get("changes") or [] + if not changes: + self._pending_file_changes[item_id] = "1 change pending" + return + kinds: dict[str, int] = {} + paths: list[str] = [] + for ch in changes: + if not isinstance(ch, dict): + continue + kind = (ch.get("kind") or {}).get("type") or "update" + kinds[kind] = kinds.get(kind, 0) + 1 + p = ch.get("path") or "" + if p: + paths.append(p) + counts = ", ".join(f"{n} {k}" for k, n in sorted(kinds.items())) + preview = ", ".join(paths[:3]) + if len(paths) > 3: + preview += f", +{len(paths) - 3} more" + self._pending_file_changes[item_id] = ( + f"{counts}: {preview}" if preview else counts + ) + elif method == "item/completed": + self._pending_file_changes.pop(item_id, None) + + def _lookup_pending_file_change(self, item_id: str) -> Optional[str]: + """Look up an in-progress fileChange item by id and summarize its + changes for the approval prompt. Returns None when we don't have + the item cached (e.g. approval arrived before item/started, or + fileChange item content not tracked yet).""" + if not item_id: + return None + cached = self._pending_file_changes.get(item_id) + if not cached: + return None + return cached + + +def _approval_choice_to_codex_decision(choice: str) -> str: + """Map Hermes approval choices onto codex's CommandExecutionApprovalDecision + / FileChangeApprovalDecision wire values. + + Hermes returns 'once', 'session', 'always', or 'deny'. + Codex expects 'accept', 'acceptForSession', 'decline', or 'cancel' + (verified against codex-rs/app-server-protocol/src/protocol/v2/item.rs + on codex 0.130.0). + """ + if choice in ("once",): + return "accept" + if choice in ("session", "always"): + return "acceptForSession" + return "decline" + + +def _has_turn_aborted_marker(text: str) -> bool: + """Return True if `text` contains any of the raw markers codex uses + to signal a turn was aborted without emitting `turn/completed`. + + Codex emits `` (and sometimes ``) as raw + text inside agentMessage items when an interrupt or upstream error + tears the turn down before the normal completion path fires. Mirrors + openclaw beta.8's terminal-marker fix so we don't burn the full turn + deadline waiting for a turn/completed that never comes. + """ + if not text: + return False + for marker in _TURN_ABORTED_MARKERS: + if marker in text: + return True + return False + + +def _get_hermes_version() -> str: + """Best-effort Hermes version string for codex's userAgent line.""" + try: + from importlib.metadata import version + + return version("hermes-agent") + except Exception: # pragma: no cover + return "0.0.0" diff --git a/agent/transports/codex_event_projector.py b/agent/transports/codex_event_projector.py new file mode 100644 index 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/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/agent/web_search_provider.py b/agent/web_search_provider.py new file mode 100644 index 00000000000..7223bbf2cfe --- /dev/null +++ b/agent/web_search_provider.py @@ -0,0 +1,221 @@ +""" +Web Search Provider ABC +======================= + +Defines the pluggable-backend interface for web search and content extraction. +Providers register instances via ``PluginContext.register_web_search_provider()``; +the active one (selected via ``web.search_backend`` / ``web.extract_backend`` / +``web.backend`` in ``config.yaml``) services every ``web_search`` / +``web_extract`` tool call. + +Providers live in ``/plugins/web//`` (built-in, auto-loaded as +``kind: backend``) or ``~/.hermes/plugins/web//`` (user, opt-in via +``plugins.enabled``). + +This ABC is the SINGLE plugin-facing surface for web providers — every +provider in the tree (brave-free, ddgs, searxng, exa, parallel, tavily, +firecrawl) implements it. The legacy in-tree ``tools.web_providers.base`` +ABCs were deleted in PR #25182 along with the per-vendor inline helpers +in ``tools/web_tools.py``; the response-shape contract documented below +is preserved bit-for-bit so the tool wrapper does not have to translate. + +Response shape (preserved from the legacy contract): + +Search results:: + + { + "success": True, + "data": { + "web": [ + {"title": str, "url": str, "description": str, "position": int}, + ... + ] + } + } + +Extract results:: + + { + "success": True, + "data": [ + {"url": str, "title": str, "content": str, + "raw_content": str, "metadata": dict}, + ... + ] + } + +On failure (either capability):: + + {"success": False, "error": str} +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict, List + + +# --------------------------------------------------------------------------- +# ABC +# --------------------------------------------------------------------------- + + +class WebSearchProvider(abc.ABC): + """Abstract base class for a web search/extract/crawl backend. + + Subclasses must implement :meth:`is_available` and at least one of + :meth:`search` / :meth:`extract` / :meth:`crawl`. The + :meth:`supports_search` / :meth:`supports_extract` / :meth:`supports_crawl` + capability flags let the registry route each tool call to the right + provider, and let multi-capability providers (Firecrawl, Tavily, Exa, + …) advertise multiple capabilities from a single class. + """ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used in ``web.search_backend`` / + ``web.extract_backend`` / ``web.backend`` config keys. + + Lowercase, no spaces; hyphens permitted to preserve existing + user-visible names. Examples: ``brave-free``, ``ddgs``, + ``searxng``, ``firecrawl``. + """ + + @property + def display_name(self) -> str: + """Human-readable label shown in ``hermes tools``. Defaults to ``name``.""" + return self.name + + @abc.abstractmethod + def is_available(self) -> bool: + """Return True when this provider can service calls. + + Typically a cheap check (env var present, optional Python dep + importable, instance URL set). Must NOT make network calls — this + runs at tool-registration time and on every ``hermes tools`` paint. + """ + + def supports_search(self) -> bool: + """Return True if this provider implements :meth:`search`.""" + return True + + def supports_extract(self) -> bool: + """Return True if this provider implements :meth:`extract`. + + Both sync and async :meth:`extract` implementations are valid — the + dispatcher detects coroutine functions via + :func:`inspect.iscoroutinefunction` and awaits as needed. Sync + implementations that perform blocking I/O (HTTP, SDK calls) should + ideally wrap in :func:`asyncio.to_thread` at the call site; small + providers can keep their sync shape and let the dispatcher handle + threading. + """ + return False + + def supports_crawl(self) -> bool: + """Return True if this provider implements :meth:`crawl`. + + Crawl differs from extract in that the agent provides a *seed URL* + and the provider walks linked pages on its own — useful for + documentation sites where the agent doesn't know all relevant + URLs upfront. Tavily is the only built-in backend that natively + crawls today; Firecrawl provides a similar capability that we + don't currently surface as a tool. + + Providers that don't crawl should leave this as False; the + dispatcher in :func:`tools.web_tools.web_crawl_tool` will fall + back to its auxiliary-model summarization path. + """ + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a web search. + + Override when :meth:`supports_search` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_search` before calling. + """ + raise NotImplementedError( + f"{self.name} does not support search (override supports_search)" + ) + + def extract(self, urls: List[str], **kwargs: Any) -> Any: + """Extract content from one or more URLs. + + Override when :meth:`supports_extract` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_extract` before calling. + + Return shape: a list of result dicts matching what the legacy + :func:`tools.web_tools.web_extract_tool` post-processing pipeline + expects:: + + [ + { + "url": str, + "title": str, + "content": str, + "raw_content": str, + "metadata": dict, # optional + "error": str, # optional, only on per-URL failure + }, + ... + ] + + Implementations MAY be ``async def`` — the dispatcher detects + coroutines via :func:`inspect.iscoroutinefunction` and awaits. + + ``kwargs`` may carry forward-compat fields (``format``, ``include_raw``, + ``max_chars``) — implementations should ignore unknown keys. + """ + raise NotImplementedError( + f"{self.name} does not support extract (override supports_extract)" + ) + + def crawl(self, url: str, **kwargs: Any) -> Any: + """Crawl a seed URL and return results. + + Override when :meth:`supports_crawl` returns True. The default + raises NotImplementedError; callers should gate on + :meth:`supports_crawl` before calling. + + Return shape: ``{"results": [{"url": str, "title": str, + "content": str, ...}, ...]}`` matching what + :func:`tools.web_tools.web_crawl_tool` post-processing expects. + + Implementations MAY be ``async def``. + + ``kwargs`` may carry forward-compat fields (e.g. ``max_depth``, + ``include_domains``) — implementations should ignore unknown keys. + """ + raise NotImplementedError( + f"{self.name} does not support crawl (override supports_crawl)" + ) + + def get_setup_schema(self) -> Dict[str, Any]: + """Return provider metadata for the ``hermes tools`` picker. + + Used by ``hermes_cli/tools_config.py`` to inject this provider as a + row in the Web Search / Web Extract picker. Shape:: + + { + "name": "Brave Search (Free)", + "badge": "free", + "tag": "No paid tier needed — uses Brave's free API.", + "env_vars": [ + {"key": "BRAVE_SEARCH_API_KEY", + "prompt": "Brave Search API key", + "url": "https://brave.com/search/api/"}, + ], + } + + Default: minimal entry derived from ``display_name``. Override to + expose API key prompts, badges, and instance URL fields. + """ + return { + "name": self.display_name, + "badge": "", + "tag": "", + "env_vars": [], + } diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py new file mode 100644 index 00000000000..c61c16cadb2 --- /dev/null +++ b/agent/web_search_registry.py @@ -0,0 +1,262 @@ +""" +Web Search Provider Registry +============================ + +Central map of registered web providers. Populated by plugins at import-time +via :meth:`PluginContext.register_web_search_provider`; consumed by the +``web_search`` and ``web_extract`` tool wrappers in :mod:`tools.web_tools` to +dispatch each call to the active backend. + +Active selection +---------------- +The active provider is chosen by configuration with this precedence: + +1. ``web.search_backend`` / ``web.extract_backend`` / ``web.crawl_backend`` + (per-capability override). +2. ``web.backend`` (shared fallback). +3. If exactly one capability-eligible provider is registered AND available, + use it. +4. Legacy preference order — ``firecrawl`` → ``parallel`` → ``tavily`` → + ``exa`` → ``searxng`` → ``brave-free`` → ``ddgs`` — filtered by + availability. Matches the historic ``tools.web_tools._get_backend()`` + candidate order so installs that never set a config key keep landing + on the same provider they did before the plugin migration. +5. Otherwise ``None`` — the tool surfaces a helpful error pointing at + ``hermes tools``. + +The capability filter (``supports_search`` / ``supports_extract`` / +``supports_crawl``) is applied at every step so a search-only provider +(``brave-free``) configured as ``web.extract_backend`` correctly falls +through to an extract-capable backend. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +_providers: Dict[str, WebSearchProvider] = {} +_lock = threading.Lock() + + +def register_provider(provider: WebSearchProvider) -> None: + """Register a web search/extract provider. + + Re-registration (same ``name``) overwrites the previous entry and logs + a debug message — makes hot-reload scenarios (tests, dev loops) behave + predictably. + """ + if not isinstance(provider, WebSearchProvider): + raise TypeError( + f"register_provider() expects a WebSearchProvider instance, " + f"got {type(provider).__name__}" + ) + name = provider.name + if not isinstance(name, str) or not name.strip(): + raise ValueError("Web provider .name must be a non-empty string") + with _lock: + existing = _providers.get(name) + _providers[name] = provider + if existing is not None: + logger.debug( + "Web provider '%s' re-registered (was %r)", + name, type(existing).__name__, + ) + else: + logger.debug( + "Registered web provider '%s' (%s)", + name, type(provider).__name__, + ) + + +def list_providers() -> List[WebSearchProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + items = list(_providers.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider(name: str) -> Optional[WebSearchProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + with _lock: + return _providers.get(name.strip()) + + +# --------------------------------------------------------------------------- +# Active-provider resolution +# --------------------------------------------------------------------------- + + +def _read_config_key(*path: str) -> Optional[str]: + """Resolve a dotted config key from ``config.yaml``. Returns None on miss.""" + try: + from hermes_cli.config import load_config + + cfg = load_config() + cur = cfg + for segment in path: + if not isinstance(cur, dict): + return None + cur = cur.get(segment) + if isinstance(cur, str) and cur.strip(): + return cur.strip() + except Exception as exc: + logger.debug("Could not read config %s: %s", ".".join(path), exc) + return None + + +# Legacy preference order — preserves behaviour for users who set no +# ``web.backend`` / ``web._backend`` config key at all. Matches +# the historic candidate order in :func:`tools.web_tools._get_backend` +# (paid providers first so existing paid setups don't get downgraded to +# a free tier on upgrade). Filtered by ``is_available()`` at walk time so +# we don't surface a provider the user has no credentials for. +_LEGACY_PREFERENCE = ( + "firecrawl", + "parallel", + "tavily", + "exa", + "searxng", + "brave-free", + "ddgs", +) + + +def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: + """Resolve the active provider for a capability ("search" | "extract" | "crawl"). + + Resolution rules (in order): + + 1. **Explicit config wins, ignoring availability.** If + ``web.{capability}_backend`` or ``web.backend`` names a registered + provider that supports *capability*, return it even if its + :meth:`is_available` returns False — the dispatcher will surface a + precise "X_API_KEY is not set" error to the user instead of silently + routing somewhere else. Matches legacy + :func:`tools.web_tools._get_backend` behavior for configured names. + + 2. **Single-provider shortcut.** When only one registered provider + supports *capability* AND ``is_available()`` reports True, return it. + + 3. **Legacy preference walk, filtered by availability.** Walk the + :data:`_LEGACY_PREFERENCE` order (firecrawl → parallel → tavily → + exa → searxng → brave-free → ddgs) looking for a provider whose + ``supports_()`` is True AND whose ``is_available()`` is + True. Matches the historic ``tools.web_tools._get_backend()`` + candidate order so users with credentials but no explicit config + key keep landing on the same provider as pre-migration. This is + the path that fires when no config key is set — pick the + highest-priority backend the user actually has credentials for. + + Returns None when no provider is configured AND no available provider + matches the legacy preference; the dispatcher then returns a "set up a + provider" error to the user. + """ + with _lock: + snapshot = dict(_providers) + + def _capable(p: WebSearchProvider) -> bool: + if capability == "search": + return bool(p.supports_search()) + if capability == "extract": + return bool(p.supports_extract()) + if capability == "crawl": + return bool(p.supports_crawl()) + return False + + def _is_available_safe(p: WebSearchProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("provider %s.is_available() raised %s", p.name, exc) + return False + + # 1. Explicit config wins — return regardless of is_available() so the + # user gets a precise downstream error message rather than a silent + # backend switch. Matches _get_backend() in web_tools.py. + if configured: + provider = snapshot.get(configured) + if provider is not None and _capable(provider): + return provider + if provider is None: + logger.debug( + "web backend '%s' configured but not registered; falling back", + configured, + ) + else: + logger.debug( + "web backend '%s' configured but does not support '%s'; falling back", + configured, capability, + ) + + # 2. + 3. Fallback path — filter by availability so we don't surface + # a provider the user has no credentials for. Without this filter, + # a registered-but-unconfigured provider could end up "active" on + # a fresh install with no API keys at all. + eligible = [ + p for p in snapshot.values() + if _capable(p) and _is_available_safe(p) + ] + if len(eligible) == 1: + return eligible[0] + + for legacy in _LEGACY_PREFERENCE: + provider = snapshot.get(legacy) + if ( + provider is not None + and _capable(provider) + and _is_available_safe(provider) + ): + return provider + + return None + + +def get_active_search_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web search provider. + + Reads ``web.search_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + """ + explicit = _read_config_key("web", "search_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="search") + + +def get_active_extract_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web extract provider. + + Reads ``web.extract_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + """ + explicit = _read_config_key("web", "extract_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="extract") + + +def get_active_crawl_provider() -> Optional[WebSearchProvider]: + """Resolve the currently-active web crawl provider. + + Reads ``web.crawl_backend`` (preferred) or ``web.backend`` (shared + fallback) from config.yaml; falls back per the module docstring. + + Crawl is a niche capability — among built-in providers only Tavily and + Firecrawl implement it. Callers should expect ``None`` and fall back to + a different strategy (e.g. summarize-via-LLM) when neither is + configured. + """ + explicit = _read_config_key("web", "crawl_backend") or _read_config_key("web", "backend") + return _resolve(explicit, capability="crawl") + + +def _reset_for_tests() -> None: + """Clear the registry. **Test-only.**""" + with _lock: + _providers.clear() diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index d7239c2ad11..71a97113c24 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/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/apps/dashboard/src/pages/AnalyticsPage.tsx b/apps/dashboard/src/pages/AnalyticsPage.tsx index 57943eba6f2..4896e760636 100644 --- a/apps/dashboard/src/pages/AnalyticsPage.tsx +++ b/apps/dashboard/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/apps/dashboard/src/pages/ModelsPage.tsx b/apps/dashboard/src/pages/ModelsPage.tsx index 01c239d7034..f09104d4241 100644 --- a/apps/dashboard/src/pages/ModelsPage.tsx +++ b/apps/dashboard/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} /> ))}
diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 6daceba04a9..f5fb7156380 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -364,6 +364,18 @@ compression: # compression of older turns. protect_last_n: 20 + # Number of non-system messages to protect at the head of the transcript, in + # ADDITION to the system prompt (which is always implicitly protected). + # Head messages are NEVER summarized — they survive every compression + # indefinitely. This gives stable early context for short/medium sessions, + # but in long-running sessions that rely on rolling compaction the pinned + # opening turns may not match how you want the session framed over time. + # Set to 0 to preserve ONLY the system prompt (plus the rolling summary + # and recent tail) — the cleanest configuration for long-running sessions. + # Default 3 preserves the system prompt plus the first three non-system + # head messages, matching the pre-feature behaviour. + protect_first_n: 3 + # To pin a specific model/provider for compression summaries, use the # auxiliary section below (auxiliary.compression.provider / model). @@ -445,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 @@ -669,6 +681,16 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# +# Discord-specific settings (config.yaml top-level, not under platforms:): +# +# discord: +# require_mention: true # Require @mention in server channels (default: true) +# auto_thread: true # Auto-create thread on @mention (default: true) +# free_response_channels: "" # Channel IDs where no mention is needed +# reactions: true # Show processing reactions (default: true) +# history_backfill: true # Recover missed channel messages on mention (default: true) +# history_backfill_limit: 50 # Max messages to scan backwards (default: 50) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) @@ -693,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 @@ -722,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/cli.py b/cli.py index da2f32954ba..50e7a8c8ce9 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: @@ -1415,9 +1670,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 +1711,10 @@ def _record_output_history_entry(entry) -> None: def _record_output_history(text: str) -> None: if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED: return - clean = _ANSI_CONTROL_RE.sub("", str(text)).replace("\r", "").rstrip("\n") - if not clean: + normalized = str(text).replace("\r", "").rstrip("\n") + if not normalized: return - for line in clean.splitlines(): + for line in normalized.splitlines(): _record_output_history_entry(line) @@ -1473,6 +1725,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 +1736,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: @@ -1705,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": @@ -2639,6 +2863,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 @@ -2703,9 +2933,36 @@ 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. + + We also flag ``_status_bar_suppressed_after_resize`` so the dynamic + status bar and input separator rules stay hidden until the next user + input. On column shrink the terminal reflows already-rendered status + bar rows into scrollback before prompt_toolkit can erase them; drawing + a fresh full-width bar immediately makes the old and new versions + look duplicated (#19280, #22976). Clearing the suppression on the + next prompt restores the bar cleanly. + """ + self._status_bar_suppressed_after_resize = True + try: + app.renderer.reset(leave_alternate_screen=False) + except Exception: + pass + try: + app.invalidate() + except Exception: + pass original_on_resize() def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: @@ -2940,10 +3197,36 @@ class HermesCLI: width = self._get_tui_terminal_width() return width < 64 + @staticmethod + def _scrollback_box_width(width: Optional[int] = None) -> int: + """Return the full viewport width for printed scrollback box rules. + + 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. + + 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, 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.""" 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 @@ -3051,8 +3334,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] @@ -3060,6 +3346,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"]: @@ -3077,6 +3365,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'}" @@ -3093,6 +3383,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 = [ @@ -3100,8 +3391,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 "--" @@ -3119,8 +3413,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"]) @@ -3153,6 +3450,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) @@ -3453,7 +3753,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}") @@ -3477,7 +3777,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 @@ -3668,7 +3968,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}") @@ -3769,7 +4069,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: @@ -5899,6 +6199,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. @@ -6596,7 +6928,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 @@ -6606,16 +6938,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: @@ -6623,14 +6964,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 = [] @@ -6756,6 +7092,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): @@ -7434,8 +7810,12 @@ 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": + self._handle_codex_runtime(cmd_original) elif canonical == "gquota": self._handle_gquota_command(cmd_original) @@ -7583,6 +7963,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": @@ -7600,6 +7982,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 @@ -7801,8 +8185,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" @@ -7817,6 +8201,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) else: _cprint(" (No response generated)") @@ -8179,6 +8564,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. @@ -8205,10 +8665,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 @@ -8301,7 +8787,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: @@ -9198,7 +9685,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 @@ -9277,20 +9764,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: @@ -9895,7 +10368,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 = { @@ -10389,7 +10862,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')} " @@ -10674,7 +11147,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}" @@ -10694,18 +11167,18 @@ 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 if use_streaming_tts and _streaming_box_opened and not is_error_response: # Text was already printed sentence-by-sentence; just close the box - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") elif already_streamed: # Response was already streamed token-by-token with box framing; @@ -10721,6 +11194,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) @@ -10937,13 +11411,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: @@ -11029,6 +11538,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 @@ -12754,7 +13270,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. @@ -12789,11 +13308,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', @@ -12801,6 +13325,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 @@ -12852,19 +13377,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(): @@ -12906,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 @@ -12923,6 +13491,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): @@ -13019,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 @@ -13159,6 +13724,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(): @@ -13179,12 +13768,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: 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/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/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/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/gateway/config.py b/gateway/config.py index d4ce43fdee6..db32a70be5c 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -74,6 +74,24 @@ def _normalize_notice_delivery(value: Any, default: str = "public") -> str: return default +def _ensure_platform_extra_dict(platforms_data: dict, name: str) -> tuple[dict, dict]: + """Get-or-create ``platforms_data[name]`` and its nested ``extra`` dict. + + Both slots are coerced to ``{}`` if a non-dict value is encountered, so + callers can safely write keys without type-checking. Returns + ``(plat_data, extra)`` for in-place mutation. + """ + plat_data = platforms_data.setdefault(name, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[name] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + return plat_data, extra + + # Module-level cache for bundled platform plugin names (lives outside the # enum so it doesn't become an accidental enum member). _Platform__bundled_plugin_names: Optional[set] = None @@ -717,6 +735,10 @@ def load_gateway_config() -> GatewayConfig: gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"] streaming_cfg = yaml_cfg.get("streaming") + if not isinstance(streaming_cfg, dict): + # Fall back to nested gateway.streaming written by + # ``hermes config set gateway.streaming.*`` + streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -755,7 +777,27 @@ def load_gateway_config() -> GatewayConfig: merged["extra"] = merged_extra platforms_data[plat_name] = merged gw_data["platforms"] = platforms_data - for plat in Platform: + # Iterate built-in platforms plus any registered plugin platforms + # so plugin authors get the same shared-key bridging (#24836). + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry as _pr + except Exception as e: + logger.debug("plugin discovery skipped: %s", e) + _pr = None + + _shared_loop_targets: list = list(Platform) + if _pr is not None: + for _entry in _pr.plugin_entries(): + try: + _plat = Platform(_entry.name) + except (ValueError, KeyError): + continue + if _plat not in _shared_loop_targets: + _shared_loop_targets.append(_plat) + + for plat in _shared_loop_targets: if plat == Platform.LOCAL: continue platform_cfg = yaml_cfg.get(plat.value) @@ -810,20 +852,38 @@ def load_gateway_config() -> GatewayConfig: enabled_was_explicit = "enabled" in platform_cfg if not bridged and not enabled_was_explicit: continue - plat_data = platforms_data.setdefault(plat.value, {}) - if not isinstance(plat_data, dict): - plat_data = {} - platforms_data[plat.value] = plat_data + plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value) if enabled_was_explicit: plat_data["enabled"] = platform_cfg["enabled"] - extra = plat_data.setdefault("extra", {}) - if not isinstance(extra, dict): - extra = {} - plat_data["extra"] = extra - if enabled_was_explicit: + if plat == Platform.SLACK and enabled_was_explicit: extra["_enabled_explicit"] = True extra.update(bridged) + # Plugin-owned YAML→env config bridges (#24836). See + # ``PlatformEntry.apply_yaml_config_fn`` for the hook contract. + # Order: shared-key loop (above) → this dispatch → legacy hardcoded + # blocks (below; no-op when a hook already set their env var) → + # ``_apply_env_overrides()`` after ``GatewayConfig.from_dict``. + if _pr is not None: + for entry in _pr.all_entries(): + if entry.apply_yaml_config_fn is None: + continue + platform_cfg = yaml_cfg.get(entry.name) + if not isinstance(platform_cfg, dict): + continue + try: + seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg) + except Exception as e: + logger.debug( + "apply_yaml_config_fn for %s raised: %s", + entry.name, e, + ) + continue + if not isinstance(seeded, dict) or not seeded: + continue + _, extra = _ensure_platform_extra_dict(platforms_data, entry.name) + extra.update(seeded) + # Slack settings → env vars (env vars take precedence) slack_cfg = yaml_cfg.get("slack", {}) if isinstance(slack_cfg, dict): @@ -852,6 +912,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(discord_cfg, dict): if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() + if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): + os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): @@ -879,6 +941,14 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ntc, list): ntc = ",".join(str(v) for v in ntc) os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + # history_backfill: recover missed channel messages for shared sessions + # when require_mention is active. Fetches messages between bot turns + # and prepends them to the user message for context. + if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): + os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + hbl = discord_cfg.get("history_backfill_limit") + if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): + os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) # allow_mentions: granular control over what the bot can ping. # Safe defaults (no @everyone/roles) are applied in the adapter; # these YAML keys only override when set and let users opt back diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 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/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/gateway/platforms/base.py b/gateway/platforms/base.py index 0bf7b9a2ad9..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. @@ -1774,8 +1780,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 +1794,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/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/discord.py b/gateway/platforms/discord.py index 1817ece173d..a3904630fa9 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, @@ -3577,6 +3587,153 @@ 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 _discord_history_backfill(self) -> bool: + """Return whether history backfill is enabled for shared sessions.""" + configured = self.config.extra.get("history_backfill") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes") + + def _discord_history_backfill_limit(self) -> int: + """Return the max number of messages to scan backwards for context. + + In practice the scan usually stops much earlier — at the bot's own + last message in the channel (the natural partition point). This + limit is a safety cap for cold starts and long gaps where no prior + bot message exists in recent history. + """ + configured = self.config.extra.get("history_backfill_limit") + if configured is not None: + try: + return int(configured) + except (ValueError, TypeError): + pass + raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50") + try: + return int(raw) + except (ValueError, TypeError): + return 50 + + async def _fetch_channel_context( + self, + channel: Any, + before: "DiscordMessage", + ) -> str: + """Fetch recent channel messages for conversational context. + + Scans backwards from *before* and collects messages until it hits + a message sent by this bot (the natural partition point between + bot turns) or reaches ``history_backfill_limit``. + + Returns a formatted block like:: + + [Recent channel messages] + [Alice] some message + [Bob [bot]] another message + + Returns an empty string if no context is available. + """ + limit = self._discord_history_backfill_limit() + if limit <= 0: + return "" + + # Determine which bot messages to include in context + allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + include_other_bots = allow_bots_raw != "none" + + # Use the in-memory cache to narrow the fetch window on hot paths. + # If we know our last message ID in this channel, pass it as `after` + # to avoid scanning the full limit. Falls back to scanning on cache + # miss (cold start / restart). + # Guard: only use the cache when it's chronologically before the + # trigger — Discord snowflake IDs are monotonically increasing, so + # a simple int comparison suffices. + channel_id = str(getattr(channel, "id", "")) + _cached_id = self._last_self_message_id.get(channel_id) + _after_obj = None + try: + if _cached_id and int(_cached_id) < int(before.id): + _after_obj = discord.Object(id=int(_cached_id)) + except (ValueError, TypeError): + pass # Malformed cache entry — fall back to cold-start scan + + try: + collected = [] + # IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x + # silently flips the default to True when `after=` is supplied, + # which would select the *earliest* N messages after our last + # response instead of the *latest* N before the trigger. In + # high-traffic windows that returns stale tool traces and drops + # the actual final answer. See the regression test + # `test_fetch_channel_context_cache_uses_latest_window_when_after_set`. + async for msg in channel.history( + limit=limit, + before=before, + after=_after_obj, + oldest_first=False, + ): + # Stop at our own message — this is the partition point. + # Everything before this is already in the session transcript. + # (Redundant when _after_obj is set, but needed for cold start.) + if msg.author == self._client.user: + break + + # Skip system messages (pins, joins, thread renames, etc.) + if msg.type not in (discord.MessageType.default, discord.MessageType.reply): + continue + + # Respect DISCORD_ALLOW_BOTS for other bots. + # For history context, "mentions" is treated as "all" — we are + # deciding what context to show, not whether to respond. + if getattr(msg.author, "bot", False) and not include_other_bots: + continue + + content = getattr(msg, "clean_content", msg.content) or "" + if not content and msg.attachments: + content = "(attachment)" + if not content: + continue + + name = msg.author.display_name + if getattr(msg.author, "bot", False): + name = f"{name} [bot]" + collected.append(f"[{name}] {content}") + + if not collected: + return "" + + # channel.history returns newest-first (oldest_first=False); reverse for chronological order + collected.reverse() + return "[Recent channel messages]\n" + "\n".join(collected) + + except discord.Forbidden: + logger.debug("[%s] Missing permissions to fetch channel history", self.name) + return "" + except Exception as e: + logger.warning("[%s] Failed to fetch channel history: %s", self.name, e) + return "" + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -3877,6 +4034,84 @@ 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 = "", @@ -4167,6 +4402,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() @@ -4209,8 +4455,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: @@ -4223,7 +4476,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: @@ -4235,13 +4488,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 @@ -4300,7 +4555,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: @@ -4387,9 +4642,50 @@ 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. Messages in the channel between bot turns are + # invisible to the session transcript. To recover that context, + # fetch recent channel history and prepend it to the user message. + # + # The fetch window is: everything after the bot's last message in + # the channel up to (but not including) the current trigger. On + # cold start (no prior bot message found), fetch the last N messages + # and stop at the first self-message encountered. + # + # Threads naturally scope to thread-only history (channel.history() + # on a thread returns only that thread's messages). DMs are skipped + # because every DM message triggers the bot — there's no mention gap + # to fill; the session transcript already has everything. + # + # Per-user sessions also benefit: Alice's session is missing the + # other-channel-participants' context, and her own messages from + # before she mentioned the bot. Backfill fills that gap. + # + # Messages that arrive while the bot is processing (between trigger + # and response) are not captured — this is an accepted simplification + # to keep the partition rule clean. + _channel_context = None + _is_dm = isinstance(message.channel, discord.DMChannel) + if not _is_dm: + _needed_mention = ( + require_mention + and not is_free_channel + and not in_bot_thread + ) + _backfill_enabled = self._discord_history_backfill() + if _needed_mention and _backfill_enabled: + _backfill_text = await self._fetch_channel_context( + message.channel, before=message, + ) + if _backfill_text: + _channel_context = _backfill_text + # Defense-in-depth: prevent empty user messages from entering session - # (can happen when user sends @mention-only with no other text) - if not event_text or not event_text.strip(): + # (can happen when user sends @mention-only with no other text). + # When channel_context is present, a bare mention means "catch me up" + # — the context IS the message, so skip the placeholder. + if (not event_text or not event_text.strip()) and not _channel_context: event_text = "(The user sent a message with no text content)" _chan = message.channel @@ -4418,6 +4714,7 @@ 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 @@ -5099,3 +5396,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/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index ae3f7075104..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: @@ -1343,8 +1343,65 @@ 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. Rebinds all module-level globals on success. + """ + if FEISHU_AVAILABLE: + 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 0133dc2dac7..95dc73201c5 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. Rebinds all module-level type globals on success. + """ token = os.getenv("MATRIX_ACCESS_TOKEN", "") password = os.getenv("MATRIX_PASSWORD", "") homeserver = os.getenv("MATRIX_HOMESERVER", "") @@ -238,10 +242,31 @@ 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 + 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]'" + ) + 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/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/gateway/platforms/slack.py b/gateway/platforms/slack.py index 7fbefd446ca..2116b569f96 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -73,8 +73,29 @@ 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. Rebinds all module-level globals on success. + """ + 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, + } + + 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: @@ -1777,6 +1798,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. @@ -2744,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/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index db25b87497d..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 @@ -2070,7 +2077,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 +2089,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 +2341,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 +2355,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 +2465,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 +2501,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() @@ -2530,8 +2543,8 @@ class TelegramAdapter(BasePlatformAdapter): # Edit message to show confirmation, remove buttons try: await query.edit_message_text( - text=result_text, - parse_mode=ParseMode.MARKDOWN, + text=self.format_message(result_text), + parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None, ) except Exception: @@ -2571,13 +2584,15 @@ 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 +2675,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 +2729,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 +2755,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 +2916,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/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/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index d79da7856ae..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+\)$') @@ -925,6 +928,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) @@ -1645,6 +1649,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 +1691,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: @@ -2132,22 +2176,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) @@ -2155,15 +2200,26 @@ 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() @@ -2332,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: @@ -2391,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 @@ -2458,26 +2514,82 @@ 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: + # 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 _RESOLVABLE_MEDIA_KINDS: 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. @@ -2506,7 +2618,11 @@ class DispatchMiddleware(InboundMiddleware): event = MessageEvent( text=_patched_event_text, - message_type=ctx.msg_type, + message_type=( + MessageType.DOCUMENT + if any(mt.startswith(("application/", "text/")) for mt in media_types) + else ctx.msg_type + ), source=ctx.source, message_id=ctx.msg_id or None, raw_message=ctx.push, diff --git a/gateway/run.py b/gateway/run.py index 46c508e4bde..5e8fce8e18d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1139,6 +1139,38 @@ def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool: return True +def _preserve_queued_followup_history_offset( + current_result: dict, + followup_result: dict, +) -> dict: + """Carry the outer history offset through queued follow-up drains. + + ``_process_message_background()`` persists transcript rows only once, after the + entire in-band queued-follow-up chain returns. Each recursive ``_run_agent()`` + call advances ``history_offset`` to the history it received, so without + correction the outermost persistence step sees only the *last* queued turn as + "new" and silently drops earlier turns from the same drain chain. + + Preserve the earliest (outermost) history offset so the final transcript slice + still includes every queued turn that ran during the chain. + """ + if not isinstance(followup_result, dict): + return followup_result + if not isinstance(current_result, dict): + return followup_result + + current_offset = current_result.get("history_offset") + followup_offset = followup_result.get("history_offset") + if not isinstance(current_offset, int): + return followup_result + if isinstance(followup_offset, int) and followup_offset <= current_offset: + return followup_result + + merged = dict(followup_result) + merged["history_offset"] = current_offset + return merged + + class GatewayRunner: """ Main gateway controller. @@ -6096,6 +6128,12 @@ 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. @@ -6135,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. @@ -6430,6 +6474,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) @@ -6513,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) @@ -6759,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 = [] @@ -7935,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": @@ -9210,6 +9268,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 @@ -9438,6 +9541,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) @@ -10209,6 +10363,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( @@ -10216,6 +10374,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) @@ -10230,10 +10390,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) @@ -10269,6 +10434,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"], @@ -10300,7 +10482,7 @@ class GatewayRunner: ) try: return agent.run_conversation( - user_message=prompt, + user_message=enriched_prompt, task_id=task_id, ) finally: @@ -15957,6 +16139,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: @@ -16042,7 +16225,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 +16237,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: @@ -16117,12 +16301,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/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/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", 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/hermes_cli/auth.py b/hermes_cli/auth.py index 88acd1cd438..c6dce709384 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 @@ -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: + # 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", + } + 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 # ============================================================================= @@ -3870,6 +4391,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 +4432,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 = { @@ -3972,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) @@ -4042,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": @@ -4262,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 @@ -4561,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/banner.py b/hermes_cli/banner.py index 1cfb0d51f76..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}[/]") @@ -581,6 +584,19 @@ def build_welcome_banner(console: Console, model: str, cwd: str, if mcp_connected: summary_parts.append(f"{mcp_connected} MCP servers") summary_parts.append("/help for commands") + # Indicate when the codex_app_server runtime is active so users + # understand why tool counts may not match what's actually reachable + # (codex builds its own tool list inside the spawned subprocess). + try: + from hermes_cli.codex_runtime_switch import get_current_runtime + from hermes_cli.config import load_config as _load_cfg + if get_current_runtime(_load_cfg()) == "codex_app_server": + right_lines.append( + f"[bold {accent}]Runtime:[/] [{text}]codex app-server[/] " + f"[dim {dim}](terminal/file ops/MCP run inside codex)[/]" + ) + except Exception: + pass # Show active profile name when not 'default' try: from hermes_cli.profiles import get_active_profile_name diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index facc8f3c50a..a6b6da7c06a 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,15 @@ def _convert_to_png(path: Path) -> bool: return path.exists() and path.stat().st_size > 0 +def _is_png_file(path: Path) -> bool: + """Return True when *path* starts with the PNG file signature.""" + try: + with path.open("rb") as f: + return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE + except OSError: + return False + + # ── X11 (xclip) ───────────────────────────────────────────────────────── def _xclip_has_image() -> bool: diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py new file mode 100644 index 00000000000..4b30d3ebf26 --- /dev/null +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -0,0 +1,757 @@ +"""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 _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_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. + + The managed section is everything between MIGRATION_MARKER (start) and + MIGRATION_END_MARKER (end), inclusive of both markers. User-edited + sections above or below are preserved verbatim. + + Backward compatibility: if the start marker is found but no end marker + follows, we fall back to the heuristic that swallows lines until we + hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/ + a `default_permissions =` key. This matches what older versions of + this code wrote so re-runs don't break configs from prior Hermes + versions.""" + lines = toml_text.splitlines(keepends=True) + out: list[str] = [] + in_managed = False + saw_end_marker = False + for line in lines: + line_stripped_nl = line.rstrip("\n") + if line_stripped_nl == MIGRATION_MARKER: + in_managed = True + saw_end_marker = False + continue + if in_managed: + if line_stripped_nl == MIGRATION_END_MARKER: + in_managed = False + saw_end_marker = True + continue + stripped = line.lstrip() + if not saw_end_marker and stripped.startswith("[") and not ( + stripped.startswith("[mcp_servers") + or stripped.startswith("[plugins") + or stripped.startswith("[permissions]") + or stripped.startswith("[permissions.") + ): + # Old-format managed block without end marker: bail back + # to user content as soon as we see a non-managed section. + in_managed = False + out.append(line) + continue + # Otherwise swallow the line. + continue + out.append(line) + return "".join(out) + + +def _query_codex_plugins( + codex_home: Optional[Path] = None, + timeout: float = 8.0, +) -> tuple[list[dict], Optional[str]]: + """Query codex's `plugin/list` for installed curated plugins. + + Spawns `codex app-server` briefly, sends initialize + plugin/list, + extracts plugins where installed=true. Returns (plugins, error). + Plugins is a list of {name, marketplace, enabled} dicts ready for + render_codex_toml_section(). + + On any failure (codex not installed, RPC error, timeout) returns + ([], error_message). Migration treats this as non-fatal — MCP + servers and permissions still write through. + """ + try: + from agent.transports.codex_app_server import CodexAppServerClient + except Exception as exc: + return [], f"transport unavailable: {exc}" + + try: + with CodexAppServerClient( + codex_home=str(codex_home) if codex_home else None + ) as client: + client.initialize(client_name="hermes-migration") + resp = client.request("plugin/list", {}, timeout=timeout) + except Exception as exc: + return [], f"plugin/list query failed: {exc}" + + out: list[dict] = [] + seen: set[tuple[str, str]] = set() + marketplaces = resp.get("marketplaces") or [] + if not isinstance(marketplaces, list): + return [], "plugin/list response missing 'marketplaces'" + for marketplace in marketplaces: + if not isinstance(marketplace, dict): + continue + market_name = str(marketplace.get("name") or "openai-curated") + plugins = marketplace.get("plugins") or [] + if not isinstance(plugins, list): + continue + for plugin in plugins: + if not isinstance(plugin, dict): + continue + installed = bool(plugin.get("installed", False)) + if not installed: + continue + # Skip plugins codex itself reports as unavailable (broken + # install, missing OAuth, removed from marketplace, etc.). + # Cf. openclaw/openclaw#80815 — OpenClaw learned to gate + # migration on app readiness to avoid writing config that + # would fail at activation time. Our migration writes to + # codex's config.toml directly, so a broken plugin would + # surface as a codex error on first use. Skipping it here + # keeps the migrated config clean and the user's first + # codex turn from failing. + availability = str(plugin.get("availability") or "").upper() + if availability and availability != "AVAILABLE": + logger.debug( + "skipping plugin %s: availability=%s", + plugin.get("name"), availability, + ) + continue + name = str(plugin.get("name") or "") + if not name: + continue + key = (name, market_name) + if key in seen: + continue + seen.add(key) + # Carry forward whatever 'enabled' codex reports — defaults to + # true for installed plugins. This is the same shape OpenClaw + # writes when migrating native codex plugins. + out.append({ + "name": name, + "marketplace": market_name, + "enabled": bool(plugin.get("enabled", True)), + }) + return out, None + + +def _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 + 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. 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 + # 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] = [] + 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']}") + + # 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) + # 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 + + 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..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"), @@ -120,6 +122,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/completion.py b/hermes_cli/completion.py index 591ffecc62f..389cf2419cb 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -238,7 +238,7 @@ _hermes() {{ esac }} -_hermes "$@" +compdef _hermes hermes """ diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 52e08cb2cb5..1c0f836787e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -731,6 +731,12 @@ 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": 3, # non-system head messages always preserved + # verbatim, in ADDITION to the system prompt + # (which is always implicitly protected). Set to + # 0 for long-running rolling-compaction sessions + # where you want nothing pinned except the + # system prompt + rolling summary + recent tail. }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). @@ -971,6 +977,21 @@ 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 @@ -1235,6 +1256,9 @@ 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) + "history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out) + "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES @@ -2113,26 +2137,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"], - "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"], + "tools": ["image_generate", "video_generate"], "password": True, "category": "tool", }, @@ -4326,10 +4334,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 @@ -4345,10 +4377,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. @@ -4451,6 +4506,7 @@ def sanitize_env_file() -> int: pass raise _secure_file(env_path) + invalidate_env_cache() return fixes @@ -4562,6 +4618,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: @@ -4617,6 +4674,7 @@ def remove_env_value(key: str) -> bool: _secure_file(env_path) os.environ.pop(key, None) + invalidate_env_cache() return found @@ -4803,6 +4861,7 @@ def show_config(): print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved") print(f" Protect last: {compression.get('protect_last_n', 20)} messages") + print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages") _aux_comp = config.get('auxiliary', {}).get('compression', {}) _sm = _aux_comp.get('model', '') or '(auto)' print(f" Model: {_sm}") @@ -4922,8 +4981,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/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/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/goals.py b/hermes_cli/goals.py index 6a8a2ae971f..62ee00547c1 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__) @@ -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), @@ -65,6 +75,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 +113,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 +150,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 +163,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 +178,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) @@ -230,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)``. @@ -284,6 +370,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 +383,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 +413,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( @@ -334,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, ) @@ -397,14 +501,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 +562,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 +646,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 +733,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 +745,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/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/main.py b/hermes_cli/main.py index 22c3a7c230b..e4d846cd583 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1469,6 +1469,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") @@ -1938,6 +1949,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": @@ -2431,30 +2444,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 @@ -2490,33 +2504,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 @@ -2825,6 +2832,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-4.3")) + 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", @@ -3096,6 +3184,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 [] @@ -3159,7 +3262,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() @@ -3182,7 +3288,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.") @@ -3193,9 +3302,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. @@ -3231,12 +3411,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 @@ -3262,6 +3442,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) @@ -3276,6 +3463,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}} @@ -3729,7 +3918,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})") @@ -4886,6 +5075,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, []) @@ -5565,21 +5785,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if not _web_ui_build_needed(web_dir): return True + # Console-encoding-safe print: Windows consoles default to cp1252 + # (or similar) and will raise UnicodeEncodeError on arrow / check + # glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print + # in this function through _say() with errors="replace" keeps the + # build path usable on a stock `py -m hermes_cli.main web` invocation. + def _say(text: str) -> None: + try: + print(text) + except UnicodeEncodeError: + encoding = getattr(sys.stdout, "encoding", None) or "ascii" + print(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) + npm = shutil.which("npm") if not npm: if fatal: - print("Web UI frontend not built and npm is not available.") - print("Install Node.js, then run: cd apps/dashboard && npm install && npm run build") + _say("Web UI frontend not built and npm is not available.") + _say("Install Node.js, then run: cd apps/dashboard && npm install && npm run build") return not fatal - print("→ Building web UI...") + _say("→ Building web UI...") + + def _relay(result: "subprocess.CompletedProcess") -> None: + """Print captured npm output so users can see *why* a step failed. + + Windows users hitting `rm -rf` / `cp -r` errors (or any other + sync-assets / Vite failure) would otherwise see only ``Web UI + build failed`` with no hint of the underlying cause, because + the npm calls run with ``capture_output=True``. + """ + for blob in (result.stdout, result.stderr): + if not blob: + continue + text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() + if text: + _say(text) + r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",)) if r1.returncode != 0: - print( + _say( f" {'✗' if fatal else '⚠'} Web UI npm install failed" + ("" if fatal else " (hermes web will not be available)") ) + _relay(r1) if fatal: - print(" Run manually: cd apps/dashboard && npm install && npm run build") + _say(" Run manually: cd apps/dashboard && npm install && npm run build") return False # First attempt r2 = subprocess.run( @@ -5614,21 +5863,20 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: # A stale UI is far better than no UI for non-interactive callers # (Windows Scheduled Tasks, CI) — issue #23817. if dist_index.exists(): - print(" ⚠ Web UI build failed — serving stale dist as fallback") + _say(" ⚠ Web UI build failed — serving stale dist as fallback") if stderr_tail: - print(f" Build error:\n {stderr_tail}") + _say(f" Build error:\n {stderr_tail}") return True - print( + _say( f" {'✗' if fatal else '⚠'} Web UI build failed" + ("" if fatal else " (hermes web will not be available)") ) - if stderr_tail: - print(f" Build error:\n {stderr_tail}") + _relay(r2) if fatal: - print(" Run manually: cd apps/dashboard && npm install && npm run build") + _say(" Run manually: cd apps/dashboard && npm install && npm run build") return False - print(" ✓ Web UI built") + _say(" ✓ Web UI built") return True @@ -6722,6 +6970,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], *, @@ -7648,6 +7964,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 / "apps" / "dashboard") @@ -9196,10 +9514,10 @@ 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", "xiaomi", "arcee", + "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go", ] @@ -9219,10 +9537,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", "proxy", "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. @@ -9562,6 +9880,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) # ========================================================================= @@ -9682,7 +10045,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)", ) @@ -9728,7 +10091,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)", ) @@ -11450,16 +11813,57 @@ 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", + ) + 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.""" 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") + 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.") - 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/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/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 7b2c6067288..1ee5ed2ec8e 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: @@ -378,6 +379,12 @@ def _write_env_vars(env_path: Path, env_writes: dict) -> None: new_lines.append(f"{key}={val}") env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + # Restrict permissions — .env holds API keys and tokens. + try: + import stat + env_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600 + except OSError: + pass # Windows or read-only FS # --------------------------------------------------------------------------- diff --git a/hermes_cli/models.py b/hermes_cli/models.py index eb55b59ee5d..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", ], @@ -445,6 +456,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,10 +924,12 @@ class ProviderEntry(NamedTuple): CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"), ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"), + ProviderEntry("novita", "NovitaAI", "NovitaAI (AI-native cloud: Model API, Agent Sandbox, GPU Cloud)"), ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"), ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"), ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"), 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)"), @@ -1014,6 +1035,8 @@ _PROVIDER_ALIASES = { "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", + "novita-ai": "novita", + "novitaai": "novita", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "tencent": "tencent-tokenhub", @@ -1025,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", @@ -1494,7 +1521,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 +1531,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d ) if normalized == "ai-gateway": return fetch_ai_gateway_pricing(force_refresh=force_refresh) + if normalized == "novita": + return _fetch_novita_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1520,6 +1549,65 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d return {} +def _fetch_novita_pricing( + timeout: float = 8.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Fetch pricing from NovitaAI /v1/models. + + NovitaAI returns input/output prices per million tokens in units of + 0.0001 USD. Convert them to the per-token strings used by the shared + pricing formatter. + + Results are cached in ``_pricing_cache`` keyed on the resolved base URL, + matching the pattern used by ``fetch_ai_gateway_pricing`` — without this, + every menu render or pricing lookup re-hits the network. + """ + api_key = os.getenv("NOVITA_API_KEY", "").strip() + if not api_key: + return {} + + base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1" + cache_key = base_url.rstrip("/") + if not force_refresh and cache_key in _pricing_cache: + return _pricing_cache[cache_key] + + url = cache_key + "/models" + headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + "User-Agent": _HERMES_USER_AGENT, + } + + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _pricing_cache[cache_key] = {} + return {} + + result: dict[str, dict[str, str]] = {} + for item in payload.get("data", []): + if not isinstance(item, dict): + continue + mid = item.get("id") + if not mid: + continue + inp = item.get("input_token_price_per_m") + out = item.get("output_token_price_per_m") + if inp is None and out is None: + continue + result[str(mid)] = { + "prompt": str(float(inp or 0) / 10_000 / 1_000_000), + "completion": str(float(out or 0) / 10_000 / 1_000_000), + } + + _pricing_cache[cache_key] = result + return result + + # All provider IDs and aliases that are valid for the provider:model syntax. _KNOWN_PROVIDER_NAMES: set[str] = ( set(_PROVIDER_LABELS.keys()) @@ -2094,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()) @@ -3372,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, @@ -3387,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, @@ -3396,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}" ), } @@ -3630,13 +3721,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/hermes_cli/plugins.py b/hermes_cli/plugins.py index 70b0dc9cd7f..9e9af0e0644 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -542,6 +542,61 @@ 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, + ) + + # -- web search/extract provider registration ---------------------------- + + def register_web_search_provider(self, provider) -> None: + """Register a web search/extract backend. + + ``provider`` must be an instance of + :class:`agent.web_search_provider.WebSearchProvider`. The + ``provider.name`` attribute is what ``web.search_backend`` / + ``web.extract_backend`` / ``web.backend`` in ``config.yaml`` + matches against when routing ``web_search`` / ``web_extract`` + tool calls. + """ + from agent.web_search_provider import WebSearchProvider + from agent.web_search_registry import register_provider as _register_web_provider + + if not isinstance(provider, WebSearchProvider): + logger.warning( + "Plugin '%s' tried to register a web provider that does " + "not inherit from WebSearchProvider. Ignoring.", + self.manifest.name, + ) + return + _register_web_provider(provider) + logger.info( + "Plugin '%s' registered web provider: %s", + self.manifest.name, provider.name, + ) + # -- platform adapter registration --------------------------------------- def register_platform( @@ -1312,6 +1367,21 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: +_thread_tool_whitelist = threading.local() + + +def set_thread_tool_whitelist( + allowed: Optional[Set[str]], + deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist", +) -> None: + _thread_tool_whitelist.allowed = allowed + _thread_tool_whitelist.fmt = deny_msg_fmt + + +def clear_thread_tool_whitelist() -> None: + _thread_tool_whitelist.allowed = None + + def get_pre_tool_call_block_message( tool_name: str, args: Optional[Dict[str, Any]], @@ -1330,6 +1400,11 @@ def get_pre_tool_call_block_message( directive wins. Invalid or irrelevant hook return values are silently ignored so existing observer-only hooks are unaffected. """ + allowed = getattr(_thread_tool_whitelist, "allowed", None) + if allowed is not None and tool_name not in allowed: + fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied") + return fmt.format(tool_name=tool_name) + hook_results = invoke_hook( "pre_tool_call", tool_name=tool_name, diff --git a/hermes_cli/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/hermes_cli/providers.py b/hermes_cli/providers.py index f766a50ebf9..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", @@ -156,6 +162,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", @@ -239,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", @@ -309,6 +324,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/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..48de784afe4 --- /dev/null +++ b/hermes_cli/proxy/server.py @@ -0,0 +1,265 @@ +"""HTTP server that forwards OpenAI-compatible requests to a configured upstream. + +Listens on ``http://:/v1/`` and forwards each request to +``/`` with the client's ``Authorization`` header +replaced by a freshly-resolved bearer from the configured adapter. The +response is streamed back unmodified, preserving SSE. + +The server is intentionally minimal: it does NOT mediate, log, transform, +or rewrite request/response bodies. It's a credential-attaching forwarder. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import signal +from typing import Optional + +try: + import aiohttp + from aiohttp import web + AIOHTTP_AVAILABLE = True +except ImportError: + aiohttp = None # type: ignore[assignment] + web = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +from hermes_cli.proxy.adapters.base import UpstreamAdapter + +logger = logging.getLogger(__name__) + +# Headers we strip when forwarding to the upstream. ``host``/``content-length`` +# are recomputed by aiohttp; ``authorization`` is replaced with our bearer. +# Everything else (content-type, accept, user-agent, x-* headers) passes through. +_HOP_BY_HOP_HEADERS = frozenset( + { + "host", + "content-length", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "authorization", # we replace this one + } +) + +DEFAULT_PORT = 8645 +DEFAULT_HOST = "127.0.0.1" + + +def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response": + """Return an OpenAI-style error JSON response.""" + body = {"error": {"message": message, "type": code, "code": code}} + return web.json_response(body, status=status) + + +def _filter_request_headers(headers: "aiohttp.typedefs.LooseHeaders") -> dict: + """Strip hop-by-hop + auth headers from the inbound request.""" + out = {} + for key, value in headers.items(): + if key.lower() in _HOP_BY_HOP_HEADERS: + continue + out[key] = value + return out + + +def _filter_response_headers(headers) -> dict: + """Strip hop-by-hop headers from the upstream response.""" + out = {} + for key, value in headers.items(): + if key.lower() in _HOP_BY_HOP_HEADERS: + continue + # aiohttp recomputes Content-Encoding/Content-Length on stream — let it. + if key.lower() in ("content-encoding", "content-length"): + continue + out[key] = value + return out + + +def create_app(adapter: UpstreamAdapter) -> "web.Application": + """Build the aiohttp application bound to a specific upstream adapter.""" + if not AIOHTTP_AVAILABLE: + raise RuntimeError( + "aiohttp is required for `hermes proxy`. Install with: " + "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + ) + + app = web.Application() + # AppKey ensures forward-compat with future aiohttp versions that strip + # bare-string keys. + _adapter_key = web.AppKey("adapter", UpstreamAdapter) + app[_adapter_key] = adapter + + async def handle_health(request: "web.Request") -> "web.Response": + return web.json_response( + { + "status": "ok", + "upstream": adapter.display_name, + "authenticated": adapter.is_authenticated(), + } + ) + + async def handle_models_fallback(request: "web.Request") -> "web.Response": + # Most clients hit /v1/models on startup. If the upstream doesn't + # serve /models, synthesize a minimal response so clients don't + # crash. The actual forwarding path handles /models when allowed. + return web.json_response( + { + "object": "list", + "data": [], + } + ) + + async def handle_proxy(request: "web.Request") -> "web.StreamResponse": + # Extract the path *after* /v1 + rel_path = request.match_info.get("tail", "") + rel_path = "/" + rel_path.lstrip("/") + + if rel_path not in adapter.allowed_paths: + allowed = ", ".join(sorted(adapter.allowed_paths)) + return _json_error( + 404, + f"Path /v1{rel_path} is not forwarded by this proxy. " + f"Allowed: {allowed}", + code="path_not_allowed", + ) + + try: + cred = adapter.get_credential() + except Exception as exc: + logger.warning("proxy: credential resolution failed: %s", exc) + return _json_error(401, str(exc), code="upstream_auth_failed") + + upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}" + # Preserve query string verbatim. + if request.query_string: + upstream_url = f"{upstream_url}?{request.query_string}" + + # Forward body verbatim. Read into memory once — request bodies for + # chat/completions/embeddings are small (<1MB typically). If we ever + # need to forward large multipart uploads we'll switch to streaming + # the request body too. + body = await request.read() + + fwd_headers = _filter_request_headers(request.headers) + fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}" + + logger.debug( + "proxy: forwarding %s %s -> %s (body=%d bytes)", + request.method, rel_path, upstream_url, len(body), + ) + + # Use a per-request session so connection state doesn't leak between + # clients. Could be optimized to a shared session later. + timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300) + try: + session = aiohttp.ClientSession(timeout=timeout) + except Exception as exc: # pragma: no cover - aiohttp setup issue + return _json_error(500, f"proxy session init failed: {exc}") + + try: + upstream_resp = await session.request( + request.method, + upstream_url, + data=body if body else None, + headers=fwd_headers, + allow_redirects=False, + ) + except aiohttp.ClientError as exc: + await session.close() + logger.warning("proxy: upstream connection failed: %s", exc) + return _json_error(502, f"upstream connection failed: {exc}", + code="upstream_unreachable") + except asyncio.TimeoutError: + await session.close() + return _json_error(504, "upstream request timed out", + code="upstream_timeout") + + # Stream response back. Headers first, then chunked body. + resp = web.StreamResponse( + status=upstream_resp.status, + headers=_filter_response_headers(upstream_resp.headers), + ) + await resp.prepare(request) + + try: + async for chunk in upstream_resp.content.iter_any(): + if chunk: + await resp.write(chunk) + except (aiohttp.ClientError, asyncio.CancelledError) as exc: + logger.warning("proxy: streaming interrupted: %s", exc) + finally: + upstream_resp.release() + await session.close() + + await resp.write_eof() + return resp + + # /health doesn't go through the upstream + app.router.add_get("/health", handle_health) + # Catch-all under /v1 — forwards if the path is allowed. + app.router.add_route("*", "/v1/{tail:.*}", handle_proxy) + + return app + + +async def run_server( + adapter: UpstreamAdapter, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + shutdown_event: Optional[asyncio.Event] = None, +) -> None: + """Run the proxy in the current event loop until shutdown_event is set. + + If shutdown_event is None, runs until cancelled (Ctrl+C or SIGTERM). + """ + if not AIOHTTP_AVAILABLE: + raise RuntimeError( + "aiohttp is required for `hermes proxy`. Install with: " + "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + ) + + app = create_app(adapter) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, host=host, port=port) + await site.start() + + logger.info( + "proxy: listening on http://%s:%d/v1 -> %s", + host, port, adapter.display_name, + ) + + stop_event = shutdown_event or asyncio.Event() + + # Wire signal handlers when we own the loop's lifetime. + if shutdown_event is None: + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok + except NotImplementedError: + # Windows / restricted environments — Ctrl+C will still + # raise KeyboardInterrupt and unwind us. + pass + + try: + await stop_event.wait() + finally: + logger.info("proxy: shutting down") + await runner.cleanup() + + +__all__ = [ + "create_app", + "run_server", + "DEFAULT_HOST", + "DEFAULT_PORT", + "AIOHTTP_AVAILABLE", +] diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1652b72034c..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, @@ -102,8 +104,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 "" @@ -164,7 +168,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 +191,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, @@ -199,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 @@ -293,6 +337,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, @@ -1087,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 df4e88e0006..50e198b9dc7 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: @@ -502,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)) @@ -1079,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", {}) @@ -1113,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)", @@ -1187,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)") @@ -3246,18 +3348,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 +3391,6 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): _print_setup_summary(config, hermes_home) - _offer_launch_chat() - def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 0acb41d6878..0946eae9168 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", ""), ) @@ -828,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") @@ -851,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", 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 f5e464f163e..377194589ea 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"), @@ -70,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"), @@ -82,7 +82,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", "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 @@ -190,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", @@ -240,6 +243,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", @@ -251,42 +263,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", @@ -296,32 +272,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": { @@ -349,6 +299,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": "🌐", @@ -463,22 +422,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": "📊", @@ -951,24 +894,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: @@ -999,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 ─────────────────────────────────────────────── @@ -1513,15 +1505,112 @@ 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 + + +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 + 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 + + +# Mirror of _plugin_image_gen_providers for web search backends. Surfaces +# every plugin-registered web provider so it appears in the +# "Web Search & Extract" picker. All seven providers (brave-free, ddgs, +# searxng, exa, parallel, tavily, firecrawl) live as plugins after +# PR #25182 — this helper is the sole source of truth for the category's +# provider rows. The hardcoded entries that used to drive the category +# were deleted in the same PR; only the two non-provider UX rows +# ("Nous Subscription" managed-gateway entry, "Firecrawl Self-Hosted") +# remain in TOOL_CATEGORIES because they describe alternative *setup +# flows* for the firecrawl backend rather than distinct providers. +def _plugin_web_search_providers() -> list[dict]: + """Build picker-row dicts from plugin-registered web search providers. + + Each returned dict is a regular ``TOOL_CATEGORIES`` provider row. It + populates both ``web_backend`` (legacy field consumed by setup + + selection helpers) and ``web_search_plugin_name`` (informational + marker) so the picker behaves identically whether a provider is + hardcoded or plugin-registered. + + After PR #25182, all seven web providers (brave-free, ddgs, searxng, + exa, parallel, tavily, firecrawl) are plugins; this helper is the sole + source of provider rows for the Web Search & Extract category. + """ + try: + from agent.web_search_registry import list_providers as _list_web_providers + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + providers = _list_web_providers() + except Exception: + return [] + + rows: list[dict] = [] + for provider in providers: + name = getattr(provider, "name", None) + if not name: + continue + try: + schema = provider.get_setup_schema() + except Exception: + continue + if not isinstance(schema, dict): + continue + row = { + "name": schema.get("name", provider.display_name), + "badge": schema.get("badge", ""), + "tag": schema.get("tag", ""), + "env_vars": schema.get("env_vars", []), + "web_backend": name, + "web_search_plugin_name": name, + } + # Optional pass-through fields the schema can opt into. + if schema.get("post_setup"): + row["post_setup"] = schema["post_setup"] + rows.append(row) return rows @@ -1541,6 +1630,19 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: if cat.get("name") == "Image Generation": visible.extend(_plugin_image_gen_providers()) + # Inject plugin-registered video_gen backends. Unlike image_gen, + # video_gen has NO hardcoded providers — every backend is a plugin. + if cat.get("name") == "Video Generation": + visible.extend(_plugin_video_gen_providers()) + + # Inject plugin-registered web search backends. After PR #25182, this + # is the SOLE source of provider rows for the Web Search & Extract + # category — the per-provider hardcoded entries were deleted. The two + # remaining hardcoded rows ("Nous Subscription", "Firecrawl + # Self-Hosted") are non-provider UX setup-flow rows for firecrawl. + if cat.get("name") == "Web Search & Extract": + visible.extend(_plugin_web_search_providers()) + return visible @@ -1608,6 +1710,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: @@ -1697,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) @@ -1952,6 +2076,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 +2238,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 +2292,10 @@ def _configure_provider(provider: dict, config: dict): if plugin_name: _select_plugin_image_gen_provider(plugin_name, config) return + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return # Imagegen backends prompt for model selection after env vars are in. backend = provider.get("imagegen_backend") if backend: @@ -2286,6 +2520,11 @@ def _reconfigure_provider(provider: dict, config: dict): if plugin_name: _select_plugin_image_gen_provider(plugin_name, config) return + # Plugin-registered video_gen provider — same flow, different registry. + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return # Imagegen backends prompt for model selection on reconfig too. backend = provider.get("imagegen_backend") if backend: @@ -2318,6 +2557,12 @@ def _reconfigure_provider(provider: dict, config: dict): _select_plugin_image_gen_provider(plugin_name, config) return + # Plugin-registered video_gen provider — same flow, different registry. + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config) + return + backend = provider.get("imagegen_backend") if backend: _configure_imagegen_model(backend, config) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index aa720bd1c4e..ec86af6ed51 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1232,39 +1232,9 @@ def get_model_options(): can share the same types. """ try: - from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.inventory import build_models_payload, load_picker_context - cfg = load_config() - model_cfg = cfg.get("model", {}) - if isinstance(model_cfg, dict): - current_model = model_cfg.get("default", model_cfg.get("name", "")) or "" - current_provider = model_cfg.get("provider", "") or "" - current_base_url = model_cfg.get("base_url", "") or "" - else: - current_model = str(model_cfg) if model_cfg else "" - current_provider = "" - current_base_url = "" - - user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} - custom_providers = ( - cfg.get("custom_providers") - if isinstance(cfg.get("custom_providers"), list) - else [] - ) - - providers = list_authenticated_providers( - current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, - user_providers=user_providers, - custom_providers=custom_providers, - max_models=50, - ) - return { - "providers": providers, - "model": current_model, - "provider": current_provider, - } + return build_models_payload(load_picker_context(), max_models=50) except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") diff --git a/hermes_state.py b/hermes_state.py index f2973f1d76e..ba3b2d51522 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1601,10 +1601,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() @@ -1704,7 +1704,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() 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/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 new file mode 100644 index 00000000000..989d59509f3 --- /dev/null +++ b/optional-skills/blockchain/evm/SKILL.md @@ -0,0 +1,211 @@ +--- +name: evm +description: "Read-only EVM client: wallets, tokens, gas across 8 chains." +version: 1.0.0 +author: Mibayy (@Mibayy), youssefea (@youssefea), ethernet8023 (@ethernet8023), Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [EVM, Ethereum, BNB, BSC, Base, Arbitrum, Polygon, Optimism, Avalanche, zkSync, Blockchain, Crypto, Web3, DeFi, NFT, ENS, Whale, Security] + category: blockchain + related_skills: [solana] + requires_toolsets: [terminal] +--- + +# EVM Blockchain Skill + +Query EVM-compatible blockchain data across 8 chains with USD pricing. +14 commands: wallet portfolio, token info, transactions, activity, gas tracker, +network stats, price lookup, multi-chain scan, whale detection, ENS resolution, +allowance checker, contract inspector, and transaction decoder. + +Supports 8 chains: Ethereum, BNB Chain (BSC), Base, Arbitrum One, Polygon, +Optimism, Avalanche (C-Chain), zkSync Era. + +No API key needed. Zero external dependencies — Python standard library only +(urllib, json, argparse, threading). + +> **Supersedes the standalone `base` skill.** Base-specific tokens (AERO, DEGEN, +> TOSHI, BRETT, WELL, cbETH, cbBTC, wstETH, rETH) and all Base RPC functionality +> previously living under `optional-skills/blockchain/base/` have been folded +> into this skill. Pass `--chain base` to any command for Base coverage. + +--- + +## When to Use +- User asks for a wallet balance or portfolio on any EVM chain +- User wants to check the same wallet across ALL chains at once +- User wants to inspect a transaction by hash (or decode what it did) +- User wants ERC-20 token metadata, price, supply, or market cap +- User wants recent transaction history for an address +- User wants current gas prices or to compare fees across chains +- User wants to find large whale transfers in recent blocks +- User asks to resolve an ENS name (vitalik.eth) or reverse-lookup an address +- User wants to check if a contract has dangerous token approvals +- User wants to inspect a smart contract (proxy? ERC-20? ERC-721? bytecode size?) +- User wants to compare gas costs across chains before a transaction + +--- + +## Prerequisites +Python 3.8+ standard library only. No pip installs required. +Pricing: CoinGecko free API (rate-limited, ~10-30 req/min). +ENS: ensideas.com public API. +Tx decoding: 4byte.directory public API. + +Override RPC endpoint: `export EVM_RPC_URL=https://your-rpc.com` + +Helper script path: `~/.hermes/skills/blockchain/evm/scripts/evm_client.py` + +--- + +## Quick Reference + +``` +SCRIPT=~/.hermes/skills/blockchain/evm/scripts/evm_client.py + +# Network & prices +python3 $SCRIPT stats # Ethereum stats +python3 $SCRIPT stats --chain arbitrum # Arbitrum stats +python3 $SCRIPT compare # Gas + prices ALL 8 chains + +# Wallet +python3 $SCRIPT wallet 0xd8dA...96045 # Portfolio (ETH + ERC-20) +python3 $SCRIPT wallet 0xd8dA...96045 --chain bsc +python3 $SCRIPT multichain 0xd8dA...96045 # Same wallet on ALL chains + +# Tokens & prices +python3 $SCRIPT price ETH +python3 $SCRIPT price 0xdAC1...1ec7 # By contract address +python3 $SCRIPT token 0xdAC1...1ec7 # ERC-20 metadata + market cap + +# Transactions +python3 $SCRIPT tx 0x5c50...f060 # Transaction details +python3 $SCRIPT decode 0x5c50...f060 # Decode input data (4byte.directory) +python3 $SCRIPT activity 0xd8dA...96045 # Recent transactions + +# Gas +python3 $SCRIPT gas # Gas prices + cost estimates +python3 $SCRIPT gas --chain optimism + +# Security +python3 $SCRIPT allowance 0xd8dA...96045 # Dangerous ERC-20 approvals +python3 $SCRIPT contract 0xdAC1...1ec7 # Contract inspection (proxy? standards?) + +# ENS +python3 $SCRIPT ens vitalik.eth # Name -> address + profile +python3 $SCRIPT ens 0xd8dA...96045 # Address -> ENS name + +# Whale detection +python3 $SCRIPT whale # Large transfers (last 20 blocks, >$10k) +python3 $SCRIPT whale --blocks 50 --min-usd 100000 --chain arbitrum +``` + +--- + +## Procedure + +### 0. Setup Check +```bash +python3 --version # 3.8+ required +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats +``` + +### 1. Wallet Portfolio +Native balance + known ERC-20 tokens, sorted by USD value. +```bash +python3 $SCRIPT wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +python3 $SCRIPT wallet 0xd8dA... --chain bsc --no-prices # faster +``` + +### 2. Multi-Chain Scan +Scans all 8 chains simultaneously for the same address using threads. +```bash +python3 $SCRIPT multichain 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` +Output: per-chain native balance + token holdings + grand total USD. + +### 3. Compare (Gas + Prices) +All 8 chains queried in parallel. Shows cheapest/most expensive chain. +```bash +python3 $SCRIPT compare +``` + +### 4. Transaction Details & Decode +```bash +python3 $SCRIPT tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 +python3 $SCRIPT decode 0x5c504ed... # Shows human-readable function signature +``` +Decode uses 4byte.directory to translate 0xa9059cbb -> transfer(address,uint256). + +### 5. ENS Resolution +```bash +python3 $SCRIPT ens vitalik.eth # -> 0xd8dA... + avatar + social links +python3 $SCRIPT ens 0xd8dA...96045 # -> vitalik.eth +``` + +### 6. Allowance Checker (Security) +Checks ERC-20 approvals granted to known DEX/bridge contracts. +```bash +python3 $SCRIPT allowance 0xYourWallet +``` +Flags UNLIMITED approvals as HIGH risk. + +### 7. Contract Inspector +```bash +python3 $SCRIPT contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # USDC (proxy) +python3 $SCRIPT contract 0xdAC17F958D2ee523a2206206994597C13D831ec7 # USDT (ERC-20) +``` +Detects: proxy (EIP-1967/EIP-1167), ERC-20, ERC-721, ERC-165. Shows bytecode size and implementation address for proxies. + +### 8. Whale Detection +```bash +python3 $SCRIPT whale # ETH, last 20 blocks, >$10k +python3 $SCRIPT whale --blocks 50 --min-usd 50000 --chain bsc +``` + +### 9. Gas Tracker +```bash +python3 $SCRIPT gas +python3 $SCRIPT gas --chain polygon +``` +Shows gwei price + USD cost for: transfer, ERC-20 transfer, approve, swap, NFT mint, NFT transfer. + +--- + +## Supported Chains +| Key | Name | Native | Chain ID | +|-----------|----------------|--------|----------| +| ethereum | Ethereum | ETH | 1 | +| bsc | BNB Chain | BNB | 56 | +| base | Base | ETH | 8453 | +| arbitrum | Arbitrum One | ETH | 42161 | +| polygon | Polygon | POL | 137 | +| optimism | Optimism | ETH | 10 | +| avalanche | Avalanche C | AVAX | 43114 | +| zksync | zkSync Era | ETH | 324 | + +--- + +## Pitfalls +- CoinGecko free tier: ~10-30 req/min. Use `--no-prices` for faster wallet scans. +- Public RPCs may throttle. Set EVM_RPC_URL to a private endpoint for production. +- `wallet` and `allowance` only check known token list (~30 tokens per chain). Use a block explorer for complete token discovery. +- `activity` scans recent blocks only (max 200). For full history, use Etherscan API. +- `multichain` runs 8 parallel threads — can trigger rate limits on public RPCs. +- ENS resolution depends on a single public endpoint (ensideas.com / ens.vitalik.ca) with no fallback. If that endpoint is down, `ens` will fail — re-run later or use a block explorer. +- Tx decoding depends on a single public endpoint (4byte.directory) with no fallback. Selectors not in their database show up as `unknown`. +- **L2 gas estimates are L2-execution only.** On rollups like Base, Arbitrum, Optimism, and zkSync, the actual transaction cost also includes an L1 data-posting fee that depends on calldata size and current L1 gas prices. The `gas` command does not estimate that L1 component. For Base specifically, see the network's L1 fee oracle (contract `0x420000000000000000000000000000000000000F`). +- Address / tx-hash inputs are validated for 0x-prefix + correct length + hex, but EIP-55 checksum casing is **not** enforced (RPC endpoints accept any-case hex). + +--- + +## Verification +```bash +# Should print current block, gas price, ETH price +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats + +# Should resolve vitalik.eth to 0xd8dA... +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py ens vitalik.eth +``` diff --git a/optional-skills/blockchain/evm/scripts/evm_client.py b/optional-skills/blockchain/evm/scripts/evm_client.py new file mode 100644 index 00000000000..31da48fd192 --- /dev/null +++ b/optional-skills/blockchain/evm/scripts/evm_client.py @@ -0,0 +1,1508 @@ +#!/usr/bin/env python3 +""" +evm_client.py — EVM blockchain CLI tool for the Hermes Agent project. +Zero external dependencies. Uses stdlib only: urllib, json, argparse, time, os, sys, typing. +""" + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Chain registry +# --------------------------------------------------------------------------- + +CHAINS: Dict[str, Dict[str, Any]] = { + "ethereum": { + "chain_id": 1, + "rpc": "https://ethereum-rpc.publicnode.com", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://etherscan.io", + "decimals": 18, + }, + "bsc": { + "chain_id": 56, + "rpc": "https://bsc-dataseed1.binance.org", + "native": "BNB", + "coingecko": "binancecoin", + "explorer": "https://bscscan.com", + "decimals": 18, + }, + "base": { + "chain_id": 8453, + "rpc": "https://mainnet.base.org", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://basescan.org", + "decimals": 18, + }, + "arbitrum": { + "chain_id": 42161, + "rpc": "https://arb1.arbitrum.io/rpc", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://arbiscan.io", + "decimals": 18, + }, + "polygon": { + "chain_id": 137, + "rpc": "https://polygon-rpc.com", + "native": "MATIC", + "coingecko": "matic-network", + "explorer": "https://polygonscan.com", + "decimals": 18, + }, + "optimism": { + "chain_id": 10, + "rpc": "https://mainnet.optimism.io", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://optimistic.etherscan.io", + "decimals": 18, + }, + "avalanche": { + "chain_id": 43114, + "rpc": "https://api.avax.network/ext/bc/C/rpc", + "native": "AVAX", + "coingecko": "avalanche-2", + "explorer": "https://snowtrace.io", + "decimals": 18, + }, + "zksync": { + "chain_id": 324, + "rpc": "https://mainnet.era.zksync.io", + "native": "ETH", + "coingecko": "ethereum", + "explorer": "https://explorer.zksync.io", + "decimals": 18, + }, +} + +DEFAULT_CHAIN = "ethereum" + +# --------------------------------------------------------------------------- +# Known ERC-20 token registry {chain -> {symbol -> address}} +# --------------------------------------------------------------------------- + +KNOWN_TOKENS: Dict[str, Dict[str, str]] = { + "ethereum": { + "USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA", + "UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "AAVE": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "MKR": "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", + "COMP": "0xc00e94Cb662C3520282E6f5717214004A7f26888", + "SNX": "0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F", + "CRV": "0xD533a949740bb3306d119CC777fa900bA034cd52", + "LDO": "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32", + "RPL": "0xD33526068D116cE69F19A9ee46F0bd304F21A51f", + "MATIC": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", + "SHIB": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + "APE": "0x4d224452801ACEd8B2F0aebE155379bb5D594381", + "GRT": "0xc944E90C64B2c07662A292be6244BDf05Cda44a7", + "FXS": "0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0", + "FRAX": "0x853d955aCEf822Db058eb8505911ED77F175b99e", + "BAL": "0xba100000625a3754423978a60c9317c58a424e3D", + "SUSHI": "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2", + "YFI": "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e", + "1INCH": "0x111111111117dC0aa78b770fA6A738034120C302", + "ENS": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", + "IMX": "0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF", + "SAND": "0x3845badAde8e6dFF049820680d1F14bD3903a5d0", + "MANA": "0x0F5D2fB29fb7d3CFeE444a200298f468908cC942", + "AXS": "0xBB0E17EF65F82Ab018d8EDd776e8DD940327B28b", + "CHZ": "0x3506424F91fD33084466F402d5D97f05F8e3b4AF", + "PEPE": "0x6982508145454Ce325dDbE47a25d4ec3d2311933", + }, + "bsc": { + "USDT": "0x55d398326f99059fF775485246999027B3197955", + "USDC": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "BUSD": "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + "WBNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "CAKE": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", + "XVS": "0xcF6BB5389c92Bdda8a3747Ddb454cB7a64626C63", + "ALPACA":"0x8F0528cE5eF7B51152A59745bEfDD91D97091d2F", + "BAKE": "0xE02dF9e3e622DeBdD69fb838bB799E3F168902c5", + "BURGER":"0xAe9269f27437f0fcBC232d39Ec814844a51d6b8f", + "DOGE": "0xbA2aE424d960c26247Dd6c32edC70B295c744C43", + }, + "base": { + # Stables + wrapped + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + "WETH": "0x4200000000000000000000000000000000000006", + # Liquid-staked ETH variants + "cbETH": "0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cF0DEc22", + "wstETH": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", + "rETH": "0xB6fe221Fe9EeF5aBa221c348bA20A1Bf5e73624c", + "cbBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", + # Base-native DeFi + meme tokens (carried over from the standalone base/ skill) + "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", + "DEGEN": "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + "TOSHI": "0xAC1Bd2486aAf3B5C0fc3Fd868558b082a531B2B4", + "BRETT": "0x532f27101965dd16442E59d40670FaF5eBB142E4", + "WELL": "0xA88594D404727625A9437C3f886C7643872296AE", + }, + "arbitrum": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548", + }, + "optimism": { + "USDC": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "USDT": "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + "WETH": "0x4200000000000000000000000000000000000006", + "OP": "0x4200000000000000000000000000000000000042", + }, + "polygon": { + "USDC": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "USDT": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + "WMATIC":"0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "WETH": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "DAI": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", + }, + "avalanche": { + "USDC": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "USDT": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", + "WAVAX": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + }, +} + +# Gas estimates (units) for common operations +GAS_ESTIMATES = { + "transfer": 21_000, + "erc20": 65_000, + "approve": 46_000, + "swap": 180_000, + "nft_mint": 150_000, + "nft_transfer": 85_000, +} + +# CoinGecko symbol -> id map for common tokens +COINGECKO_IDS: Dict[str, str] = { + "ETH": "ethereum", + "BTC": "bitcoin", + "BNB": "binancecoin", + "MATIC": "matic-network", + "AVAX": "avalanche-2", + "USDT": "tether", + "USDC": "usd-coin", + "DAI": "dai", + "WBTC": "wrapped-bitcoin", + "WETH": "weth", + "LINK": "chainlink", + "UNI": "uniswap", + "AAVE": "aave", + "MKR": "maker", + "COMP": "compound-governance-token", + "SNX": "havven", + "CRV": "curve-dao-token", + "LDO": "lido-dao", + "RPL": "rocket-pool", + "SHIB": "shiba-inu", + "APE": "apecoin", + "GRT": "the-graph", + "BAL": "balancer", + "SUSHI": "sushi", + "YFI": "yearn-finance", + "1INCH": "1inch", + "ENS": "ethereum-name-service", + "IMX": "immutable-x", + "SAND": "the-sandbox", + "MANA": "decentraland", + "AXS": "axie-infinity", + "ARB": "arbitrum", + "OP": "optimism", + "CAKE": "pancakeswap-token", + "PEPE": "pepe", + "CHZ": "chiliz", +} + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + +def hex_to_int(h: str) -> int: + if not h or h == "0x": + return 0 + return int(h, 16) + + +# --------------------------------------------------------------------------- +# Input validation +# --------------------------------------------------------------------------- + +def is_valid_address(s: str) -> bool: + """Return True if `s` looks like a 20-byte hex Ethereum address. + + Does NOT validate EIP-55 checksum — RPC endpoints accept any-case hex. + Just guards against typos / wrong-length input before we burn an RPC call. + """ + if not isinstance(s, str): + return False + if not s.startswith("0x") and not s.startswith("0X"): + return False + if len(s) != 42: + return False + try: + int(s, 16) + except ValueError: + return False + return True + + +def is_valid_txhash(s: str) -> bool: + """Return True if `s` looks like a 32-byte hex transaction hash.""" + if not isinstance(s, str): + return False + if not s.startswith("0x") and not s.startswith("0X"): + return False + if len(s) != 66: + return False + try: + int(s, 16) + except ValueError: + return False + return True + + +def require_address(s: str, *, field: str = "address") -> str: + """Return `s` lowercased if valid, else exit with an error message. + + Centralizing validation here means every subcommand fails fast on bad input + instead of bubbling up an opaque RPC error 30 seconds later. + """ + if not is_valid_address(s): + sys.stderr.write( + f"error: invalid {field} {s!r}: expected 0x-prefixed 40-hex-char address\n" + ) + sys.exit(2) + return s.lower() + + +def require_txhash(s: str, *, field: str = "tx hash") -> str: + """Return `s` lowercased if valid, else exit with an error message.""" + if not is_valid_txhash(s): + sys.stderr.write( + f"error: invalid {field} {s!r}: expected 0x-prefixed 64-hex-char tx hash\n" + ) + sys.exit(2) + return s.lower() + + +def wei_to_native(wei: int, decimals: int = 18) -> float: + return wei / (10 ** decimals) + + +def gwei_from_wei(wei: int) -> float: + return wei / 1e9 + +def _short_addr(addr: str) -> str: + if addr and len(addr) >= 10: + return addr[:6] + "..." + addr[-4:] + return addr or "" + +def print_json(data: Any) -> None: + print(json.dumps(data, indent=2, default=str)) + +# --------------------------------------------------------------------------- +# HTTP / JSON-RPC layer +# --------------------------------------------------------------------------- + +def _http_post(url: str, payload: Any, retries: int = 5, timeout: int = 20) -> Any: + body = json.dumps(payload).encode() + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (compatible; evm_client/1.0)", + } + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + delay = 1.0 + last_err: Exception = RuntimeError("No attempts made") + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 429: + time.sleep(delay) + delay = min(delay * 2, 30) + last_err = e + continue + body_text = "" + try: + body_text = e.read().decode() + except Exception: + pass + raise RuntimeError(f"HTTP {e.code}: {body_text}") from e + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + delay = min(delay * 2, 30) + raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err + +def _http_get(url: str, retries: int = 5, timeout: int = 20) -> Any: + headers = {"Accept": "application/json", "User-Agent": "evm_client/1.0"} + req = urllib.request.Request(url, headers=headers, method="GET") + delay = 1.0 + last_err: Exception = RuntimeError("No attempts made") + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 429: + time.sleep(delay) + delay = min(delay * 2, 30) + last_err = e + continue + body_text = "" + try: + body_text = e.read().decode() + except Exception: + pass + raise RuntimeError(f"HTTP {e.code}: {body_text}") from e + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + delay = min(delay * 2, 30) + raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err + +# --------------------------------------------------------------------------- +# RPC helpers +# --------------------------------------------------------------------------- + +def get_rpc_url(chain: str) -> str: + env = os.environ.get("EVM_RPC_URL", "") + if env: + return env + cfg = CHAINS.get(chain) + if not cfg: + raise ValueError(f"Unknown chain '{chain}'. Available: {', '.join(CHAINS)}") + return cfg["rpc"] + +def rpc_call(chain: str, method: str, params: List[Any], req_id: int = 1) -> Any: + url = get_rpc_url(chain) + payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + resp = _http_post(url, payload) + if "error" in resp: + raise RuntimeError(f"RPC error: {resp['error']}") + return resp.get("result") + +def rpc_batch(chain: str, calls: List[Tuple[str, List[Any]]], batch_limit: int = 10) -> List[Any]: + """Send a batch of JSON-RPC calls; returns list of results in same order. + + Auto-chunks at `batch_limit` (default 10) so we stay under per-RPC limits. + Base's public RPC caps batches at 10 — exceeding that returns a single error + dict instead of a results list, which would mask all our calls. + """ + url = get_rpc_url(chain) + + # Build the full payload, preserving order via JSON-RPC `id` + items = [ + {"jsonrpc": "2.0", "id": i, "method": m, "params": p} + for i, (m, p) in enumerate(calls) + ] + + out: List[Any] = [None] * len(items) + for start in range(0, len(items), batch_limit): + chunk = items[start:start + batch_limit] + resp = _http_post(url, chunk) + if not isinstance(resp, list): + # Single error response (e.g. batch-too-large) — leave this chunk as None + continue + for r in resp: + rid = r.get("id") + if isinstance(rid, int) and 0 <= rid < len(out): + if "error" in r: + out[rid] = None + else: + out[rid] = r.get("result") + return out + +# --------------------------------------------------------------------------- +# ABI encoding helpers (minimal, for ERC-20 calls) +# --------------------------------------------------------------------------- + +def _encode_address(addr: str) -> str: + """Pad address to 32 bytes.""" + return addr.lower().replace("0x", "").zfill(64) + +def _keccak256(data: bytes) -> bytes: + """Pure Python Keccak-256 (Ethereum's hash, NOT SHA3-256).""" + # Keccak-256 round constants + RC = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, + ] + ROT = [ + [0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14], + ] + def rot64(x, n): return ((x << n) | (x >> (64 - n))) & 0xFFFFFFFFFFFFFFFF + rate = 136 # 1088 bits for keccak-256 + # Padding + msg = bytearray(data) + msg.append(0x01) + while len(msg) % rate != 0: + msg.append(0x00) + msg[-1] |= 0x80 + # Absorb + state = [0] * 25 + for block_start in range(0, len(msg), rate): + block = msg[block_start:block_start + rate] + for i in range(rate // 8): + state[i] ^= int.from_bytes(block[i*8:(i+1)*8], "little") + # Keccak-f[1600] + for rnd in range(24): + # Theta + C = [state[x] ^ state[x+5] ^ state[x+10] ^ state[x+15] ^ state[x+20] for x in range(5)] + D = [C[(x-1) % 5] ^ rot64(C[(x+1) % 5], 1) for x in range(5)] + state = [state[i] ^ D[i % 5] for i in range(25)] + # Rho + Pi + B = [0] * 25 + for x in range(5): + for y in range(5): + B[y*5 + ((2*x+3*y) % 5)] = rot64(state[x + 5*y], ROT[x][y]) + # Chi + state = [B[i] ^ ((~B[(i//5)*5 + (i%5+1)%5]) & B[(i//5)*5 + (i%5+2)%5]) for i in range(25)] + # Iota + state[0] ^= RC[rnd] + # Squeeze + out = b"".join(state[i].to_bytes(8, "little") for i in range(4)) + return out + + +def _selector(sig: str) -> str: + """Compute 4-byte function selector via keccak-256.""" + return "0x" + _keccak256(sig.encode()).hex()[:8] + +# Precomputed selectors for ERC-20 functions +ERC20_SELECTORS: Dict[str, str] = { + "name()": "0x06fdde03", + "symbol()": "0x95d89b41", + "decimals()": "0x313ce567", + "totalSupply()": "0x18160ddd", + "balanceOf(address)": "0x70a08231", +} + +def eth_call_erc20(chain: str, contract: str, fn: str, arg_addr: Optional[str] = None) -> str: + selector = ERC20_SELECTORS[fn] + data = selector + if arg_addr: + data += _encode_address(arg_addr) + params = [{"to": contract, "data": data}, "latest"] + return rpc_call(chain, "eth_call", params) or "0x" + +def decode_string(hex_data: str) -> str: + """Decode ABI-encoded string from eth_call result.""" + try: + raw = hex_data[2:] if hex_data.startswith("0x") else hex_data + if len(raw) < 128: + # Try decoding as raw bytes (some tokens return non-ABI strings) + b = bytes.fromhex(raw) + return b.rstrip(b"\x00").decode("utf-8", errors="replace").strip() + # offset (skip 32 bytes), length, data + length = int(raw[64:128], 16) + chars = raw[128:128 + length * 2] + return bytes.fromhex(chars).decode("utf-8", errors="replace").strip() + except Exception: + return "" + +def decode_uint256(hex_data: str) -> int: + try: + raw = hex_data[2:] if hex_data.startswith("0x") else hex_data + if not raw: + return 0 + return int(raw, 16) + except Exception: + return 0 + +def decode_uint8(hex_data: str) -> int: + return decode_uint256(hex_data) + +# --------------------------------------------------------------------------- +# CoinGecko price fetching +# --------------------------------------------------------------------------- + +COINGECKO_BASE = "https://api.coingecko.com/api/v3" + +def cg_price_by_id(cg_id: str) -> Optional[float]: + try: + url = f"{COINGECKO_BASE}/simple/price?ids={cg_id}&vs_currencies=usd" + data = _http_get(url) + return data.get(cg_id, {}).get("usd") + except Exception: + return None + +def cg_price_by_ids(cg_ids: List[str]) -> Dict[str, float]: + """Fetch multiple prices in one request.""" + if not cg_ids: + return {} + try: + joined = ",".join(cg_ids) + url = f"{COINGECKO_BASE}/simple/price?ids={joined}&vs_currencies=usd" + data = _http_get(url) + return {k: v.get("usd", 0.0) for k, v in data.items() if "usd" in v} + except Exception: + return {} + +def cg_price_by_contract(chain: str, contract: str) -> Optional[float]: + cg_platform_map = { + "ethereum": "ethereum", + "bsc": "binance-smart-chain", + "base": "base", + "arbitrum": "arbitrum-one", + "polygon": "polygon-pos", + "optimism": "optimistic-ethereum", + "avalanche":"avalanche", + "zksync": "zksync", + } + platform = cg_platform_map.get(chain) + if not platform: + return None + try: + url = ( + f"{COINGECKO_BASE}/simple/token_price/{platform}" + f"?contract_addresses={contract}&vs_currencies=usd" + ) + data = _http_get(url) + addr_lower = contract.lower() + for k, v in data.items(): + if k.lower() == addr_lower: + return v.get("usd") + return None + except Exception: + return None + +def get_native_price(chain: str) -> Optional[float]: + cg_id = CHAINS[chain]["coingecko"] + return cg_price_by_id(cg_id) + +# --------------------------------------------------------------------------- +# Command implementations +# --------------------------------------------------------------------------- + +def cmd_stats(args: argparse.Namespace) -> None: + chain = args.chain + cfg = CHAINS[chain] + + # Batch: blockNumber + gasPrice + results = rpc_batch(chain, [ + ("eth_blockNumber", []), + ("eth_gasPrice", []), + ]) + block_num = hex_to_int(results[0] or "0x0") + gas_price_wei = hex_to_int(results[1] or "0x0") + + # TPS estimate: compare latest block timestamp with parent + tps: Optional[float] = None + try: + latest_block = rpc_call(chain, "eth_getBlockByNumber", ["latest", False]) + if latest_block: + parent_hex = latest_block.get("parentHash") + parent_block = rpc_call(chain, "eth_getBlockByHash", [parent_hex, False]) + if parent_block: + t1 = hex_to_int(latest_block.get("timestamp", "0x0")) + t0 = hex_to_int(parent_block.get("timestamp", "0x0")) + tx_count = len(latest_block.get("transactions", [])) + if t1 > t0: + tps = round(tx_count / (t1 - t0), 2) + except Exception: + pass + + native_price = get_native_price(chain) + + print_json({ + "chain": chain, + "block_number": block_num, + "gas_price_gwei": round(gwei_from_wei(gas_price_wei), 4), + "gas_price_wei": gas_price_wei, + "native_token": cfg["native"], + "native_price_usd": native_price, + "tps_estimate": tps, + "explorer": cfg["explorer"], + }) + + +def cmd_wallet(args: argparse.Namespace) -> None: + address = require_address(args.address) + chain = args.chain + limit = args.limit + no_prices = args.no_prices + cfg = CHAINS[chain] + + # Native balance + balance_hex = rpc_call(chain, "eth_getBalance", [address, "latest"]) + native_wei = hex_to_int(balance_hex or "0x0") + native_val = wei_to_native(native_wei, cfg["decimals"]) + + native_usd_price: Optional[float] = None + native_usd: Optional[float] = None + if not no_prices: + native_usd_price = get_native_price(chain) + if native_usd_price is not None: + native_usd = round(native_val * native_usd_price, 4) + + # ERC-20 tokens + token_list = list((KNOWN_TOKENS.get(chain) or {}).items())[:limit] + tokens_out = [] + portfolio_usd = native_usd or 0.0 + + if token_list: + # Batch balanceOf calls + balance_calls = [ + ("eth_call", [{"to": addr, "data": ERC20_SELECTORS["balanceOf(address)"] + _encode_address(address)}, "latest"]) + for _, addr in token_list + ] + balances = rpc_batch(chain, balance_calls) + + for idx, (symbol, addr) in enumerate(token_list): + raw_bal = decode_uint256(balances[idx] or "0x0") + if raw_bal == 0: + continue + + # Fetch decimals + dec_hex = eth_call_erc20(chain, addr, "decimals()") + decimals = decode_uint8(dec_hex) if dec_hex and dec_hex != "0x" else 18 + bal_human = wei_to_native(raw_bal, decimals) + + token_price: Optional[float] = None + token_usd: Optional[float] = None + if not no_prices: + try: + cg_id = COINGECKO_IDS.get(symbol) + if cg_id: + token_price = cg_price_by_id(cg_id) + if token_price is None: + token_price = cg_price_by_contract(chain, addr) + if token_price is not None: + token_usd = round(bal_human * token_price, 4) + portfolio_usd += token_usd + except Exception: + pass + + tokens_out.append({ + "symbol": symbol, + "contract": addr, + "balance": round(bal_human, 8), + "price_usd": token_price, + "value_usd": token_usd, + }) + + print_json({ + "chain": chain, + "address": address, + "native_token": cfg["native"], + "native_balance": round(native_val, 8), + "native_price_usd": native_usd_price, + "native_value_usd": native_usd, + "erc20_tokens": tokens_out, + "portfolio_total_usd": round(portfolio_usd, 4) if not no_prices else None, + }) + + +def cmd_tx(args: argparse.Namespace) -> None: + tx_hash = require_txhash(args.hash) + chain = args.chain + cfg = CHAINS[chain] + + results = rpc_batch(chain, [ + ("eth_getTransactionByHash", [tx_hash]), + ("eth_getTransactionReceipt", [tx_hash]), + ]) + tx = results[0] + receipt = results[1] + + if not tx: + print_json({"error": f"Transaction {tx_hash} not found on {chain}"}) + return + + block_num = hex_to_int(tx.get("blockNumber") or "0x0") + timestamp: Optional[int] = None + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(block_num), False]) + if blk: + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + except Exception: + pass + + value_wei = hex_to_int(tx.get("value", "0x0")) + value_eth = wei_to_native(value_wei, cfg["decimals"]) + gas_price = hex_to_int(tx.get("gasPrice") or "0x0") + gas_limit = hex_to_int(tx.get("gas", "0x0")) + gas_used = hex_to_int((receipt or {}).get("gasUsed", "0x0")) if receipt else None + status = None + if receipt: + status = "success" if hex_to_int(receipt.get("status", "0x0")) == 1 else "failed" + + input_data = tx.get("input", "0x") + input_preview = input_data[:66] + ("..." if len(input_data) > 66 else "") + + native_price = get_native_price(chain) + value_usd = round(value_eth * native_price, 4) if native_price else None + + fee_eth: Optional[float] = None + fee_usd: Optional[float] = None + if gas_used is not None: + fee_eth = wei_to_native(gas_used * gas_price, cfg["decimals"]) + if native_price: + fee_usd = round(fee_eth * native_price, 6) + + print_json({ + "chain": chain, + "hash": tx_hash, + "block": block_num, + "timestamp": timestamp, + "from": tx.get("from"), + "to": tx.get("to"), + "value": round(value_eth, 8), + "value_usd": value_usd, + "native_token": cfg["native"], + "gas_limit": gas_limit, + "gas_used": gas_used, + "gas_price_gwei": round(gwei_from_wei(gas_price), 4), + "fee_native": round(fee_eth, 8) if fee_eth is not None else None, + "fee_usd": fee_usd, + "status": status, + "input_preview": input_preview, + "nonce": hex_to_int(tx.get("nonce", "0x0")), + "explorer_url": f"{cfg['explorer']}/tx/{tx_hash}", + }) + + +def cmd_token(args: argparse.Namespace) -> None: + contract = require_address(args.contract, field="contract address") + chain = args.chain + + # Batch all ERC-20 metadata calls + calls = [ + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["name()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["symbol()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["decimals()"]}, "latest"]), + ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["totalSupply()"]}, "latest"]), + ] + results = rpc_batch(chain, calls) + name = decode_string(results[0] or "0x") + symbol = decode_string(results[1] or "0x") + decimals = decode_uint8(results[2] or "0x0") + supply_raw = decode_uint256(results[3] or "0x0") + supply = wei_to_native(supply_raw, decimals) + + price: Optional[float] = None + market_cap: Optional[float] = None + cg_id = COINGECKO_IDS.get(symbol.upper()) + if cg_id: + price = cg_price_by_id(cg_id) + if price is None: + price = cg_price_by_contract(chain, contract) + if price is not None and supply > 0: + market_cap = round(price * supply, 2) + + cfg = CHAINS[chain] + print_json({ + "chain": chain, + "contract": contract, + "name": name, + "symbol": symbol, + "decimals": decimals, + "total_supply": round(supply, 4), + "price_usd": price, + "market_cap_usd": market_cap, + "explorer_url": f"{cfg['explorer']}/token/{contract}", + }) + + +def cmd_activity(args: argparse.Namespace) -> None: + address = require_address(args.address) + chain = args.chain + limit = args.limit + cfg = CHAINS[chain] + + # Get current block + block_hex = rpc_call(chain, "eth_blockNumber", []) + latest = hex_to_int(block_hex or "0x0") + + txs_out: List[Dict[str, Any]] = [] + scan_range = min(200, latest) + blocks_checked = 0 + + for bn in range(latest, max(0, latest - scan_range), -1): + if len(txs_out) >= limit: + break + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True]) + except Exception: + continue + if not blk: + continue + blocks_checked += 1 + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + for tx in blk.get("transactions", []): + if len(txs_out) >= limit: + break + frm = (tx.get("from") or "").lower() + to = (tx.get("to") or "").lower() + addr_lower = address.lower() + if frm == addr_lower or to == addr_lower: + value_wei = hex_to_int(tx.get("value", "0x0")) + value_eth = wei_to_native(value_wei, cfg["decimals"]) + gas_price = hex_to_int(tx.get("gasPrice") or "0x0") + txs_out.append({ + "hash": tx.get("hash"), + "block": bn, + "timestamp": timestamp, + "from": tx.get("from"), + "to": tx.get("to"), + "value": round(value_eth, 8), + "native_token": cfg["native"], + "gas_price_gwei": round(gwei_from_wei(gas_price), 4), + "direction": "out" if frm == addr_lower else "in", + }) + + print_json({ + "chain": chain, + "address": address, + "blocks_scanned": blocks_checked, + "tx_count": len(txs_out), + "transactions": txs_out, + }) + + +def cmd_gas(args: argparse.Namespace) -> None: + chain = args.chain + cfg = CHAINS[chain] + + gas_price_hex = rpc_call(chain, "eth_gasPrice", []) + gas_wei = hex_to_int(gas_price_hex or "0x0") + gas_gwei = gwei_from_wei(gas_wei) + + native_price = get_native_price(chain) + + estimates: Dict[str, Any] = {} + for op, gas_units in GAS_ESTIMATES.items(): + cost_wei = gas_wei * gas_units + cost_native = wei_to_native(cost_wei, cfg["decimals"]) + cost_usd = round(cost_native * native_price, 6) if native_price else None + estimates[op] = { + "gas_units": gas_units, + "cost_native": round(cost_native, 8), + "cost_usd": cost_usd, + } + + print_json({ + "chain": chain, + "native_token": cfg["native"], + "gas_price_gwei": round(gas_gwei, 4), + "gas_price_wei": gas_wei, + "native_price_usd": native_price, + "estimates": estimates, + }) + + +def cmd_price(args: argparse.Namespace) -> None: + token = args.token + chain = args.chain + + price: Optional[float] = None + source = "unknown" + + # Check if it's a contract address + if token.startswith("0x") and len(token) >= 10: + price = cg_price_by_contract(chain, token) + source = "coingecko_contract" + if price is None: + print_json({"error": f"Could not find price for contract {token} on {chain}"}) + return + else: + symbol = token.upper() + cg_id = COINGECKO_IDS.get(symbol) + if cg_id: + price = cg_price_by_id(cg_id) + source = f"coingecko:{cg_id}" + if price is None: + # Try known tokens on given chain + contract = (KNOWN_TOKENS.get(chain) or {}).get(symbol) + if contract: + price = cg_price_by_contract(chain, contract) + source = f"coingecko_contract:{contract}" + if price is None: + print_json({"error": f"Could not find price for '{token}'. Try a contract address."}) + return + + print_json({ + "token": token, + "chain": chain, + "price_usd": price, + "source": source, + }) + + +def _fetch_chain_stats(chain: str) -> Dict[str, Any]: + """Fetch gas price + native price for a single chain (used in compare).""" + try: + gas_hex = rpc_call(chain, "eth_gasPrice", []) + gas_wei = hex_to_int(gas_hex or "0x0") + gas_gwei = round(gwei_from_wei(gas_wei), 4) + except Exception: + gas_gwei = None + + cg_id = CHAINS[chain]["coingecko"] + native_price = cg_price_by_id(cg_id) + + transfer_usd: Optional[float] = None + if gas_gwei is not None and native_price is not None: + gas_wei_val = int(gas_gwei * 1e9) + cost_wei = gas_wei_val * GAS_ESTIMATES["transfer"] + cost_native = wei_to_native(cost_wei, CHAINS[chain]["decimals"]) + transfer_usd = round(cost_native * native_price, 6) + + return { + "chain": chain, + "native_token": CHAINS[chain]["native"], + "gas_price_gwei": gas_gwei, + "native_price_usd": native_price, + "transfer_cost_usd": transfer_usd, + } + + +def cmd_compare(_args: argparse.Namespace) -> None: + """Compare gas prices and native token prices across all chains simultaneously.""" + import threading + + results: Dict[str, Any] = {} + errors: Dict[str, str] = {} + lock = threading.Lock() + + def fetch(chain: str) -> None: + try: + data = _fetch_chain_stats(chain) + with lock: + results[chain] = data + except Exception as e: + with lock: + errors[chain] = str(e) + + threads = [threading.Thread(target=fetch, args=(c,), daemon=True) for c in CHAINS] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + sorted_by_gas = sorted( + results.values(), + key=lambda x: x.get("gas_price_gwei") or float("inf"), + ) + + print_json({ + "comparison": sorted_by_gas, + "errors": errors, + "cheapest_gas": sorted_by_gas[0]["chain"] if sorted_by_gas else None, + "most_expensive_gas": sorted_by_gas[-1]["chain"] if sorted_by_gas else None, + }) + + +def cmd_whale(args: argparse.Namespace) -> None: + chain = args.chain + blocks = args.blocks + min_usd = args.min_usd + cfg = CHAINS[chain] + + native_price = get_native_price(chain) + if native_price is None: + print_json({"error": "Could not fetch native token price for USD conversion."}) + return + + block_hex = rpc_call(chain, "eth_blockNumber", []) + latest = hex_to_int(block_hex or "0x0") + + whales: List[Dict[str, Any]] = [] + blocks_scanned = 0 + + for bn in range(latest, max(0, latest - blocks), -1): + try: + blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True]) + except Exception: + continue + if not blk: + continue + blocks_scanned += 1 + timestamp = hex_to_int(blk.get("timestamp", "0x0")) + + for tx in blk.get("transactions", []): + value_wei = hex_to_int(tx.get("value", "0x0")) + if value_wei == 0: + continue + value_native = wei_to_native(value_wei, cfg["decimals"]) + value_usd = value_native * native_price + if value_usd >= min_usd: + whales.append({ + "hash": tx.get("hash"), + "block": bn, + "timestamp": timestamp, + "from": tx.get("from"), + "from_short": _short_addr(tx.get("from") or ""), + "to": tx.get("to"), + "to_short": _short_addr(tx.get("to") or ""), + "value_native": round(value_native, 6), + "native_token": cfg["native"], + "value_usd": round(value_usd, 2), + }) + + whales.sort(key=lambda x: x["value_usd"], reverse=True) + + print_json({ + "chain": chain, + "blocks_scanned": blocks_scanned, + "latest_block": latest, + "min_usd": min_usd, + "native_price_usd": native_price, + "whale_count": len(whales), + "transfers": whales, + }) + + +# --------------------------------------------------------------------------- +# New commands: multichain, allowance, decode, ens, contract +# --------------------------------------------------------------------------- + +def cmd_multichain(args: argparse.Namespace) -> None: + """Scan same wallet across all 8 chains simultaneously.""" + import threading + + address = require_address(args.address) + results: Dict[str, Any] = {} + lock = threading.Lock() + + def scan_chain(chain: str) -> None: + cfg = CHAINS[chain] + try: + bal_hex = rpc_call(chain, "eth_getBalance", [address, "latest"]) + native_bal = int(bal_hex, 16) / 1e18 if bal_hex else 0.0 + native_price = get_native_price(chain) + native_usd = round(native_bal * native_price, 2) if native_price else None + entry: Dict[str, Any] = { + "native_symbol": cfg["native"], + "native_balance": round(native_bal, 8), + "native_price_usd": native_price, + "native_value_usd": native_usd, + "tokens": [], + "total_usd": native_usd or 0.0, + } + # Check known tokens for this chain. + # KNOWN_TOKENS[chain] maps {symbol: contract_address}, not {addr: (sym, name)}. + known = KNOWN_TOKENS.get(chain, {}) + for symbol, contract in known.items(): + raw = eth_call_erc20(chain, contract, "balanceOf(address)", address) + if not raw or raw == "0x": + continue + try: + bal_int = int(raw, 16) + except Exception: + continue + if bal_int == 0: + continue + dec_raw = eth_call_erc20(chain, contract, "decimals()") + decimals = decode_uint8(dec_raw) if dec_raw else 18 + human = bal_int / (10 ** decimals) + tok_price = cg_price_by_contract(chain, contract) + tok_usd = round(human * tok_price, 2) if tok_price else None + entry["tokens"].append({ + "symbol": symbol, + "balance": round(human, 6), + "value_usd": tok_usd, + }) + if tok_usd: + entry["total_usd"] = round(entry["total_usd"] + tok_usd, 2) + with lock: + results[chain] = entry + except Exception as exc: + with lock: + results[chain] = {"error": str(exc)} + + threads = [threading.Thread(target=scan_chain, args=(c,)) for c in CHAINS] + for t in threads: + t.start() + for t in threads: + t.join() + + grand_total = sum( + v.get("total_usd", 0) for v in results.values() if isinstance(v, dict) + ) + print_json({ + "address": address, + "chains": results, + "grand_total_usd": round(grand_total, 2), + }) + + +def cmd_allowance(args: argparse.Namespace) -> None: + """Check dangerous ERC-20 approvals for a wallet (known spenders).""" + address = require_address(args.address) + chain = args.chain + + # Well-known spender contracts (DEXes, bridges, etc.) + KNOWN_SPENDERS = { + "0x000000000022D473030F116dDEE9F6B43aC78BA3": "Permit2 (Uniswap)", + "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap V2 Router", + "0xE592427A0AEce92De3Edee1F18E0157C05861564": "Uniswap V3 Router", + "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45": "Uniswap Universal Router", + "0x1111111254EEB25477B68fb85Ed929f73A960582": "1inch Router V5", + "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5": "KyberSwap Router", + "0xDef1C0ded9bec7F1a1670819833240f027b25EfF": "0x Exchange Proxy", + "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": "Uniswap Universal Router 2", + } + + known = KNOWN_TOKENS.get(chain, {}) + approvals = [] + + # KNOWN_TOKENS[chain] is {symbol: contract_address}, not {addr: (sym, name)}. + for symbol, contract in known.items(): + for spender_addr, spender_name in KNOWN_SPENDERS.items(): + # allowance(owner, spender) = 0xdd62ed3e + owner_pad = address.lower().replace("0x", "").zfill(64) + spender_pad = spender_addr.lower().replace("0x", "").zfill(64) + data = "0xdd62ed3e" + owner_pad + spender_pad + raw = rpc_call(chain, "eth_call", [{"to": contract, "data": data}, "latest"]) + if not raw or raw == "0x": + continue + try: + allowance_int = int(raw, 16) + except Exception: + continue + if allowance_int == 0: + continue + + dec_raw = eth_call_erc20(chain, contract, "decimals()") + decimals = decode_uint8(dec_raw) if dec_raw else 18 + max_uint = 2**256 - 1 + is_unlimited = allowance_int >= max_uint // 2 + + approvals.append({ + "token": symbol, + "contract": contract, + "spender": spender_name, + "spender_address": spender_addr, + "allowance": "UNLIMITED" if is_unlimited else str(round(allowance_int / 10**decimals, 4)), + "risk": "HIGH" if is_unlimited else "LOW", + }) + + print_json({ + "chain": chain, + "address": address, + "approvals_found": len(approvals), + "approvals": approvals, + "note": "Only checks known DEX/bridge spenders. Use a full allowance checker for complete coverage.", + }) + + +def cmd_decode(args: argparse.Namespace) -> None: + """Decode transaction input data using 4byte.directory.""" + chain = args.chain + tx_hash = require_txhash(args.hash) + + tx = rpc_call(chain, "eth_getTransactionByHash", [tx_hash]) + if not tx: + print_json({"error": "Transaction not found"}) + return + + input_data: str = tx.get("input", "0x") + if not input_data or input_data == "0x": + print_json({ + "chain": chain, + "hash": tx_hash, + "decoded": None, + "note": "No input data (plain ETH transfer)", + }) + return + + selector = input_data[:10] # 0x + 4 bytes = 10 chars + + # Query 4byte.directory + url = f"https://www.4byte.directory/api/v1/signatures/?hex_signature={selector}" + data = _http_get(url) + + signatures = [] + if data and data.get("results"): + signatures = [r["text_signature"] for r in data["results"]] + + # Decode known transfer(address,uint256) manually as fallback + decoded_args: Optional[Dict] = None + if signatures and len(input_data) >= 74: + sig = signatures[0] + if sig == "transfer(address,uint256)" and len(input_data) == 138: + to_addr = "0x" + input_data[34:74] + amount_hex = input_data[74:] + try: + amount = int(amount_hex, 16) + decoded_args = {"to": to_addr, "amount_raw": amount} + except Exception: + pass + + print_json({ + "chain": chain, + "hash": tx_hash, + "selector": selector, + "input_length_bytes": (len(input_data) - 2) // 2, + "from": tx.get("from"), + "to": tx.get("to"), + "signatures": signatures, + "primary_signature": signatures[0] if signatures else None, + "decoded_args": decoded_args, + "raw_input_preview": input_data[:74] + ("..." if len(input_data) > 74 else ""), + "source": "4byte.directory", + }) + + +def cmd_ens(args: argparse.Namespace) -> None: + """Resolve ENS name <-> address via ensideas.com public API (no key needed).""" + query = args.name_or_address + + # ensideas.com handles both forward (name->address) and reverse (address->name) + try: + data = _http_get(f"https://api.ensideas.com/ens/resolve/{query}") + except Exception as exc: + print_json({"error": str(exc), "note": "ENS API unavailable"}) + return + + if not data: + print_json({"query": query, "address": None, "ens_name": None, "note": "Not found"}) + return + + print_json({ + "query": query, + "address": data.get("address"), + "ens_name": data.get("name"), + "avatar": data.get("avatar"), + "display": data.get("displayName"), + "twitter": data.get("twitter"), + "github": data.get("github"), + "source": "ensideas.com", + }) + + +def cmd_contract(args: argparse.Namespace) -> None: + """Inspect a smart contract: bytecode size, proxy detection, creation info.""" + chain = args.chain + address = require_address(args.address) + + # Get bytecode + code_hex = rpc_call(chain, "eth_getCode", [address, "latest"]) + if not code_hex or code_hex == "0x": + print_json({"chain": chain, "address": address, "is_contract": False, "note": "EOA (externally owned account)"}) + return + + bytecode_bytes = (len(code_hex) - 2) // 2 + + # Proxy detection patterns + # EIP-1967: implementation slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc + impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + impl_raw = rpc_call(chain, "eth_getStorageAt", [address, impl_slot, "latest"]) + implementation = None + is_proxy = False + if impl_raw and impl_raw != "0x" and int(impl_raw, 16) != 0: + is_proxy = True + implementation = "0x" + impl_raw[-40:] + + # EIP-1167 minimal proxy detection (starts with 0x363d3d37) + if code_hex[2:10] == "363d3d37" or code_hex[2:18] == "3d602d80600a3d39": + is_proxy = True + + # supportsInterface check: ERC-165 + supports_erc165 = False + try: + erc165_data = "0x01ffc9a701ffc9a700000000000000000000000000000000000000000000000000000000" + erc165_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc165_data}, "latest"]) + supports_erc165 = bool(erc165_raw and erc165_raw != "0x" and int(erc165_raw, 16) == 1) + except Exception: + pass + + # Try to detect ERC-20 (has totalSupply) + is_erc20 = False + try: + ts_raw = eth_call_erc20(chain, address, "totalSupply()") + is_erc20 = ts_raw is not None and ts_raw != "0x" and int(ts_raw, 16) > 0 + except Exception: + pass + + # Try to detect ERC-721 (supportsInterface 0x80ac58cd) + is_erc721 = False + try: + erc721_data = "0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000" + erc721_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc721_data}, "latest"]) + is_erc721 = bool(erc721_raw and erc721_raw != "0x" and int(erc721_raw, 16) == 1) + except Exception: + pass + + detected_standards = [] + if is_erc20: + detected_standards.append("ERC-20") + if is_erc721: + detected_standards.append("ERC-721") + if supports_erc165: + detected_standards.append("ERC-165") + + print_json({ + "chain": chain, + "address": address, + "is_contract": True, + "bytecode_size_bytes": bytecode_bytes, + "is_proxy": is_proxy, + "implementation": implementation, + "detected_standards": detected_standards, + "explorer_url": f"{CHAINS[chain]['explorer']}/address/{address}", + "note": "Proxy detected via EIP-1967 storage slot. Standards via EIP-165 + heuristics." if is_proxy else None, + }) + + +# --------------------------------------------------------------------------- +# Argument parsing & dispatch +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + chain_choices = list(CHAINS.keys()) + + parser = argparse.ArgumentParser( + prog="evm_client", + description="EVM blockchain CLI — stdlib only, zero dependencies.", + ) + sub = parser.add_subparsers(dest="command", metavar="COMMAND") + sub.required = True + + # -- stats -- + p_stats = sub.add_parser("stats", help="Chain stats: block, gas price, native price, TPS") + p_stats.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- wallet -- + p_wallet = sub.add_parser("wallet", help="Wallet balance + ERC-20 portfolio") + p_wallet.add_argument("address", help="Wallet address (0x...)") + p_wallet.add_argument("--limit", type=int, default=20, metavar="N", + help="Max number of known tokens to check (default: 20)") + p_wallet.add_argument("--no-prices", action="store_true", + help="Skip USD price lookups (faster)") + p_wallet.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- tx -- + p_tx = sub.add_parser("tx", help="Transaction details") + p_tx.add_argument("hash", help="Transaction hash (0x...)") + p_tx.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- token -- + p_token = sub.add_parser("token", help="ERC-20 token metadata + price") + p_token.add_argument("contract", help="Token contract address (0x...)") + p_token.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- activity -- + p_act = sub.add_parser("activity", help="Recent transactions for an address") + p_act.add_argument("address", help="Wallet address (0x...)") + p_act.add_argument("--limit", type=int, default=10, metavar="N", + help="Max transactions to return (default: 10)") + p_act.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- gas -- + p_gas = sub.add_parser("gas", help="Gas prices and cost estimates") + p_gas.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- price -- + p_price = sub.add_parser("price", help="Token price by symbol or contract address") + p_price.add_argument("token", help="Symbol (e.g. ETH, USDC) or contract address") + p_price.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- compare -- + sub.add_parser("compare", help="Gas + native prices across ALL chains simultaneously") + + # -- whale -- + p_whale = sub.add_parser("whale", help="Scan for large value transfers in recent blocks") + p_whale.add_argument("--blocks", type=int, default=20, metavar="N", + help="Number of recent blocks to scan (default: 20)") + p_whale.add_argument("--min-usd", type=float, default=10_000.0, metavar="N", + help="Minimum USD value to report (default: 10000)") + p_whale.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- multichain -- + p_multi = sub.add_parser("multichain", help="Scan same wallet across ALL chains simultaneously") + p_multi.add_argument("address", help="Wallet address (0x...)") + + # -- allowance -- + p_allow = sub.add_parser("allowance", help="Check dangerous ERC-20 approvals (known DEX/bridge spenders)") + p_allow.add_argument("address", help="Wallet address (0x...)") + p_allow.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- decode -- + p_decode = sub.add_parser("decode", help="Decode transaction input data via 4byte.directory") + p_decode.add_argument("hash", help="Transaction hash (0x...)") + p_decode.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + # -- ens -- + p_ens = sub.add_parser("ens", help="Resolve ENS name <-> address (Ethereum only)") + p_ens.add_argument("name_or_address", help="ENS name (vitalik.eth) or address (0x...)") + + # -- contract -- + p_contract = sub.add_parser("contract", help="Inspect a smart contract: proxy, standards, bytecode size") + p_contract.add_argument("address", help="Contract address (0x...)") + p_contract.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices) + + return parser + + +DISPATCH = { + "stats": cmd_stats, + "wallet": cmd_wallet, + "tx": cmd_tx, + "token": cmd_token, + "activity": cmd_activity, + "gas": cmd_gas, + "price": cmd_price, + "compare": cmd_compare, + "whale": cmd_whale, + "multichain": cmd_multichain, + "allowance": cmd_allowance, + "decode": cmd_decode, + "ens": cmd_ens, + "contract": cmd_contract, +} + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + # Validate chain exists (argparse choices already handles this, but for ENV override) + if hasattr(args, "chain") and args.chain not in CHAINS: + print_json({"error": f"Unknown chain '{args.chain}'. Available: {list(CHAINS.keys())}"}) + sys.exit(1) + + cmd_fn = DISPATCH.get(args.command) + if cmd_fn is None: + print_json({"error": f"Unknown command '{args.command}'"}) + sys.exit(1) + + try: + cmd_fn(args) + except KeyboardInterrupt: + print_json({"error": "Interrupted by user"}) + sys.exit(130) + except Exception as e: + print_json({"error": str(e)}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/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/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/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/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/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py new file mode 100644 index 00000000000..e49e289a0de --- /dev/null +++ b/plugins/model-providers/novita/__init__.py @@ -0,0 +1,27 @@ +"""NovitaAI provider profile.""" + +from providers import register_provider +from providers.base import ProviderProfile + + +novita = ProviderProfile( + name="novita", + aliases=("novita-ai", "novitaai"), + display_name="NovitaAI", + description="NovitaAI — AI-native cloud for builders and agents", + signup_url="https://novita.ai/settings/key-management", + env_vars=("NOVITA_API_KEY", "NOVITA_BASE_URL"), + base_url="https://api.novita.ai/openai/v1", + auth_type="api_key", + default_aux_model="deepseek/deepseek-v3-0324", + fallback_models=( + "moonshotai/kimi-k2.5", + "minimax/minimax-m2.7", + "zai-org/glm-5", + "deepseek/deepseek-v3-0324", + "deepseek/deepseek-r1-0528", + "qwen/qwen3-235b-a22b-fp8", + ), +) + +register_provider(novita) diff --git a/plugins/model-providers/novita/plugin.yaml b/plugins/model-providers/novita/plugin.yaml new file mode 100644 index 00000000000..d572ca616bd --- /dev/null +++ b/plugins/model-providers/novita/plugin.yaml @@ -0,0 +1,5 @@ +name: novita-provider +kind: model-provider +version: 1.0.0 +description: NovitaAI AI-native cloud for builders and agents +author: Nous Research diff --git a/plugins/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/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/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..d6fe9d04a7b --- /dev/null +++ b/plugins/video_gen/xai/__init__.py @@ -0,0 +1,441 @@ +"""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: 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 + +import asyncio +import logging +import os +import uuid +from typing import Any, Dict, List, Optional, Tuple + +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 _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_user_agent() -> str: + try: + from tools.xai_http import hermes_xai_user_agent + + return hermes_xai_user_agent() + except Exception: + 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": _xai_user_agent(), + } + + +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], + *, + 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"{base_url}/videos/generations", + headers={**_xai_headers(api_key), "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, + *, + api_key: str, + base_url: 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"{base_url}/videos/{request_id}", + headers=_xai_headers(api_key), + 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: + 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()] + + def default_model(self) -> Optional[str]: + 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 Grok Imagine", + "badge": "paid", + "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]: + 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]: + api_key, base_url = _resolve_xai_credentials() + if not api_key: + return error_response( + 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, + ) + + 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, api_key=api_key, base_url=base_url + ) + 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, + api_key=api_key, base_url=base_url, + 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/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/tools/web_providers/brave_free.py b/plugins/web/brave_free/provider.py similarity index 56% rename from tools/web_providers/brave_free.py rename to plugins/web/brave_free/provider.py index 52d02dec2a1..df4584f7732 100644 --- a/tools/web_providers/brave_free.py +++ b/plugins/web/brave_free/provider.py @@ -1,23 +1,20 @@ -"""Brave Search web search provider (free tier). +"""Brave Search (free tier) — plugin form. -Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the -time of writing) after signing up at https://brave.com/search/api/. This -provider implements ``WebSearchProvider`` only — the Data-for-Search endpoint -returns search results, it does not extract/crawl arbitrary URLs. +Subclasses :class:`agent.web_search_provider.WebSearchProvider` (the +plugin-facing ABC). The legacy in-tree module +``tools.web_providers.brave_free`` was removed in the same commit that +moved this code under ``plugins/``; this file is now the canonical +implementation. -Configuration:: +Config keys this provider responds to:: - # ~/.hermes/.env - BRAVE_SEARCH_API_KEY=your-subscription-token - - # ~/.hermes/config.yaml web: - search_backend: "brave-free" - extract_backend: "firecrawl" # pair with an extract provider if needed + search_backend: "brave-free" # explicit per-capability + backend: "brave-free" # shared fallback -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. +Auth env var:: + + BRAVE_SEARCH_API_KEY=... # https://brave.com/search/api/ (free tier) """ from __future__ import annotations @@ -26,49 +23,45 @@ import logging import os from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) _BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search" -class BraveFreeSearchProvider(WebSearchProvider): - """Search via the Brave Search API (free tier). +class BraveFreeWebSearchProvider(WebSearchProvider): + """Search-only Brave provider using the free-tier Data-for-Search API. - Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the - ``X-Subscription-Token`` header. No extract capability — pair with - Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``. + Free tier is 2,000 queries/month (1 qps). No content-extraction capability — + users pair this with Firecrawl/Tavily/Exa for ``web_extract``. """ - def provider_name(self) -> str: + @property + def name(self) -> str: + # Hyphen form preserved for backward compat with the existing + # ``web.search_backend: "brave-free"`` config keys users have set. return "brave-free" - def is_configured(self) -> bool: + @property + def display_name(self) -> str: + return "Brave Search (Free)" + + def is_available(self) -> bool: """Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value.""" return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip()) + def 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 normalized results:: - - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } - - On failure returns ``{"success": False, "error": str}``. + Returns ``{"success": True, "data": {"web": [{"title", "url", "description", "position"}]}}`` + on success, or ``{"success": False, "error": str}`` on failure. """ import httpx @@ -128,3 +121,17 @@ class BraveFreeSearchProvider(WebSearchProvider): ) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Brave Search (Free)", + "badge": "free", + "tag": "Free-tier API key — 2k queries/mo, search only.", + "env_vars": [ + { + "key": "BRAVE_SEARCH_API_KEY", + "prompt": "Brave Search API key (free tier)", + "url": "https://brave.com/search/api/", + }, + ], + } diff --git a/plugins/web/ddgs/__init__.py b/plugins/web/ddgs/__init__.py new file mode 100644 index 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/tools/web_providers/ddgs.py b/plugins/web/ddgs/provider.py similarity index 50% rename from tools/web_providers/ddgs.py rename to plugins/web/ddgs/provider.py index b81b97de2cb..e8846236a24 100644 --- a/tools/web_providers/ddgs.py +++ b/plugins/web/ddgs/provider.py @@ -1,28 +1,13 @@ -"""DuckDuckGo web search provider via the ``ddgs`` Python package. +"""DuckDuckGo search — plugin form (via the ``ddgs`` package). -DuckDuckGo does not provide an official programmatic search API. The -community-maintained `ddgs `_ package (the -renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results -page and normalizes them. It implements ``WebSearchProvider`` only — there is -no extract capability. +Subclasses the plugin-facing :class:`agent.web_search_provider.WebSearchProvider`. +The legacy in-tree module ``tools.web_providers.ddgs`` was removed in the +same commit that moved this code under ``plugins/``; this file is now the +canonical implementation. -Configuration:: - - # No API key required. Enable by installing the package and pointing the - # web backend at ddgs: - pip install ddgs - - # ~/.hermes/config.yaml - web: - search_backend: "ddgs" - extract_backend: "firecrawl" # pair with an extract provider if needed - -Rate limits are enforced server-side by DuckDuckGo. Expect intermittent -``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider -surfaces them as ``{"success": False, "error": ...}`` rather than crashing -the tool call. - -See https://duckduckgo.com/?q=duckduckgo+tos for terms of use. +The ``ddgs`` package is an optional dependency. ``is_available()`` reflects +whether the package is importable; the plugin still registers either way so +``hermes tools`` can prompt the user to install it. """ from __future__ import annotations @@ -30,39 +15,49 @@ from __future__ import annotations import logging from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -class DDGSSearchProvider(WebSearchProvider): - """Search via the ``ddgs`` package (DuckDuckGo HTML scrape). +class DDGSWebSearchProvider(WebSearchProvider): + """DuckDuckGo HTML-scrape search provider. - No API key required. The provider is considered "configured" when the - ``ddgs`` package is importable — there is nothing else to set up. + No API key needed. Rate limits are enforced server-side by DuckDuckGo; + the provider surfaces ``DuckDuckGoSearchException`` and other ddgs errors + as ``{"success": False, "error": ...}`` rather than raising. """ - def provider_name(self) -> str: + @property + def name(self) -> str: return "ddgs" - def is_configured(self) -> bool: + @property + def display_name(self) -> str: + return "DuckDuckGo (ddgs)" + + def is_available(self) -> bool: """Return True when the ``ddgs`` package is importable. - Called at tool-registration time; must not perform network I/O. + Probes the import once; cheap because Python caches the import. Must + NOT perform network I/O — runs at tool-registration time and on every + ``hermes tools`` paint. """ try: import ddgs # noqa: F401 + return True except ImportError: return False - def search(self, query: str, limit: int = 5) -> Dict[str, Any]: - """Execute a DuckDuckGo search and return normalized results. + def supports_search(self) -> bool: + return True - Returns ``{"success": True, "data": {"web": [...]}}`` on success or - ``{"success": False, "error": str}`` on failure (missing package, - rate-limited, network error, etc.). - """ + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a DuckDuckGo search and return normalized results.""" try: from ddgs import DDGS # type: ignore except ImportError: @@ -96,3 +91,14 @@ class DDGSSearchProvider(WebSearchProvider): logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "DuckDuckGo (ddgs)", + "badge": "free · no key · search only", + "tag": "Search via the ddgs Python package — no API key (pair with any extract provider)", + "env_vars": [], + # Trigger `_run_post_setup("ddgs")` after the user picks this row + # so the ddgs Python package gets pip-installed on first selection. + "post_setup": "ddgs", + } diff --git a/plugins/web/exa/__init__.py b/plugins/web/exa/__init__.py new file mode 100644 index 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..0fea6fb5a8b --- /dev/null +++ b/plugins/web/exa/provider.py @@ -0,0 +1,212 @@ +"""Exa web search + content extraction — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses the +official Exa SDK (``exa-py``) which is lazy-loaded via +:func:`tools.lazy_deps.ensure` so that cold-start CLI users don't pay the +SDK import cost when Exa isn't configured. + +Config keys this provider responds to:: + + web: + search_backend: "exa" # explicit per-capability + extract_backend: "exa" # explicit per-capability + backend: "exa" # shared fallback for both + +Env var:: + + EXA_API_KEY=... # https://exa.ai (paid tier; free trial available) + +The previous in-tree implementation lived at +``tools.web_tools._exa_search`` / ``_exa_extract``; this file is the +canonical replacement. Behavior is bit-for-bit identical aside from the +ABC method-name change. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +# Module-level note: the canonical ``_exa_client`` cache slot lives on +# :mod:`tools.web_tools` so tests that do ``tools.web_tools._exa_client = +# None`` between cases see fresh state. The plugin reads/writes through +# that public module (see :func:`_get_exa_client`). + + +def _get_exa_client() -> Any: + """Lazy-import and cache an Exa SDK client. + + Cache lives on :mod:`tools.web_tools` (as ``_exa_client``) so unit + tests that reset that name between cases keep working. Raises + ``ValueError`` when ``EXA_API_KEY`` is unset. + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_exa_client", None) + if cached is not None: + return cached + + api_key = os.getenv("EXA_API_KEY") + if not api_key: + raise ValueError( + "EXA_API_KEY environment variable not set. " + "Get your API key at https://exa.ai" + ) + + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("search.exa", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints + raise ImportError(str(exc)) + + from exa_py import Exa # noqa: WPS433 — deliberately lazy + + client = Exa(api_key=api_key) + client.headers["x-exa-integration"] = "hermes-agent" + _wt._exa_client = client + return client + + +def _reset_client_for_tests() -> None: + """Drop the cached Exa client so tests can re-instantiate cleanly.""" + import tools.web_tools as _wt + + _wt._exa_client = None + + +class ExaWebSearchProvider(WebSearchProvider): + """Exa search + extract provider. + + Both methods are sync — Exa's SDK is sync-only. The web_extract_tool + dispatcher wraps sync extracts via ``asyncio.to_thread`` when it + needs to keep the event loop responsive. + """ + + @property + def name(self) -> str: + return "exa" + + @property + def display_name(self) -> str: + return "Exa" + + def is_available(self) -> bool: + """Return True when ``EXA_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("EXA_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute an Exa search. + + Returns ``{"success": True, "data": {"web": [{...}, ...]}}`` on + success, ``{"success": False, "error": str}`` on failure (incl. + missing API key and SDK install errors). + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Exa search: '%s' (limit=%d)", query, limit) + response = _get_exa_client().search( + query, + num_results=limit, + contents={"highlights": True}, + ) + + web_results = [] + for i, result in enumerate(response.results or []): + highlights = result.highlights or [] + web_results.append( + { + "url": result.url or "", + "title": result.title or "", + "description": " ".join(highlights) if highlights else "", + "position": i + 1, + } + ) + + return {"success": True, "data": {"web": web_results}} + except ValueError as exc: + # Raised by _get_exa_client when EXA_API_KEY missing + return {"success": False, "error": str(exc)} + except ImportError as exc: + return {"success": False, "error": f"Exa SDK not installed: {exc}"} + except Exception as exc: # noqa: BLE001 — surface as failure + logger.warning("Exa search error: %s", exc) + return {"success": False, "error": f"Exa search failed: {exc}"} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Exa. + + Returns a list of result dicts shaped for the legacy LLM + post-processing pipeline. On per-URL or whole-batch failure, + results carry an ``error`` field rather than raising. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Exa extract: %d URL(s)", len(urls)) + response = _get_exa_client().get_contents(urls, text=True) + + results: List[Dict[str, Any]] = [] + for result in response.results or []: + content = result.text or "" + url = result.url or "" + title = result.title or "" + results.append( + { + "url": url, + "title": title, + "content": content, + "raw_content": content, + "metadata": {"sourceURL": url, "title": title}, + } + ) + return results + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except ImportError as exc: + return [ + {"url": u, "title": "", "content": "", "error": f"Exa SDK not installed: {exc}"} + for u in urls + ] + except Exception as exc: # noqa: BLE001 + logger.warning("Exa extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Exa extract failed: {exc}"} + for u in urls + ] + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Exa", + "badge": "paid", + "tag": "Semantic + neural web search with content extraction.", + "env_vars": [ + { + "key": "EXA_API_KEY", + "prompt": "Exa API key", + "url": "https://exa.ai", + }, + ], + } diff --git a/plugins/web/firecrawl/__init__.py b/plugins/web/firecrawl/__init__.py new file mode 100644 index 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..bcc574ffca3 --- /dev/null +++ b/plugins/web/firecrawl/provider.py @@ -0,0 +1,773 @@ +"""Firecrawl web search + extract — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. This is +the largest provider migrated in this PR; it captures the full inline +firecrawl implementation that previously lived in tools/web_tools.py: + + - :data:`Firecrawl` lazy proxy that defers the ~200ms SDK import to + first use (re-exported by tools.web_tools for backward compat with + existing tests that mock that name). + - :func:`_get_firecrawl_client` with direct + managed-gateway dual + mode, controlled by ``web.use_gateway`` config when both are + configured. + - :func:`check_firecrawl_api_key` re-exported (tests + tools_config + setup hint depend on this name living in tools.web_tools). + - :func:`_extract_web_search_results` / :func:`_extract_scrape_payload` + response-shape normalizers that handle SDK / direct API / gateway + response variants. + - Per-URL extract loop with 60s timeout, redirect-aware SSRF re-check, + website-policy gating, and format-aware content selection. + +Async note: the underlying SDK is sync. ``extract()`` is declared +``async def`` because it performs per-URL I/O that benefits from +running in an executor; the implementation wraps each scrape in +:func:`asyncio.to_thread` with :func:`asyncio.wait_for(timeout=60)` to +guard against hung fetches. + +Config keys this provider responds to:: + + web: + search_backend: "firecrawl" # explicit per-capability + extract_backend: "firecrawl" # explicit per-capability + backend: "firecrawl" # shared fallback (default) + use_gateway: false # prefer managed gateway when both + # direct + gateway credentials exist + +Env vars:: + + FIRECRAWL_API_KEY=... # direct cloud auth + FIRECRAWL_API_URL=... # self-hosted Firecrawl + FIRECRAWL_GATEWAY_URL=... # Nous tool-gateway (subscribers) + TOOL_GATEWAY_DOMAIN=... # alternate gateway env + TOOL_GATEWAY_SCHEME=... + TOOL_GATEWAY_USER_TOKEN=... +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from agent.web_search_provider import WebSearchProvider +from tools.website_policy import check_website_access + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Lazy Firecrawl SDK proxy +# --------------------------------------------------------------------------- +# The firecrawl SDK pulls ~200ms of imports (httpcore, firecrawl.v1/v2 type +# trees) on a cold CLI. We only need it when the backend is actually +# "firecrawl", so defer the import to first use via a callable proxy. +# +# Tests that do ``patch("tools.web_tools.Firecrawl", ...)`` continue to +# work because tools/web_tools.py re-exports ``Firecrawl`` from this +# module — so the patched name still references the same proxy instance. + +if TYPE_CHECKING: + from firecrawl import Firecrawl as FirecrawlSDK # noqa: F401 — type hints only + +_FIRECRAWL_CLS_CACHE: Optional[type] = None + + +def _load_firecrawl_cls() -> type: + """Import and cache ``firecrawl.Firecrawl``.""" + global _FIRECRAWL_CLS_CACHE + if _FIRECRAWL_CLS_CACHE is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("search.firecrawl", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 — surface install hint + raise ImportError(str(exc)) + from firecrawl import Firecrawl as _cls # noqa: WPS433 — deliberately lazy + + _FIRECRAWL_CLS_CACHE = _cls + return _FIRECRAWL_CLS_CACHE + + +class _FirecrawlProxy: + """Callable proxy that looks like ``firecrawl.Firecrawl`` but imports lazily.""" + + __slots__ = () + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return _load_firecrawl_cls()(*args, **kwargs) + + def __instancecheck__(self, obj: Any) -> bool: + return isinstance(obj, _load_firecrawl_cls()) + + def __repr__(self) -> str: + return "" + + +Firecrawl = _FirecrawlProxy() + + +# --------------------------------------------------------------------------- +# Client construction (direct vs managed-gateway) +# --------------------------------------------------------------------------- +# +# The canonical cache slots live on :mod:`tools.web_tools` so tests that do +# ``tools.web_tools._firecrawl_client = None`` between cases see fresh +# state. The plugin reads/writes through that public module — see +# :func:`_get_firecrawl_client` below. + + +def _get_direct_firecrawl_config() -> Optional[tuple]: + """Return explicit direct Firecrawl kwargs + cache key, or None when unset.""" + api_key = os.getenv("FIRECRAWL_API_KEY", "").strip() + api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/") + + if not api_key and not api_url: + return None + + kwargs: Dict[str, str] = {} + if api_key: + kwargs["api_key"] = api_key + if api_url: + kwargs["api_url"] = api_url + + return kwargs, ("direct", api_url or None, api_key or None) + + +def _get_firecrawl_gateway_url() -> str: + """Return the configured Firecrawl gateway URL.""" + import tools.web_tools as _wt + + return _wt.build_vendor_gateway_url("firecrawl") + + +def _is_tool_gateway_ready() -> bool: + """Return True when gateway URL + Nous Subscriber token are available. + + Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway`` + via :mod:`tools.web_tools` rather than direct imports, so unit tests + that ``patch("tools.web_tools._read_nous_access_token", ...)`` see + their patches honored. The names are re-exported on + :mod:`tools.web_tools` for exactly this reason. + """ + import tools.web_tools as _wt + + return _wt.resolve_managed_tool_gateway( + "firecrawl", token_reader=_wt._read_nous_access_token + ) is not None + + +def _has_direct_firecrawl_config() -> bool: + """Return True when direct Firecrawl config is explicitly configured.""" + return _get_direct_firecrawl_config() is not None + + +def check_firecrawl_api_key() -> bool: + """Return True when Firecrawl backend (direct or gateway) is usable. + + Re-exported by :mod:`tools.web_tools` for backward compatibility with + existing tests and the ``hermes tools`` setup flow. + """ + return _has_direct_firecrawl_config() or _is_tool_gateway_ready() + + +def _firecrawl_backend_help_suffix() -> str: + """Return optional managed-gateway guidance for Firecrawl help text.""" + import tools.web_tools as _wt + + if not _wt.managed_nous_tools_enabled(): + return "" + return ( + ", or use the Nous Tool Gateway via your subscription " + "(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)" + ) + + +def _raise_web_backend_configuration_error() -> None: + """Raise a clear error for unsupported web backend configuration.""" + import tools.web_tools as _wt + + message = ( + "Web tools are not configured. " + "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL " + "for a self-hosted Firecrawl instance." + ) + if _wt.managed_nous_tools_enabled(): + message += ( + " With your Nous subscription you can also use the Tool Gateway — " + "run `hermes tools` and select Nous Subscription as the web provider." + ) + raise ValueError(message) + + +def _get_firecrawl_client() -> Any: + """Get or create the cached Firecrawl client. + + When ``web.use_gateway`` is set in config, the managed Tool Gateway is + preferred even if direct Firecrawl credentials are present. Otherwise + direct Firecrawl takes precedence when explicitly configured. + + Raises ValueError when neither path is usable. + + The cached client is stored on :mod:`tools.web_tools` (as + ``_firecrawl_client`` and ``_firecrawl_client_config``) rather than on + this plugin module so that unit tests that reset the cache via + ``tools.web_tools._firecrawl_client = None`` keep working. Helper + functions (``prefers_gateway``, ``resolve_managed_tool_gateway``, + ``_read_nous_access_token``, ``Firecrawl``) are also looked up via + :mod:`tools.web_tools` for the same reason — see + :func:`_is_tool_gateway_ready`. + """ + import tools.web_tools as _wt + + direct_config = _get_direct_firecrawl_config() + if direct_config is not None and not _wt.prefers_gateway("web"): + kwargs, client_config = direct_config + else: + managed_gateway = _wt.resolve_managed_tool_gateway( + "firecrawl", token_reader=_wt._read_nous_access_token + ) + if managed_gateway is None: + logger.error( + "Firecrawl client initialization failed: " + "missing direct config and tool-gateway auth." + ) + _raise_web_backend_configuration_error() + + kwargs = { + "api_key": managed_gateway.nous_user_token, + "api_url": managed_gateway.gateway_origin, + } + client_config = ( + "tool-gateway", + kwargs["api_url"], + managed_gateway.nous_user_token, + ) + + cached = getattr(_wt, "_firecrawl_client", None) + cached_config = getattr(_wt, "_firecrawl_client_config", None) + if cached is not None and cached_config == client_config: + return cached + + # Construct via the re-exported Firecrawl proxy on tools.web_tools so + # unit tests patching ``tools.web_tools.Firecrawl`` see their mock. + _wt._firecrawl_client = _wt.Firecrawl(**kwargs) + _wt._firecrawl_client_config = client_config + return _wt._firecrawl_client + + +def _reset_client_for_tests() -> None: + """Drop the cached Firecrawl client so tests can re-instantiate cleanly. + + Clears the canonical slots on :mod:`tools.web_tools` (where + :func:`_get_firecrawl_client` reads/writes them). + """ + import tools.web_tools as _wt + + _wt._firecrawl_client = None + _wt._firecrawl_client_config = None + + +# --------------------------------------------------------------------------- +# Response shape normalization (SDK / direct / gateway differ) +# --------------------------------------------------------------------------- + + +def _to_plain_object(value: Any) -> Any: + """Convert SDK objects to plain python data structures when possible.""" + if value is None: + return None + + if isinstance(value, (dict, list, str, int, float, bool)): + return value + + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: # noqa: BLE001 + pass + + if hasattr(value, "__dict__"): + try: + return {k: v for k, v in value.__dict__.items() if not k.startswith("_")} + except Exception: # noqa: BLE001 + pass + + return value + + +def _normalize_result_list(values: Any) -> List[Dict[str, Any]]: + """Normalize mixed SDK/list payloads into a list of dicts.""" + if not isinstance(values, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for item in values: + plain = _to_plain_object(item) + if isinstance(plain, dict): + normalized.append(plain) + return normalized + + +def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]: + """Extract Firecrawl search results across SDK/direct/gateway response shapes.""" + response_plain = _to_plain_object(response) + + if isinstance(response_plain, dict): + data = response_plain.get("data") + if isinstance(data, list): + return _normalize_result_list(data) + + if isinstance(data, dict): + data_web = _normalize_result_list(data.get("web")) + if data_web: + return data_web + data_results = _normalize_result_list(data.get("results")) + if data_results: + return data_results + + top_web = _normalize_result_list(response_plain.get("web")) + if top_web: + return top_web + + top_results = _normalize_result_list(response_plain.get("results")) + if top_results: + return top_results + + if hasattr(response, "web"): + return _normalize_result_list(getattr(response, "web", [])) + + return [] + + +def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]: + """Normalize Firecrawl scrape payload shape across SDK and gateway variants.""" + result_plain = _to_plain_object(scrape_result) + if not isinstance(result_plain, dict): + return {} + + nested = result_plain.get("data") + if isinstance(nested, dict): + return nested + + return result_plain + + +# --------------------------------------------------------------------------- +# Provider class +# --------------------------------------------------------------------------- + + +class FirecrawlWebSearchProvider(WebSearchProvider): + """Firecrawl search + extract provider with dual auth paths.""" + + @property + def name(self) -> str: + return "firecrawl" + + @property + def display_name(self) -> str: + return "Firecrawl" + + def is_available(self) -> bool: + """Return True when direct Firecrawl OR managed-gateway path is configured.""" + return check_firecrawl_api_key() + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Firecrawl search. + + Sync; matches the legacy ``_get_firecrawl_client().search(...)`` + call directly. Normalizes the response across SDK/direct/gateway + shapes via :func:`_extract_web_search_results`. + + Pre-flight errors (``ValueError`` from configuration check, + ``ImportError`` from missing SDK) propagate to the dispatcher's + top-level handler, which wraps them as ``tool_error(...)`` — + matching the legacy ``{"error": "Error searching web: ..."}`` + envelope. Only in-flight errors are caught and surfaced as + ``{"success": False, "error": ...}``. + """ + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Firecrawl search: '%s' (limit=%d)", query, limit) + # _get_firecrawl_client() raises ValueError on unconfigured systems — + # let it propagate so the dispatcher emits the legacy envelope shape. + client = _get_firecrawl_client() + try: + response = client.search(query=query, limit=limit) + web_results = _extract_web_search_results(response) + logger.info("Firecrawl: found %d search results", len(web_results)) + return {"success": True, "data": {"web": web_results}} + except Exception as exc: # noqa: BLE001 + logger.warning("Firecrawl search error: %s", exc) + return {"success": False, "error": f"Firecrawl search failed: {exc}"} + + async def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Firecrawl. + + Async; each URL is scraped in a background thread with a 60s + timeout. After scraping, the final URL (post-redirect) is + re-checked against website-access policy. + + Accepted kwargs (others ignored for forward compat): + - ``format``: ``"markdown"`` or ``"html"``; default is both + (request both, return markdown when available). + + Returns the legacy per-URL list-of-results shape. Per-URL failures + (timeout, SSRF block, scrape error, policy block) become items + with an ``error`` field rather than raising. + """ + from tools.interrupt import is_interrupted as _is_interrupted + + if _is_interrupted(): + return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] + + format = kwargs.get("format") + formats: List[str] = [] + if format == "markdown": + formats = ["markdown"] + elif format == "html": + formats = ["html"] + else: + formats = ["markdown", "html"] + + # check_website_access is the legacy policy gate; imported at + # module level (lazy-friendly because the website_policy import is + # cheap) so monkeypatching it in tests works as expected. + + results: List[Dict[str, Any]] = [] + + for url in urls: + if _is_interrupted(): + results.append({"url": url, "error": "Interrupted", "title": ""}) + continue + + # Pre-scrape website policy gate + blocked = check_website_access(url) + if blocked: + logger.info( + "Blocked web_extract for %s by rule %s", + blocked["host"], + blocked["rule"], + ) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": blocked["message"], + "blocked_by_policy": { + "host": blocked["host"], + "rule": blocked["rule"], + "source": blocked["source"], + }, + } + ) + continue + + try: + logger.info("Firecrawl scraping: %s", url) + try: + scrape_result = await asyncio.wait_for( + asyncio.to_thread( + _get_firecrawl_client().scrape, + url=url, + formats=formats, + ), + timeout=60, + ) + except asyncio.TimeoutError: + logger.warning("Firecrawl scrape timed out for %s", url) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": ( + "Scrape timed out after 60s — page may be too large " + "or unresponsive. Try browser_navigate instead." + ), + } + ) + continue + + scrape_payload = _extract_scrape_payload(scrape_result) + metadata = scrape_payload.get("metadata", {}) + content_markdown = scrape_payload.get("markdown") + content_html = scrape_payload.get("html") + + # Ensure metadata is a dict (SDK may return a typed object) + if not isinstance(metadata, dict): + if hasattr(metadata, "model_dump"): + metadata = metadata.model_dump() + elif hasattr(metadata, "__dict__"): + metadata = metadata.__dict__ + else: + metadata = {} + + title = metadata.get("title", "") + final_url = metadata.get("sourceURL", url) + + # Re-check website-access policy after any redirect + final_blocked = check_website_access(final_url) + if final_blocked: + logger.info( + "Blocked redirected web_extract for %s by rule %s", + final_blocked["host"], + final_blocked["rule"], + ) + results.append( + { + "url": final_url, + "title": title, + "content": "", + "raw_content": "", + "error": final_blocked["message"], + "blocked_by_policy": { + "host": final_blocked["host"], + "rule": final_blocked["rule"], + "source": final_blocked["source"], + }, + } + ) + continue + + # Choose markdown vs html according to the requested format + if format == "markdown" or (format is None and content_markdown): + chosen_content = content_markdown + else: + chosen_content = content_html or content_markdown or "" + + results.append( + { + "url": final_url, + "title": title, + "content": chosen_content, + "raw_content": chosen_content, + "metadata": metadata, + } + ) + except Exception as scrape_err: # noqa: BLE001 + logger.debug("Firecrawl scrape failed for %s: %s", url, scrape_err) + results.append( + { + "url": url, + "title": "", + "content": "", + "raw_content": "", + "error": str(scrape_err), + } + ) + + return results + + async def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + """Crawl a seed URL via Firecrawl's ``/crawl`` endpoint. + + Sync SDK call wrapped in ``asyncio.to_thread`` because the dispatcher + in :func:`tools.web_tools.web_crawl_tool` is async and runs LLM + post-processing on the response. The dispatcher gates the seed URL + against SSRF + website-access policy before calling us; this method + re-checks every crawled page's URL against the policy after the + crawl returns to catch redirected pages that map to a blocked host. + + Accepted kwargs (others ignored for forward compat): + - ``instructions``: str — logged then dropped. Firecrawl's /crawl + endpoint does NOT accept natural-language instructions (that's + an /extract feature), so we record the value for debugging and + proceed without it. Tavily's crawl IS instruction-aware; this + divergence is documented in both plugins' docstrings. + - ``limit``: int — max pages to crawl (default 20). + - ``depth``: str — accepted for API parity with Tavily; ignored + by Firecrawl's crawl endpoint. + + Returns ``{"results": [...]}`` matching the shape that + :func:`tools.web_tools.web_crawl_tool`'s shared LLM-summarization + path expects. Per-page failures (policy block on redirected URL, + bad response shape) are included as items with an ``error`` field + rather than raising. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"results": [{"url": url, "title": "", "content": "", "error": "Interrupted"}]} + + instructions = kwargs.get("instructions") + limit = kwargs.get("limit", 20) + + # Firecrawl's /crawl endpoint does not accept natural-language + # instructions (that's an /extract feature). Log + drop. + if instructions: + logger.info( + "Firecrawl crawl: 'instructions' parameter ignored " + "(not supported by Firecrawl /crawl)" + ) + + logger.info("Firecrawl crawl: %s (limit=%d)", url, limit) + + crawl_params = { + "limit": limit, + "scrape_options": {"formats": ["markdown"]}, + } + + # The SDK call is sync; run in a thread so we don't block the + # gateway event loop on a multi-page crawl. + crawl_result = await asyncio.to_thread( + _get_firecrawl_client().crawl, + url=url, + **crawl_params, + ) + + # CrawlJob normalization across SDK + direct + gateway shapes. + data_list: List[Any] = [] + if hasattr(crawl_result, "data"): + data_list = crawl_result.data if crawl_result.data else [] + logger.info( + "Firecrawl crawl status: %s, %d pages", + getattr(crawl_result, "status", "unknown"), + len(data_list), + ) + elif isinstance(crawl_result, dict) and "data" in crawl_result: + data_list = crawl_result.get("data", []) or [] + else: + logger.warning( + "Firecrawl crawl: unexpected result type %r", + type(crawl_result).__name__, + ) + + pages: List[Dict[str, Any]] = [] + for item in data_list: + # Pydantic model | typed object | dict — handle all shapes. + content_markdown = None + content_html = None + metadata: Any = {} + + if hasattr(item, "model_dump"): + item_dict = item.model_dump() + content_markdown = item_dict.get("markdown") + content_html = item_dict.get("html") + metadata = item_dict.get("metadata", {}) + elif hasattr(item, "__dict__"): + content_markdown = getattr(item, "markdown", None) + content_html = getattr(item, "html", None) + metadata_obj = getattr(item, "metadata", {}) + if hasattr(metadata_obj, "model_dump"): + metadata = metadata_obj.model_dump() + elif hasattr(metadata_obj, "__dict__"): + metadata = metadata_obj.__dict__ + elif isinstance(metadata_obj, dict): + metadata = metadata_obj + else: + metadata = {} + elif isinstance(item, dict): + content_markdown = item.get("markdown") + content_html = item.get("html") + metadata = item.get("metadata", {}) + + # Ensure metadata is a plain dict. + if not isinstance(metadata, dict): + if hasattr(metadata, "model_dump"): + metadata = metadata.model_dump() + elif hasattr(metadata, "__dict__"): + metadata = metadata.__dict__ + else: + metadata = {} + + page_url = metadata.get( + "sourceURL", metadata.get("url", "Unknown URL") + ) + title = metadata.get("title", "") + + # Per-page policy re-check (catches blocked redirects). + page_blocked = check_website_access(page_url) + if page_blocked: + logger.info( + "Blocked crawled page %s by rule %s", + page_blocked["host"], + page_blocked["rule"], + ) + pages.append( + { + "url": page_url, + "title": title, + "content": "", + "raw_content": "", + "error": page_blocked["message"], + "blocked_by_policy": { + "host": page_blocked["host"], + "rule": page_blocked["rule"], + "source": page_blocked["source"], + }, + } + ) + continue + + content = content_markdown or content_html or "" + pages.append( + { + "url": page_url, + "title": title, + "content": content, + "raw_content": content, + "metadata": metadata, + } + ) + + return {"results": pages} + except ValueError as exc: + return {"results": [{"url": url, "title": "", "content": "", "error": str(exc)}]} + except ImportError as exc: + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Firecrawl SDK not installed: {exc}", + } + ] + } + except Exception as exc: # noqa: BLE001 + logger.warning("Firecrawl crawl error: %s", exc) + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Firecrawl crawl failed: {exc}", + } + ] + } + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Firecrawl", + "badge": "paid · optional gateway", + "tag": ( + "Full search + extract + crawl; supports direct API and " + "Nous tool-gateway routing." + ), + "env_vars": [ + { + "key": "FIRECRAWL_API_KEY", + "prompt": "Firecrawl API key (or leave blank for self-hosted)", + "url": "https://docs.firecrawl.dev/introduction", + }, + ], + } diff --git a/plugins/web/parallel/__init__.py b/plugins/web/parallel/__init__.py new file mode 100644 index 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..38578e6b52c --- /dev/null +++ b/plugins/web/parallel/provider.py @@ -0,0 +1,291 @@ +"""Parallel.ai web search + content extraction — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses two +distinct Parallel SDK clients: + +- ``Parallel`` (sync) — for :meth:`search` +- ``AsyncParallel`` (async) — for :meth:`extract` + +This is the first plugin to exercise the **async-extract** code path in +the ABC: :meth:`extract` is declared ``async def``, and the dispatcher +in :func:`tools.web_tools.web_extract_tool` detects coroutines via +:func:`inspect.iscoroutinefunction` and awaits. + +Config keys this provider responds to:: + + web: + search_backend: "parallel" # explicit per-capability + extract_backend: "parallel" # explicit per-capability + backend: "parallel" # shared fallback + # Optional: search mode (default "agentic"; also "fast" or "one-shot") + # via the PARALLEL_SEARCH_MODE env var. + +Env vars:: + + PARALLEL_API_KEY=... # https://parallel.ai (required) + PARALLEL_SEARCH_MODE=agentic # optional: agentic|fast|one-shot +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +# Module-level note: the canonical cache slots ``_parallel_client`` and +# ``_async_parallel_client`` live on :mod:`tools.web_tools` so tests that do +# ``tools.web_tools._parallel_client = None`` between cases see fresh state. +# The plugin reads/writes through that public module (see +# :func:`_get_sync_client` / :func:`_get_async_client`). + + +def _ensure_parallel_sdk_installed() -> None: + """Trigger lazy install of the parallel SDK if it isn't present. + + Mirrors the lazy-deps pattern used by the legacy implementation. + Swallows benign ImportError from the lazy_deps helper itself; if the + SDK is genuinely missing the subsequent ``from parallel import ...`` + raises ImportError that the caller can handle. + """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 — surface install hint as ImportError + raise ImportError(str(exc)) + + +def _get_sync_client() -> Any: + """Lazy-load + cache the sync Parallel client. + + Cache lives on :mod:`tools.web_tools` (as ``_parallel_client``) so unit + tests that reset that name between cases keep working. + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_parallel_client", None) + if cached is not None: + return cached + + api_key = os.getenv("PARALLEL_API_KEY") + if not api_key: + raise ValueError( + "PARALLEL_API_KEY environment variable not set. " + "Get your API key at https://parallel.ai" + ) + + _ensure_parallel_sdk_installed() + from parallel import Parallel # noqa: WPS433 — deliberately lazy + + client = Parallel(api_key=api_key) + _wt._parallel_client = client + return client + + +def _get_async_client() -> Any: + """Lazy-load + cache the async Parallel client. + + Cache lives on :mod:`tools.web_tools` (as ``_async_parallel_client``). + """ + import tools.web_tools as _wt + + cached = getattr(_wt, "_async_parallel_client", None) + if cached is not None: + return cached + + api_key = os.getenv("PARALLEL_API_KEY") + if not api_key: + raise ValueError( + "PARALLEL_API_KEY environment variable not set. " + "Get your API key at https://parallel.ai" + ) + + _ensure_parallel_sdk_installed() + from parallel import AsyncParallel # noqa: WPS433 — deliberately lazy + + client = AsyncParallel(api_key=api_key) + _wt._async_parallel_client = client + return client + + +def _reset_clients_for_tests() -> None: + """Drop both cached clients so tests can re-instantiate cleanly. + + Clears the canonical slots on :mod:`tools.web_tools` (where + :func:`_get_sync_client` / :func:`_get_async_client` read/write them). + """ + import tools.web_tools as _wt + + _wt._parallel_client = None + _wt._async_parallel_client = None + + +# Backward-compatible aliases for the names that lived in tools.web_tools +# before the migration (matches existing tests + external callers). +_get_parallel_client = _get_sync_client +_get_async_parallel_client = _get_async_client + + +def _resolve_search_mode() -> str: + """Return the validated PARALLEL_SEARCH_MODE value (default "agentic").""" + mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() + if mode not in {"fast", "one-shot", "agentic"}: + mode = "agentic" + return mode + + +class ParallelWebSearchProvider(WebSearchProvider): + """Parallel.ai search + async extract provider.""" + + @property + def name(self) -> str: + return "parallel" + + @property + def display_name(self) -> str: + return "Parallel" + + def is_available(self) -> bool: + """Return True when ``PARALLEL_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("PARALLEL_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Parallel search (sync). + + Uses the ``beta.search`` endpoint with the configured mode + (``PARALLEL_SEARCH_MODE`` env var, default "agentic"). Limit is + capped at 20 server-side. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + mode = _resolve_search_mode() + logger.info( + "Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit + ) + response = _get_sync_client().beta.search( + search_queries=[query], + objective=query, + mode=mode, + max_results=min(limit, 20), + ) + + web_results = [] + for i, result in enumerate(response.results or []): + excerpts = result.excerpts or [] + web_results.append( + { + "url": result.url or "", + "title": result.title or "", + "description": " ".join(excerpts) if excerpts else "", + "position": i + 1, + } + ) + + return {"success": True, "data": {"web": web_results}} + except ValueError as exc: + return {"success": False, "error": str(exc)} + except ImportError as exc: + return { + "success": False, + "error": f"Parallel SDK not installed: {exc}", + } + except Exception as exc: # noqa: BLE001 + logger.warning("Parallel search error: %s", exc) + return {"success": False, "error": f"Parallel search failed: {exc}"} + + async def extract( + self, urls: List[str], **kwargs: Any + ) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via the async SDK. + + Returns the legacy list-of-results shape that + :func:`tools.web_tools.web_extract_tool` expects: one entry per + successful URL plus one entry per failed URL with an ``error`` + field. Errors are not raised — they're returned as per-URL items. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Parallel extract: %d URL(s)", len(urls)) + response = await _get_async_client().beta.extract( + urls=urls, + full_content=True, + ) + + results: List[Dict[str, Any]] = [] + for result in response.results or []: + content = result.full_content or "" + if not content: + content = "\n\n".join(result.excerpts or []) + url = result.url or "" + title = result.title or "" + results.append( + { + "url": url, + "title": title, + "content": content, + "raw_content": content, + "metadata": {"sourceURL": url, "title": title}, + } + ) + + for error in response.errors or []: + results.append( + { + "url": error.url or "", + "title": "", + "content": "", + "error": error.content or error.error_type or "extraction failed", + "metadata": {"sourceURL": error.url or ""}, + } + ) + + return results + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except ImportError as exc: + return [ + {"url": u, "title": "", "content": "", "error": f"Parallel SDK not installed: {exc}"} + for u in urls + ] + except Exception as exc: # noqa: BLE001 + logger.warning("Parallel extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Parallel extract failed: {exc}"} + for u in urls + ] + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Parallel", + "badge": "paid", + "tag": "Objective-tuned search + parallel page extraction.", + "env_vars": [ + { + "key": "PARALLEL_API_KEY", + "prompt": "Parallel API key", + "url": "https://parallel.ai", + }, + ], + } diff --git a/plugins/web/searxng/__init__.py b/plugins/web/searxng/__init__.py new file mode 100644 index 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/tools/web_providers/searxng.py b/plugins/web/searxng/provider.py similarity index 51% rename from tools/web_providers/searxng.py rename to plugins/web/searxng/provider.py index 589b0a2b337..043f6711c1b 100644 --- a/tools/web_providers/searxng.py +++ b/plugins/web/searxng/provider.py @@ -1,22 +1,23 @@ -"""SearXNG web search provider. +"""SearXNG search — plugin form. -SearXNG is a free, self-hosted, privacy-respecting metasearch engine. -It implements ``WebSearchProvider`` only — there is no extract capability. +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Same JSON +API call (``/search?format=json``), same result normalization. The legacy +in-tree module ``tools.web_providers.searxng`` was removed in the same +commit that moved this code under ``plugins/``; this file is now the +canonical implementation. -Configuration:: +Search-only — SearXNG aggregates results from upstream engines but does not +fetch/extract arbitrary URLs. ``supports_extract()`` returns False. - # ~/.hermes/.env - SEARXNG_URL=http://localhost:8080 +Config keys this provider responds to:: - # Use SearXNG for search, pair with any extract provider: - # ~/.hermes/config.yaml web: - search_backend: "searxng" - extract_backend: "firecrawl" + search_backend: "searxng" # explicit per-capability + backend: "searxng" # shared fallback -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). +Env var:: + + SEARXNG_URL=http://localhost:8080 """ from __future__ import annotations @@ -25,50 +26,34 @@ import logging import os from typing import Any, Dict -from tools.web_providers.base import WebSearchProvider +from agent.web_search_provider import WebSearchProvider logger = logging.getLogger(__name__) -class SearXNGSearchProvider(WebSearchProvider): - """Search via a SearXNG instance. +class SearXNGWebSearchProvider(WebSearchProvider): + """Search via a user-hosted 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: + @property + def name(self) -> str: return "searxng" - def is_configured(self) -> bool: - """Return True when ``SEARXNG_URL`` is set to a non-empty value.""" + @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. - - Returns normalized results:: - - { - "success": True, - "data": { - "web": [ - { - "title": str, - "url": str, - "description": str, - "position": int, - }, - ... - ] - } - } - - On failure returns ``{"success": False, "error": str}``. - """ + """Execute a search against the configured SearXNG instance.""" import httpx base_url = os.getenv("SEARXNG_URL", "").strip().rstrip("/") @@ -91,16 +76,25 @@ class SearXNGSearchProvider(WebSearchProvider): resp.raise_for_status() except httpx.HTTPStatusError as exc: logger.warning("SearXNG HTTP error: %s", exc) - return {"success": False, "error": f"SearXNG returned HTTP {exc.response.status_code}"} + return { + "success": False, + "error": f"SearXNG returned HTTP {exc.response.status_code}", + } except httpx.RequestError as exc: logger.warning("SearXNG request error: %s", exc) - return {"success": False, "error": f"Could not reach SearXNG at {base_url}: {exc}"} + return { + "success": False, + "error": f"Could not reach SearXNG at {base_url}: {exc}", + } try: data = resp.json() except Exception as exc: # noqa: BLE001 logger.warning("SearXNG response parse error: %s", exc) - return {"success": False, "error": "Could not parse SearXNG response as JSON"} + return { + "success": False, + "error": "Could not parse SearXNG response as JSON", + } raw_results = data.get("results", []) @@ -130,3 +124,17 @@ class SearXNGSearchProvider(WebSearchProvider): ) return {"success": True, "data": {"web": web_results}} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "SearXNG", + "badge": "free · self-hosted", + "tag": "Free, privacy-respecting metasearch. Point SEARXNG_URL at your instance.", + "env_vars": [ + { + "key": "SEARXNG_URL", + "prompt": "SearXNG instance URL (e.g. http://localhost:8080)", + "url": "https://searx.space/", + }, + ], + } diff --git a/plugins/web/tavily/__init__.py b/plugins/web/tavily/__init__.py new file mode 100644 index 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..50e15973fb3 --- /dev/null +++ b/plugins/web/tavily/provider.py @@ -0,0 +1,285 @@ +"""Tavily web search + content extraction + crawl — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Three +capabilities advertised: + +- ``supports_search()`` -> True (Tavily ``/search``) +- ``supports_extract()`` -> True (Tavily ``/extract``) +- ``supports_crawl()`` -> True (Tavily ``/crawl``) — sync HTTP crawl; + Firecrawl also advertises ``supports_crawl=True`` (async) + +All three are sync — the underlying call is ``httpx.post(...)``. The +dispatcher in :func:`tools.web_tools.web_crawl_tool` (which is itself +async) will run sync providers in a thread when appropriate. + +Config keys this provider responds to:: + + web: + search_backend: "tavily" # explicit per-capability + extract_backend: "tavily" # explicit per-capability + crawl_backend: "tavily" # explicit per-capability + backend: "tavily" # shared fallback for all three + +Env vars:: + + TAVILY_API_KEY=... # https://app.tavily.com/home (required) + TAVILY_BASE_URL=... # optional override of https://api.tavily.com + +Auth note: Tavily uses ``api_key`` in the JSON body for /search and +/extract, but **also requires** ``Authorization: Bearer `` for /crawl +(body-only auth returns 401 on /crawl). The plugin handles both. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +def _tavily_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST to the Tavily API and return the parsed JSON response. + + Mirrors :func:`tools.web_tools._tavily_request`. Raises ``ValueError`` + when ``TAVILY_API_KEY`` is unset; the caller catches and surfaces as + a typed error response. + """ + import httpx + + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + raise ValueError( + "TAVILY_API_KEY environment variable not set. " + "Get your API key at https://app.tavily.com/home" + ) + + base_url = os.getenv("TAVILY_BASE_URL", "https://api.tavily.com") + payload = dict(payload) # don't mutate caller's dict + payload["api_key"] = api_key + url = f"{base_url}/{endpoint.lstrip('/')}" + logger.info("Tavily %s request to %s", endpoint, url) + + # Tavily /crawl requires Bearer header auth in addition to body auth; + # /search and /extract are body-only. + headers = {"Authorization": f"Bearer {api_key}"} if endpoint.strip("/") == "crawl" else {} + + response = httpx.post(url, json=payload, headers=headers, timeout=60) + response.raise_for_status() + return response.json() + + +def _normalize_tavily_search_results(response: Dict[str, Any]) -> Dict[str, Any]: + """Map Tavily ``/search`` response to ``{success, data: {web: [...]}}``.""" + web_results = [] + for i, result in enumerate(response.get("results", [])): + web_results.append( + { + "title": result.get("title", ""), + "url": result.get("url", ""), + "description": result.get("content", ""), + "position": i + 1, + } + ) + return {"success": True, "data": {"web": web_results}} + + +def _normalize_tavily_documents( + response: Dict[str, Any], fallback_url: str = "" +) -> List[Dict[str, Any]]: + """Map Tavily ``/extract`` or ``/crawl`` response to standard documents. + + Documents follow the legacy LLM post-processing shape:: + + {"url", "title", "content", "raw_content", "metadata"} + + Failures (``failed_results``, ``failed_urls``) become result entries + with an ``error`` field rather than raising. + """ + documents: List[Dict[str, Any]] = [] + for result in response.get("results", []): + url = result.get("url", fallback_url) + raw = result.get("raw_content", "") or result.get("content", "") + documents.append( + { + "url": url, + "title": result.get("title", ""), + "content": raw, + "raw_content": raw, + "metadata": {"sourceURL": url, "title": result.get("title", "")}, + } + ) + for fail in response.get("failed_results", []): + documents.append( + { + "url": fail.get("url", fallback_url), + "title": "", + "content": "", + "raw_content": "", + "error": fail.get("error", "extraction failed"), + "metadata": {"sourceURL": fail.get("url", fallback_url)}, + } + ) + for fail_url in response.get("failed_urls", []): + url_str = fail_url if isinstance(fail_url, str) else str(fail_url) + documents.append( + { + "url": url_str, + "title": "", + "content": "", + "raw_content": "", + "error": "extraction failed", + "metadata": {"sourceURL": url_str}, + } + ) + return documents + + +class TavilyWebSearchProvider(WebSearchProvider): + """Tavily search + extract + crawl provider.""" + + @property + def name(self) -> str: + return "tavily" + + @property + def display_name(self) -> str: + return "Tavily" + + def is_available(self) -> bool: + """Return True when ``TAVILY_API_KEY`` is set to a non-empty value.""" + return bool(os.getenv("TAVILY_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Tavily search.""" + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Tavily search: '%s' (limit=%d)", query, limit) + raw = _tavily_request( + "search", + { + "query": query, + "max_results": min(limit, 20), + "include_raw_content": False, + "include_images": False, + }, + ) + return _normalize_tavily_search_results(raw) + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: # noqa: BLE001 — including httpx errors + logger.warning("Tavily search error: %s", exc) + return {"success": False, "error": f"Tavily search failed: {exc}"} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from one or more URLs via Tavily. + + Sync — the underlying call is httpx.post(...). Returns the legacy + list-of-results shape; per-URL failures become items with ``error``. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [ + {"url": u, "error": "Interrupted", "title": ""} for u in urls + ] + + logger.info("Tavily extract: %d URL(s)", len(urls)) + raw = _tavily_request( + "extract", + { + "urls": urls, + "include_images": False, + }, + ) + return _normalize_tavily_documents( + raw, fallback_url=urls[0] if urls else "" + ) + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except Exception as exc: # noqa: BLE001 + logger.warning("Tavily extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Tavily extract failed: {exc}"} + for u in urls + ] + + def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + """Crawl a seed URL via Tavily's ``/crawl`` endpoint. + + Accepted kwargs (others ignored for forward compat): + - ``instructions``: str — natural-language guidance for the crawl + - ``depth``: str — ``"basic"`` (default) or ``"advanced"`` + - ``limit``: int — max pages to crawl (default 20) + + Returns ``{"results": [...]}`` shaped to match what + :func:`tools.web_tools.web_crawl_tool` post-processes. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"results": [{"url": url, "title": "", "content": "", "error": "Interrupted"}]} + + instructions = kwargs.get("instructions") + depth = kwargs.get("depth", "basic") + limit = kwargs.get("limit", 20) + + logger.info("Tavily crawl: %s (depth=%s, limit=%d)", url, depth, limit) + payload: Dict[str, Any] = { + "url": url, + "limit": limit, + "extract_depth": depth, + } + if instructions: + payload["instructions"] = instructions + + raw = _tavily_request("crawl", payload) + return { + "results": _normalize_tavily_documents(raw, fallback_url=url) + } + except ValueError as exc: + return {"results": [{"url": url, "title": "", "content": "", "error": str(exc)}]} + except Exception as exc: # noqa: BLE001 + logger.warning("Tavily crawl error: %s", exc) + return { + "results": [ + { + "url": url, + "title": "", + "content": "", + "error": f"Tavily crawl failed: {exc}", + } + ] + } + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Tavily", + "badge": "paid", + "tag": "Search + extract + crawl in one provider.", + "env_vars": [ + { + "key": "TAVILY_API_KEY", + "prompt": "Tavily API key", + "url": "https://app.tavily.com/home", + }, + ], + } diff --git a/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/pyproject.toml b/pyproject.toml index a7b4f654264..24ae545a38e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,9 +85,9 @@ 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"] +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"] @@ -172,14 +172,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 @@ -221,14 +213,15 @@ 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/**/*"] 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"] @@ -244,11 +237,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/run_agent.py b/run_agent.py index f0597c90880..a82c6417ae1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -181,6 +181,10 @@ from agent.tool_guardrails import ( append_toolguard_guidance, toolguard_synthetic_result, ) +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, @@ -349,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 @@ -1267,11 +1271,11 @@ 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" - 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" @@ -2111,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", 3)) + ) # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -2191,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: @@ -2311,7 +2328,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, @@ -3233,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 @@ -4250,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), \ @@ -4263,18 +4282,28 @@ 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), parent_session_id=self.session_id, - enabled_toolsets=["memory", "skills"], ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" @@ -4291,12 +4320,75 @@ 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 + # 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 - 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() + + # Tear down memory providers while stdout is still + # redirected so background thread teardown (Honcho flush, + # Hindsight sync, etc.) stays silent. The finally block + # below is a safety net for the exception path. + try: + review_agent.shutdown_memory_provider() + except Exception: + pass + try: + review_agent.close() + except Exception: + pass + review_messages = list(getattr(review_agent, "_session_messages", [])) + review_agent = None + # Scan the review agent's messages for successful tool actions # and surface a compact summary to the user. Tool messages # already present in messages_snapshot must be skipped, since @@ -4304,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, ) @@ -4326,21 +4418,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 @@ -5347,7 +5442,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 @@ -7043,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") @@ -7066,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 @@ -9230,6 +9371,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). @@ -9495,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, @@ -9977,11 +10158,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: @@ -10013,6 +10195,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": @@ -10252,6 +10450,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: @@ -10982,14 +11183,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, @@ -11404,11 +11601,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, @@ -12059,7 +12252,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 @@ -12110,6 +12303,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() @@ -12506,16 +12713,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, @@ -12938,7 +13159,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( @@ -12959,7 +13180,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 { @@ -13407,6 +13628,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( @@ -13519,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" @@ -14165,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) @@ -14415,7 +14646,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), ) @@ -15158,9 +15391,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() @@ -15549,6 +15782,153 @@ 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") + # Crash → unconditionally drop the session so the next turn + # respawns from scratch instead of reusing a dead client. + try: + self._codex_session.close() + except Exception: + pass + self._codex_session = None + return { + "final_response": ( + f"Codex app-server turn failed: {exc}. " + f"Fall back to default runtime with `/codex-runtime auto`." + ), + "messages": messages, + "api_calls": 0, + "completed": False, + "partial": True, + "error": str(exc), + } + + # If the turn signalled the underlying client is wedged (deadline + # blown, post-tool watchdog tripped, OAuth refresh died, subprocess + # exited), retire the session so the next turn respawns codex + # rather than riding the broken process. Mirrors openclaw beta.8's + # "retire timed-out app-server clients" fix. + if getattr(turn, "should_retire", False): + logger.warning( + "codex app-server session retired (turn error: %s)", + turn.error, + ) + try: + self._codex_session.close() + except Exception: + pass + self._codex_session = None + + # Splice projected messages into the conversation. The projector emits + # standard {role, content, tool_calls, tool_call_id} entries, which + # is exactly what curator.py / sessions DB expect. + if turn.projected_messages: + messages.extend(turn.projected_messages) + + # Counter ticks for the self-improvement loop. + # _turns_since_memory and _user_turn_count are ALREADY incremented + # in the run_conversation() pre-loop block (lines ~11793-11817) so we + # do NOT touch them here — that would double-count. + # Only _iters_since_skill needs explicit increment, since the + # chat_completions loop bumps it per tool iteration (line ~12110) + # and that loop is bypassed on this path. + self._iters_since_skill = ( + getattr(self, "_iters_since_skill", 0) + turn.tool_iterations + ) + + # Now check the skill nudge AFTER iters were incremented — same + # pattern the chat_completions path uses (line ~15432). + should_review_skills = False + if ( + self._skill_nudge_interval > 0 + and self._iters_since_skill >= self._skill_nudge_interval + and "skill_manage" in self.valid_tool_names + ): + should_review_skills = True + self._iters_since_skill = 0 + + # External memory provider sync (mirrors line ~15439). Skipped on + # interrupt/error to avoid feeding partial transcripts to memory. + if not turn.interrupted and turn.error is None: + try: + self._sync_external_memory_for_turn( + original_user_message=original_user_message, + final_response=turn.final_text, + interrupted=False, + ) + except Exception: + logger.debug("external memory sync raised", exc_info=True) + + # Background review fork — same cadence + signature as the default + # path (line ~15449). Only fires when a trigger actually tripped AND + # we have a real final response. + if ( + turn.final_text + and not turn.interrupted + and (should_review_memory or should_review_skills) + ): + try: + self._spawn_background_review( + messages_snapshot=list(messages), + review_memory=should_review_memory, + review_skills=should_review_skills, + ) + except Exception: + logger.debug("background review spawn raised", exc_info=True) + + return { + "final_response": turn.final_text, + "messages": messages, + "api_calls": 1, # one app-server "turn" maps to one logical API call + "completed": not turn.interrupted and turn.error is None, + "partial": turn.interrupted or turn.error is not None, + "error": turn.error, + "codex_thread_id": turn.thread_id, + "codex_turn_id": turn.turn_id, + } + def main( query: str = None, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e2fe765174c..2cf81969beb 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 @@ -925,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 72cc81637da..9c5db6b1c08 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -64,10 +64,12 @@ NODE_VERSION="22" # data still at /root/.hermes (HERMES_HOME). Matches Claude Code / Codex CLI # and keeps Docker bind-mounted /root/ volumes lean. ROOT_FHS_LAYOUT=false +DETECTED_BROWSER_EXECUTABLE="" # Options USE_VENV=true RUN_SETUP=true +SKIP_BROWSER=false BRANCH="main" # Detect non-interactive mode (e.g. curl | bash) @@ -90,6 +92,10 @@ while [[ $# -gt 0 ]]; do RUN_SETUP=false shift ;; + --skip-browser|--no-playwright) + SKIP_BROWSER=true + shift + ;; --branch) BRANCH="$2" shift 2 @@ -111,6 +117,7 @@ while [[ $# -gt 0 ]]; do echo "Options:" echo " --no-venv Don't create virtual environment" echo " --skip-setup Skip interactive setup wizard" + echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)" echo " --branch NAME Git branch to install (default: main)" echo " --dir PATH Installation directory" echo " default (non-root): ~/.hermes/hermes-agent" @@ -1044,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 @@ -1236,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" } @@ -1280,6 +1275,10 @@ setup_path() { # We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars # can't make this launcher import modules from another checkout. mkdir -p "$command_link_dir" + # Older installs created this path as a symlink to $HERMES_BIN. Without + # the rm, `cat >` follows the symlink and overwrites the venv pip entry + # point with this shim — making `exec "$HERMES_BIN"` self-recurse. (#21454) + rm -f "$command_link_dir/hermes" cat > "$command_link_dir/hermes" </dev/null 2>&1; then + command -v "$AGENT_BROWSER_EXECUTABLE_PATH" + return 0 + fi + fi + + local candidate + for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + + return 1 +} + +run_browser_install_with_timeout() { + local timeout_seconds="$1" + shift + + if command -v timeout >/dev/null 2>&1; then + timeout "$timeout_seconds" "$@" + else + "$@" + fi +} + +configure_browser_env_from_system_browser() { + local env_file="$HERMES_HOME/.env" + local browser_path="${DETECTED_BROWSER_EXECUTABLE:-}" + + if [ -z "$browser_path" ]; then + browser_path="$(find_system_browser 2>/dev/null || true)" + fi + + if [ -z "$browser_path" ] || [ ! -f "$env_file" ]; then + return 0 + fi + + if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then + log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured" + return 0 + fi + + { + echo "" + echo "# Hermes Agent browser tools — use the system Chrome/Chromium binary." + echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" + } >> "$env_file" + log_success "Configured browser tools to use $browser_path" +} + install_node_deps() { if [ "$HAS_NODE" = false ]; then log_info "Skipping Node.js dependencies (Node not installed)" @@ -1494,58 +1560,90 @@ install_node_deps() { # Playwright's --with-deps only supports apt-based systems natively. # For Arch/Manjaro we install the system libs via pacman first. # Other systems must install Chromium dependencies manually. + if [ "$SKIP_BROWSER" = true ]; then + log_info "Skipping Playwright/Chromium install (--skip-browser)" + log_info "Browser tools will be unavailable until you run manually:" + log_info " cd $INSTALL_DIR && npx playwright install chromium" + log_info "On apt-based systems, an admin also needs to run:" + log_info " sudo npx playwright install-deps chromium" + else log_info "Installing browser engine (Playwright Chromium)..." - case "$DISTRO" in - ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) - log_info "Playwright may request sudo to install browser system dependencies (shared libraries)." - log_info "This is standard Playwright setup — Hermes itself does not require root access." - cd "$INSTALL_DIR" && npx playwright install --with-deps chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — browser tools will not work." - log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium" - } - ;; - arch|manjaro) - if command -v pacman &> /dev/null; then - log_info "Arch/Manjaro detected — installing Chromium system dependencies via pacman..." - if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true - elif [ "$(id -u)" -eq 0 ]; then - pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" + if [ -n "$DETECTED_BROWSER_EXECUTABLE" ]; then + log_success "Found system Chrome/Chromium at $DETECTED_BROWSER_EXECUTABLE" + log_info "Skipping Playwright browser download; Hermes will use the system browser." + else + case "$DISTRO" in + ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) + # Use --with-deps only when sudo is available non-interactively + # (root, or a user with passwordless sudo). Non-sudo users + # — typical for systemd service accounts and unprivileged + # operator users — would otherwise get blocked on an + # interactive sudo prompt that they can't satisfy. Fall back + # to the browser-only install in that case, and print the + # exact command the admin needs to run separately. + if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then + log_info "Installing Playwright Chromium with system dependencies..." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install --with-deps chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium" + } else - log_warn "Cannot install browser deps without sudo. Run manually:" - log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + log_warn "No sudo available — skipping system-library install (--with-deps)." + log_info "Ask an administrator to run, one time, as root:" + log_info " sudo npx playwright install-deps chromium" + log_info " (from $INSTALL_DIR, after Node.js deps are installed)" + log_info "Installing Chromium binary into this user's Playwright cache..." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install chromium" + } fi - fi - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — browser tools will not work." - } - ;; - fedora|rhel|centos|rocky|alma) - log_warn "Playwright does not support automatic dependency installation on RPM-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - opensuse*|sles) - log_warn "Playwright does not support automatic dependency installation on zypper-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2" - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - *) - log_warn "Playwright does not support automatic dependency installation on $DISTRO." - log_info "Install Chromium/browser system dependencies for your distribution, then run:" - log_info " cd $INSTALL_DIR && npx playwright install chromium" - log_info "Browser tools will not work until dependencies are installed." - cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || true - ;; - esac + ;; + arch|manjaro|cachyos|endeavouros|garuda) + if command -v pacman &> /dev/null; then + log_info "Arch-family distro detected — installing Chromium system dependencies via pacman..." + if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then + sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \ + nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + elif [ "$(id -u)" -eq 0 ]; then + pacman -S --noconfirm --needed \ + nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true + else + log_warn "Cannot install browser deps without sudo. Run manually:" + log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" + fi + fi + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — browser tools will not work." + } + ;; + fedora|rhel|centos|rocky|alma) + log_warn "Playwright does not support automatic dependency installation on RPM-based systems." + log_info "Install Chromium system dependencies manually before using browser tools:" + log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — install dependencies above and retry." + } + ;; + opensuse*|sles) + log_warn "Playwright does not support automatic dependency installation on zypper-based systems." + log_info "Install Chromium system dependencies manually before using browser tools:" + log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2" + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || { + log_warn "Playwright browser installation failed — install dependencies above and retry." + } + ;; + *) + log_warn "Playwright does not support automatic dependency installation on $DISTRO." + log_info "Install Chromium/browser system dependencies for your distribution, then run:" + log_info " cd $INSTALL_DIR && npx playwright install chromium" + log_info "Browser tools will not work until dependencies are installed." + cd "$INSTALL_DIR" && run_browser_install_with_timeout 600 npx playwright install chromium 2>/dev/null || true + ;; + esac + fi + fi log_success "Browser engine setup complete" fi diff --git a/scripts/release.py b/scripts/release.py index 482bb911a21..740b79091b1 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,11 @@ REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" +# 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" + # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping # ────────────────────────────────────────────────────────────────────── @@ -41,27 +47,55 @@ 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", + "anadi.jaggia@gmail.com": "Jaggia", + "32201324+simpolism@users.noreply.github.com": "simpolism", + "simpolism@gmail.com": "simpolism", + "jake@nousresearch.com": "simpolism", + "mgongzai@gmail.com": "vKongv", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", + "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", "leone.parise@gmail.com": "leoneparise", + "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", "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", "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", "treydong.zh@gmail.com": "TreyDong", + "phil.thomas@gametime.co": "explainanalyze", "kyanam.preetham@gmail.com": "pkyanam", + "zhizhong.xu@shopee.com": "1000Delta", + "30397170+1000Delta@users.noreply.github.com": "1000Delta", + "szymonclawd@mac.home": "szymonclawd", + "257759490+szymonclawd@users.noreply.github.com": "szymonclawd", + "zhanganzhe@tenclass.com": "luoyuctl", + "51604064+luoyuctl@users.noreply.github.com": "luoyuctl", "127238744+teknium1@users.noreply.github.com": "teknium1", + "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", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", @@ -79,8 +113,10 @@ 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", + "108541149+amethystani@users.noreply.github.com": "amethystani", "ytchen0719@gmail.com": "liquidchen", "am@studio1.tailb672fe.ts.net": "subtract0", "mike@grossmann.at": "ReqX", @@ -133,6 +169,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", @@ -186,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", @@ -221,11 +259,13 @@ 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", "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", @@ -366,6 +406,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", @@ -598,6 +639,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", @@ -606,6 +648,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", @@ -704,6 +747,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", @@ -746,6 +790,8 @@ AUTHOR_MAP = { "chayton@sina.com": "ycbai", "longsizhuo@gmail.com": "longsizhuo", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", + "phil.thomas@gametime.co": "explainanalyze", "276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123", "22549957+li0near@users.noreply.github.com": "li0near", "guoyu801@gmail.com": "li0near", @@ -844,6 +890,8 @@ AUTHOR_MAP = { "dpaluy@users.noreply.github.com": "dpaluy", "psikonetik@gmail.com": "el-analista", "chenb19870707@gmail.com": "ms-alan", + "agorgianitisj@hotmail.com": "johnisag", + "phil.thomas@gametime.co": "explainanalyze", "hex-clawd@users.noreply.github.com": "hex-clawd", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", "barteq@hacknotes.local": "barteqpl", @@ -1003,6 +1051,30 @@ 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) + "23yntong@stu.edu.cn": "iuyup", # PR #6155 salvage (shell=True hardening) + "86501179+1RB@users.noreply.github.com": "1RB", # PR #25462 salvage (discord forwarded messages) + "44045943+ayushere@users.noreply.github.com": "ayushere", # PR #25342 salvage (memory teardown leak) + "15791290+domtriola@users.noreply.github.com": "domtriola", # PR #25424 salvage (docs tirith link) + "284216128+ephron-ren@users.noreply.github.com": "ephron-ren", # PR #25358 salvage (MiMo reasoning echo-back) + "96843562+freqyfreqy@users.noreply.github.com": "freqyfreqy", # PR #25423 salvage (docs LSP worktree -> repo) + "54306477+fu576@users.noreply.github.com": "fu576", # PR #25369 salvage (api_mode not inherited cross-provider) + "258095375+kfa-ai@users.noreply.github.com": "kfa-ai", # PR #25398 salvage (whatsapp quoted reply metadata) + "99181308+magic524@users.noreply.github.com": "magic524", # PR #25361 salvage (QQBot reconnect loop) + "9150277+PaTTeeL@users.noreply.github.com": "PaTTeeL", # PR #25359 salvage (custom_providers in compression length) + "1700913+pearjelly@users.noreply.github.com": "pearjelly", # PR #25388 salvage (feishu ws connect override sync) + "100820567+raymaylee@users.noreply.github.com": "raymaylee", # PR #25394 salvage (context compaction status) + "122434621+Tianyu199509@users.noreply.github.com": "Tianyu199509", # PR #25421 salvage (gateway PID Windows) + "58224596+HxT9@users.noreply.github.com": "HxT9", # PR #25760 salvage (web sync-assets cross-platform) + "120411712+evgyur@users.noreply.github.com": "evgyur", # PR #25651 salvage (docs media session context) + "36507055+AsoTora@users.noreply.github.com": "AsoTora", # PR #25624 salvage (MCP auth no-retry) + "98992931+oxngon@users.noreply.github.com": "oxngon", # PR #25603 salvage (forward image attachments to bg tasks) + "37467487+yifengingit@users.noreply.github.com": "yifengingit", # PR #25589 salvage (AUTOINCREMENT id ordering) + "89525629+vanthinh6886@users.noreply.github.com": "vanthinh6886", # PR #25562 salvage (.env 0600 perms) + "16034932+Arkmusn@users.noreply.github.com": "Arkmusn", # PR #25559 salvage (approvals.timeout from config) + "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) } @@ -1104,19 +1176,48 @@ 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's version + uvx package pin in lockstep + with pyproject. + + 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 + 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" + ) + 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, @@ -1129,7 +1230,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()) @@ -1236,11 +1337,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", ) @@ -1254,14 +1355,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 @@ -1281,7 +1382,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: @@ -1432,6 +1533,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) @@ -1476,7 +1578,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 @@ -1518,7 +1623,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, diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 9ab6118da1b..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 @@ -300,7 +317,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 +432,10 @@ async function startSocket() { mediaType, mediaUrls, mentionedIds, + quotedMessageId, quotedParticipant, + quotedRemoteJid, + hasQuotedMessage, botIds, timestamp: msg.messageTimestamp, }; @@ -481,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) { @@ -515,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) { @@ -619,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); 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/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`. 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..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 @@ -15,6 +18,179 @@ 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(): + 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/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" diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py new file mode 100644 index 00000000000..633b4a8494c --- /dev/null +++ b/tests/acp/test_registry_manifest.py @@ -0,0 +1,90 @@ +"""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_uvx_distribution_without_local_command_fields(): + data = _manifest() + + 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"] + + +def test_agent_json_version_matches_pyproject(): + assert _manifest()["version"] == _pyproject_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()}" + ) + + +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/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/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/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 97a7c7b3d0f..559cf2237a2 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,92 @@ class TestSummaryTargetRatio: c = ContextCompressor(model="test", quiet_mode=True) assert c.protect_last_n == 20 + def test_default_protect_first_n_is_3(self): + """Default protect_first_n is 3 (system + 3 extra non-system messages = + 4 protected messages total when a system prompt is present). With the + new semantics, the constructor default is 3 — the system prompt is + always implicitly protected ON TOP OF protect_first_n non-system + messages. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor(model="test", quiet_mode=True) + assert c.protect_first_n == 3 + + def test_protect_first_n_override(self): + """protect_first_n=0 should be honoured — for users who rely on rolling + compaction and want NOTHING pinned at head except the system prompt + (always implicitly protected).""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=0) + assert c.protect_first_n == 0 + + def test_protect_first_n_0_preserves_only_system_prompt(self): + """End-to-end: when protect_first_n=0, compression should treat only + the system prompt as head. All user/assistant messages between the + system prompt and the protected tail become summarization candidates. + + This is the cleanest configuration for long-running rolling-compaction + sessions — no user/assistant turn gets pinned verbatim forever just + because it happened to be early in the session.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=0, + protect_last_n=2, + ) + msgs = ( + [{"role": "system", "content": "System prompt"}] + + [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} + for i in range(8)] + ) + result = c.compress(msgs) + # System prompt (msg[0]) survives as head + assert result[0]["role"] == "system" + assert result[0]["content"].startswith("System prompt") + # The first user/assistant exchange (msg 0, msg 1) should NOT be pinned + # as head verbatim — those would have been summarized or absorbed. + # Under default protect_first_n=3, result[1..3] would be the literal + # "msg 0" / "msg 1" / "msg 2"; with protect_first_n=0 they aren't. + assert result[1].get("content") != "msg 0" + # Last 2 messages are tail-protected under protect_last_n=2 + assert result[-1]["content"] == msgs[-1]["content"] + + def test_protect_first_n_semantics_stable_without_system_prompt(self): + """Regression: gateway /compress handler strips the system prompt + before calling compress(). protect_first_n must mean the same thing + in both paths — "N non-system head messages" — so configuring + protect_first_n=0 preserves NOTHING at the head regardless of whether + the system prompt is in the messages list. + + Bug this covers: under the old semantics, protect_first_n counted + literally from messages[0]. In the gateway path (no system prompt) + that meant protect_first_n=1 would pin the first user turn of the + session forever — a user-reported complaint that a week-old + resolved question kept getting reinserted into every compaction + summary.""" + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=0, + protect_last_n=2, + ) + # No system prompt — this is what the gateway passes to compress(). + msgs = [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} + for i in range(10) + ] + head_size = c._protect_head_size(msgs) + # With no system prompt and protect_first_n=0 → head is empty. + # The first user message is NOT pinned as head. + assert head_size == 0 + + # And with protect_first_n=3 on the same no-system-prompt list → + # head size is 3 (the three earliest non-system messages). + c.protect_first_n = 3 + assert c._protect_head_size(msgs) == 3 + class TestTokenBudgetTailProtection: """Tests for token-budget-based tail protection (PR #6240). diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index 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/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_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 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/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/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..f51996dd067 --- /dev/null +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -0,0 +1,976 @@ +"""Tests for CodexAppServerSession — drive turns through a mock client. + +The session adapter has the most complex behavior of the three new modules: +notification draining, server-request handling (approvals), interrupt, +deadline timeouts. These tests pin all of that without spawning real codex. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Optional + +import pytest + +from agent.transports.codex_app_server_session import ( + CodexAppServerSession, + TurnResult, + _ServerRequestRouting, + _approval_choice_to_codex_decision, +) + + +class FakeClient: + """Stand-in for CodexAppServerClient that records calls and lets the test + drive the notification / server-request streams synchronously.""" + + def __init__(self, *, codex_bin: str = "codex", codex_home=None) -> None: + self.codex_bin = codex_bin + self.codex_home = codex_home + self.requests: list[tuple[str, dict]] = [] + self.notifications_responses: list[dict] = [] + self.responses: list[tuple[Any, dict]] = [] + self.error_responses: list[tuple[Any, int, str]] = [] + self._initialized = False + self._closed = False + self._notifications: list[dict] = [] + self._server_requests: list[dict] = [] + self._request_handler = None # Optional[Callable[[str, dict], dict]] + + # API matching CodexAppServerClient + def initialize(self, **kwargs): + self._initialized = True + return {"userAgent": "fake/0.0.0", "codexHome": "/tmp", + "platformOs": "linux", "platformFamily": "unix"} + + def request(self, method: str, params: Optional[dict] = None, timeout: float = 30.0): + self.requests.append((method, params or {})) + if self._request_handler is not None: + return self._request_handler(method, params or {}) + # Sensible defaults for protocol methods used by the session + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}, + "activePermissionProfile": {"id": "workspace-write"}} + if method == "turn/start": + return {"turn": {"id": "turn-fake-001"}} + if method == "turn/interrupt": + return {} + return {} + + def notify(self, method: str, params=None): + pass + + def respond(self, request_id, result): + self.responses.append((request_id, result)) + + def respond_error(self, request_id, code, message, data=None): + self.error_responses.append((request_id, code, message)) + + def take_notification(self, timeout: float = 0.0): + if self._notifications: + return self._notifications.pop(0) + # Honor a tiny sleep so the loop doesn't hot-spin; the real client + # blocks on a queue. For tests we want determinism. + if timeout > 0: + time.sleep(min(timeout, 0.001)) + return None + + def take_server_request(self, timeout: float = 0.0): + if self._server_requests: + return self._server_requests.pop(0) + return None + + def close(self): + self._closed = True + + def is_alive(self) -> bool: + # Fake is "alive" until close() is called; tests that want a dead + # subprocess can patch this attribute or call close() directly. + return not self._closed + + def stderr_tail(self, n: int = 20): + return list(getattr(self, "_stderr_tail", []))[-n:] + + # Test helpers + def queue_notification(self, method: str, **params): + self._notifications.append({"method": method, "params": params}) + + def queue_server_request(self, method: str, request_id: Any = "srv-1", **params): + self._server_requests.append({"id": request_id, "method": method, "params": params}) + + def set_stderr_tail(self, lines): + """Test helper: seed stderr_tail() output for OAuth-refresh classifier tests.""" + self._stderr_tail = list(lines) + + +def make_session(client: FakeClient, **kwargs) -> CodexAppServerSession: + return CodexAppServerSession( + cwd="/tmp", + client_factory=lambda **kw: client, + **kwargs, + ) + + +# ---- choice mapping ---- + +class TestApprovalChoiceMapping: + @pytest.mark.parametrize("choice,expected", [ + ("once", "accept"), + ("session", "acceptForSession"), + ("always", "acceptForSession"), + ("deny", "decline"), + ("anything-else", "decline"), + ]) + def test_mapping(self, choice, expected): + assert _approval_choice_to_codex_decision(choice) == expected + + +# ---- lifecycle ---- + +class TestLifecycle: + def test_ensure_started_is_idempotent(self): + client = FakeClient() + s = make_session(client) + tid_a = s.ensure_started() + tid_b = s.ensure_started() + assert tid_a == tid_b == "thread-fake-001" + # thread/start should be called exactly once + method_calls = [m for (m, _) in client.requests if m == "thread/start"] + assert len(method_calls) == 1 + + def test_thread_start_passes_cwd_only(self): + """thread/start carries cwd. We intentionally do NOT pass `permissions` + on this codex version (experimentalApi-gated + requires matching + config.toml [permissions] table). Letting codex use its default + (read-only unless user configures otherwise) is the documented path.""" + client = FakeClient() + s = make_session(client, permission_profile="workspace-write") + s.ensure_started() + method, params = next(r for r in client.requests if r[0] == "thread/start") + assert params["cwd"] == "/tmp" + assert "permissions" not in params # see session.ensure_started() comment + + def test_close_idempotent(self): + client = FakeClient() + s = make_session(client) + s.ensure_started() + s.close() + s.close() + assert client._closed is True + + +# ---- turn loop ---- + +class TestRunTurn: + def test_simple_text_turn_returns_final_message(self): + client = FakeClient() + client.queue_notification("turn/started", threadId="t", turn={"id": "tu1"}) + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "hello world"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", + threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.final_text == "hello world" + assert r.interrupted is False + assert r.error is None + assert any(m["role"] == "assistant" and m.get("content") == "hello world" + for m in r.projected_messages) + # turn_id propagated for downstream session-DB linkage + assert r.turn_id == "turn-fake-001" + + def test_tool_iteration_counter_ticks(self): + client = FakeClient() + # Two completed exec items + one final agent message + for i, item_id in enumerate(("ex1", "ex2"), start=1): + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": item_id, + "command": f"cmd{i}", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "ok", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "done"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("do stuff", turn_timeout=2.0) + assert r.tool_iterations == 2 + # Each tool item produces (assistant, tool) — 2*2 + final assistant = 5 msgs + assert len(r.projected_messages) == 5 + + def test_turn_start_failure_returns_error(self): + client = FakeClient() + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32600, message="bad input") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "bad input" in r.error + assert r.final_text == "" + + def test_turn_start_failure_attaches_redacted_stderr_tail(self): + """When codex stderr has content (non-OAuth), the tail gets attached + to the user-facing error so config/provider problems are debuggable + instead of just 'Internal error'. Secrets in stderr are redacted + via agent.redact(force=True).""" + client = FakeClient() + client.set_stderr_tail([ + "ERROR: provider auth failed", + "Authorization: Bearer sk-live-deadbeefdeadbeef", + "url=https://api.example.com/v1?token=querysecret12345", + ]) + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32603, message="Internal error") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "turn/start failed" in r.error + assert "Internal error" in r.error + # Stderr tail attached + assert "codex stderr" in r.error + assert "provider auth failed" in r.error + # Secrets redacted + assert "sk-live-deadbeefdeadbeef" not in r.error + assert "querysecret12345" not in r.error + # Non-OAuth → should NOT retire (subprocess JSON-RPC is still healthy). + assert r.should_retire is False + + def test_turn_start_timeout_attaches_redacted_stderr_tail(self): + """A non-OAuth TimeoutError on turn/start surfaces with codex stderr + context attached and marks the session for retirement.""" + client = FakeClient() + client.set_stderr_tail([ + "WARN: provider request stalled", + "Authorization: Bearer sk-stalled-secret-abc123", + ]) + + def stall(method, params): + if method == "turn/start": + raise TimeoutError("codex method 'turn/start' timed out after 10s") + return {"thread": {"id": "t"}, "activePermissionProfile": {"id": "x"}} + + client._request_handler = stall + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "turn/start timed out" in r.error + assert "provider request stalled" in r.error + assert "sk-stalled-secret-abc123" not in r.error + assert r.should_retire is True + + def test_startup_failure_returns_error_with_stderr(self): + """Codex thread/start failures during ensure_started() used to bubble + up as uncaught exceptions. Now they return a TurnResult.error so + AIAgent surfaces a clean diagnostic instead of crashing the turn.""" + client = FakeClient() + client.set_stderr_tail([ + "FATAL: model_provider 'azure_foundry' not configured", + ]) + from agent.transports.codex_app_server import CodexAppServerError + + def boom(method, params): + if method == "thread/start": + raise CodexAppServerError(code=-32603, message="Internal error") + return {} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=2.0) + assert r.error is not None + assert "startup failed" in r.error + assert "model_provider 'azure_foundry' not configured" in r.error + assert r.should_retire is True + assert r.final_text == "" + + def test_interrupt_during_turn_issues_turn_interrupt(self): + client = FakeClient() + # Don't queue turn/completed — the loop has to interrupt out + client.queue_notification( + "item/completed", + item={"type": "commandExecution", "id": "x", "command": "sleep 60", + "cwd": "/", "status": "inProgress", + "aggregatedOutput": None, "exitCode": None, + "commandActions": []}, + threadId="t", turnId="tu1", + ) + s = make_session(client) + s.ensure_started() + # Trip the interrupt before run_turn even consumes the notification. + # The loop will see interrupt set on its first iteration and bail. + s.request_interrupt() + r = s.run_turn("loop forever", turn_timeout=2.0) + assert r.interrupted is True + # turn/interrupt was requested with the right turnId + assert any( + method == "turn/interrupt" and params.get("turnId") == "turn-fake-001" + for (method, params) in client.requests + ) + + def test_deadline_exceeded_records_error(self): + client = FakeClient() + # No notifications and no completion → must hit deadline + s = make_session(client) + r = s.run_turn("never finishes", turn_timeout=0.05, + notification_poll_timeout=0.01) + assert r.interrupted is True + assert r.error and "timed out" in r.error + + def test_failed_turn_records_error_from_turn_completed(self): + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "failed", + "error": {"message": "model error"}}, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0) + assert r.error and "model error" in r.error + + +# ---- approval bridge ---- + +class TestServerRequestRouting: + def test_exec_approval_with_callback_approves_once(self): + client = FakeClient() + client.queue_server_request( + "item/commandExecution/requestApproval", request_id="req-1", + command="ls /tmp", cwd="/tmp", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + captured: dict = {} + + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + captured["description"] = description + return "once" + + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + assert captured["command"] == "ls /tmp" + # The session must have responded to the server request with "accept" + assert ("req-1", {"decision": "accept"}) in client.responses + + def test_exec_approval_no_callback_denies(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="req-1", + command="rm -rf /", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) # no approval_callback wired + s.run_turn("hi", turn_timeout=1.0) + assert ("req-1", {"decision": "decline"}) in client.responses + + def test_apply_patch_approval_session_maps_to_session_decision(self): + client = FakeClient() + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-1", + turnId="t1", + threadId="th", + startedAtMs=1234567890, + reason="create new file with hello() function", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + def cb(command, description, *, allow_permanent=True): + return "session" + + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + assert ("req-2", {"decision": "acceptForSession"}) in client.responses + + def test_unknown_server_request_replied_with_error(self): + client = FakeClient() + client.queue_server_request("totally/unknown", request_id="req-3") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert any( + rid == "req-3" and code == -32601 + for (rid, code, _msg) in client.error_responses + ) + + def test_mcp_elicitation_for_hermes_tools_auto_accepts(self): + """When codex elicits on behalf of hermes-tools (our own callback), + accept automatically — the user already opted in by enabling the + runtime.""" + client = FakeClient() + client.queue_server_request( + "mcpServer/elicitation/request", request_id="elic-1", + threadId="t", turnId="tu1", + serverName="hermes-tools", + mode="form", + message="confirm", + requestedSchema={"type": "object", "properties": {}}, + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert ("elic-1", {"action": "accept", "content": None, "_meta": None}) in client.responses + + def test_mcp_elicitation_for_other_servers_declines(self): + """For third-party MCP servers we decline by default so users + explicitly opt in through codex's own UI.""" + client = FakeClient() + client.queue_server_request( + "mcpServer/elicitation/request", request_id="elic-2", + threadId="t", turnId="tu1", + serverName="some-third-party", + mode="url", + message="please log in", + url="https://example.com/oauth", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + s.run_turn("hi", turn_timeout=1.0) + assert ("elic-2", {"action": "decline", "content": None, "_meta": None}) in client.responses + + def test_routing_auto_approve_bypass(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="r1", + command="ls", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + # No callback, but routing says auto-approve. Should approve. + s = make_session(client, request_routing=_ServerRequestRouting( + auto_approve_exec=True)) + s.run_turn("hi", turn_timeout=1.0) + assert ("r1", {"decision": "accept"}) in client.responses + + def test_callback_raises_falls_back_to_decline(self): + client = FakeClient() + client.queue_server_request("item/commandExecution/requestApproval", request_id="r1", + command="ls", cwd="/") + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + + def boom(*a, **kw): + raise RuntimeError("ui crashed") + + s = make_session(client, approval_callback=boom) + s.run_turn("hi", turn_timeout=1.0) + # Fail-closed: deny on callback exception + assert ("r1", {"decision": "decline"}) in client.responses + + +# ---- enriched approval prompts ---- + +class TestApprovalPromptEnrichment: + """Quirk #4: apply_patch prompt should show what's changing. + Quirk #10: exec prompt should never show empty cwd.""" + + def test_exec_falls_back_to_session_cwd(self): + """When codex omits cwd from the approval params, the prompt shows + the session cwd, not an empty string.""" + client = FakeClient() + client.queue_server_request( + "item/commandExecution/requestApproval", request_id="r1", + command="ls", # no cwd + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["description"] = description + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Session cwd is /tmp by default in make_session() + assert "/tmp" in captured["description"] + assert "Codex requests exec in " not in captured["description"] + + def test_apply_patch_prompt_summarizes_pending_changes(self): + """When the projector has cached the fileChange item from item/started, + the approval prompt surfaces the change summary.""" + client = FakeClient() + # item/started fires first (carries the changes), then approval request + client.queue_notification( + "item/started", + item={"type": "fileChange", "id": "fc-1", + "changes": [ + {"kind": {"type": "add"}, "path": "/tmp/new.py"}, + {"kind": {"type": "update"}, "path": "/tmp/old.py"}, + ]}, + threadId="t", turnId="tu1", + ) + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-1", turnId="tu1", threadId="t", + startedAtMs=1234567890, + reason="add and update files", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + captured["description"] = description + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Both add and update kinds should be in the summary + assert "1 add" in captured["command"] or "1 add" in captured["description"] + assert "1 update" in captured["command"] or "1 update" in captured["description"] + # And at least one of the paths + joined = captured["command"] + " " + captured["description"] + assert "/tmp/new.py" in joined or "/tmp/old.py" in joined + + def test_apply_patch_prompt_works_without_cached_summary(self): + """When approval arrives before item/started (or without changes + info), prompt falls back to whatever codex provided.""" + client = FakeClient() + client.queue_server_request( + "item/fileChange/requestApproval", request_id="req-2", + itemId="fc-orphan", turnId="tu1", threadId="t", + startedAtMs=1234567890, + reason="apply some changes", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + captured = {} + def cb(command, description, *, allow_permanent=True): + captured["command"] = command + return "once" + s = make_session(client, approval_callback=cb) + s.run_turn("hi", turn_timeout=1.0) + # Falls back to the reason + assert "apply some changes" in captured["command"] + + +# ---- openclaw beta.8 parity: retire/wedge/oauth/abort marker ---- + +class TestSessionRetirement: + """Mirrors openclaw beta.8's resilience fixes: + - retire timed-out app-server clients (should_retire on deadline) + - post-tool completion watchdog (don't burn the full deadline after a + tool result if codex goes silent) + - raw marker as terminal (don't wait for turn/completed + that never comes) + - OAuth refresh failure classification (suggest `codex login` instead + of raw RPC error strings) + - dead subprocess detection between iterations + """ + + def test_deadline_marks_session_for_retirement(self): + client = FakeClient() + s = make_session(client) + r = s.run_turn( + "never finishes", + turn_timeout=0.05, + notification_poll_timeout=0.01, + ) + assert r.interrupted is True + assert r.error and "timed out" in r.error + assert r.should_retire is True, ( + "Deadline exhaustion must signal retirement so the next turn " + "respawns codex instead of riding a wedged subprocess." + ) + + def test_completed_turn_does_not_retire(self): + client = FakeClient() + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "hi"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.should_retire is False + + def test_post_tool_quiet_watchdog_trips_and_retires(self): + client = FakeClient() + # One tool completion, then total silence — no further events, + # no turn/completed. With a tiny post_tool_quiet_timeout the + # watchdog must fire before the larger turn deadline. + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": "ex1", + "command": "echo hi", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "hi", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + s = make_session(client) + r = s.run_turn( + "tool then silence", + turn_timeout=5.0, # would be miserable to wait + notification_poll_timeout=0.02, + post_tool_quiet_timeout=0.15, + ) + assert r.interrupted is True + assert r.should_retire is True + assert r.error and "silent" in r.error + # Confirm we issued turn/interrupt to free codex compute + assert any(method == "turn/interrupt" for (method, _) in client.requests) + + def test_post_tool_watchdog_resets_on_further_activity(self): + """A tool completion followed by an agent message should NOT trip + the watchdog — further activity = codex still alive.""" + client = FakeClient() + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": "ex1", + "command": "echo hi", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "hi", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + # Non-tool activity immediately after — resets watchdog. + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", "text": "tool finished"}, + threadId="t", turnId="tu1", + ) + client.queue_notification( + "turn/completed", threadId="t", + turn={"id": "tu1", "status": "completed", "error": None}, + ) + s = make_session(client) + r = s.run_turn( + "tool then talk", turn_timeout=2.0, + notification_poll_timeout=0.01, + post_tool_quiet_timeout=0.05, + ) + # Tool ran, then text reset the watchdog, then turn/completed. + # Should NOT be a retirement case. + assert r.tool_iterations == 1 + assert r.final_text == "tool finished" + assert r.should_retire is False + assert r.interrupted is False + + def test_turn_aborted_marker_in_text_is_terminal(self): + """If codex emits `` in agent text and never sends + turn/completed, we still exit promptly instead of burning the + deadline.""" + client = FakeClient() + client.queue_notification( + "item/completed", + item={ + "type": "agentMessage", "id": "m1", + "text": "partial output... ", + }, + threadId="t", turnId="tu1", + ) + # Deliberately NO turn/completed notification queued. + s = make_session(client) + r = s.run_turn( + "abort mid-turn", turn_timeout=2.0, + notification_poll_timeout=0.01, + ) + assert r.interrupted is True + assert r.error and "turn_aborted" in r.error + # Should have exited fast — not waited for the full 2s deadline. + # (Can't measure wall clock reliably in CI; presence of the marker + # error string instead of a "timed out" message is the proxy.) + assert "timed out" not in r.error + + def test_turn_aborted_self_closing_marker_also_terminal(self): + client = FakeClient() + client.queue_notification( + "item/completed", + item={"type": "agentMessage", "id": "m1", + "text": ""}, + threadId="t", turnId="tu1", + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=2.0, + notification_poll_timeout=0.01) + assert r.interrupted is True + assert r.error and "turn_aborted" in r.error + + def test_oauth_refresh_failure_on_turn_start_suggests_login(self): + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError( + code=-32603, + message="auth refresh failed: invalid_grant", + ) + return {"thread": {"id": "t"}, + "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_oauth_failure_from_stderr_on_turn_start_failure(self): + """If the RPC error itself is opaque but stderr shows an auth + problem, we still classify it as a refresh failure.""" + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + client.set_stderr_tail([ + "[2026-05-14T10:00:00Z WARN codex_core::auth] token refresh failed", + "[2026-05-14T10:00:00Z ERROR codex_core] please log in again", + ]) + + def boom(method, params): + if method == "turn/start": + raise CodexAppServerError(code=-32603, message="rpc broke") + return {"thread": {"id": "t"}, + "activePermissionProfile": {"id": "x"}} + + client._request_handler = boom + s = make_session(client) + r = s.run_turn("hi", turn_timeout=1.0) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_oauth_failure_in_turn_completed_error(self): + """A failed turn/completed whose error mentions auth/refresh + triggers the re-auth hint + retirement.""" + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={ + "id": "tu1", "status": "failed", + "error": {"message": "401 Unauthorized: please reauthenticate"}, + }, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0, + notification_poll_timeout=0.01) + assert r.error is not None + assert "codex login" in r.error + assert r.should_retire is True + + def test_generic_turn_failure_does_not_trigger_oauth_hint(self): + """A boring model error must NOT rewrite the message into a fake + re-auth hint. Conservative classifier.""" + client = FakeClient() + client.queue_notification( + "turn/completed", threadId="t", + turn={ + "id": "tu1", "status": "failed", + "error": {"message": "rate limit exceeded"}, + }, + ) + s = make_session(client) + r = s.run_turn("x", turn_timeout=1.0, + notification_poll_timeout=0.01) + assert r.error is not None + assert "codex login" not in r.error + assert "rate limit exceeded" in r.error + # Generic model failures don't retire — the session itself is fine + assert r.should_retire is False + + def test_dead_subprocess_detected_between_iterations(self): + """If codex dies (segfault, OOM, killed by its auth refresh + thread), the inter-iteration is_alive check breaks the loop + instead of waiting on a queue that will never fill.""" + client = FakeClient() + s = make_session(client) + s.ensure_started() + # Simulate subprocess death by setting _closed (FakeClient's + # is_alive returns False when closed). + client._closed = True + client.set_stderr_tail([ + "thread 'tokio-runtime-worker' panicked at 'oauth: invalid_grant'", + ]) + r = s.run_turn("x", turn_timeout=2.0, + notification_poll_timeout=0.01) + assert r.should_retire is True + # Stderr-derived auth hint takes precedence over generic message + assert r.error and "codex login" in r.error + + +# ---- thread/start cross-fill ---- + +class TestThreadStartCrossFill: + """Mirrors openclaw beta.8's tolerance for thread.id/sessionId aliasing.""" + + def test_thread_id_under_thread_key(self): + client = FakeClient() + s = make_session(client) + tid = s.ensure_started() + assert tid == "thread-fake-001" + + def test_thread_session_id_alias_under_thread_key(self): + client = FakeClient() + client._request_handler = lambda method, params: ( + {"thread": {"sessionId": "alias-1"}, + "activePermissionProfile": {"id": "x"}} + if method == "thread/start" else + {"turn": {"id": "tu1"}} if method == "turn/start" else {} + ) + s = make_session(client) + tid = s.ensure_started() + assert tid == "alias-1" + + def test_top_level_session_id_fallback(self): + client = FakeClient() + client._request_handler = lambda method, params: ( + {"sessionId": "top-1"} if method == "thread/start" else + {"turn": {"id": "tu1"}} if method == "turn/start" else {} + ) + s = make_session(client) + tid = s.ensure_started() + assert tid == "top-1" + + def test_missing_thread_id_raises(self): + from agent.transports.codex_app_server import CodexAppServerError + + client = FakeClient() + client._request_handler = lambda method, params: ( + {"thread": {}, "activePermissionProfile": {"id": "x"}} + if method == "thread/start" else + {"turn": {"id": "tu1"}} + ) + s = make_session(client) + with pytest.raises(CodexAppServerError, match="no thread id"): + s.ensure_started() + + +class TestHasTurnAbortedMarker: + """Unit coverage for the marker matcher itself.""" + + def test_empty_string(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("") is False + assert _has_turn_aborted_marker(None) is False # type: ignore[arg-type] + + def test_plain_text_no_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("normal response with no markers") is False + + def test_open_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("blah blah") is True + + def test_self_closing_marker(self): + from agent.transports.codex_app_server_session import ( + _has_turn_aborted_marker, + ) + assert _has_turn_aborted_marker("") is True + + +class TestClassifyOAuthFailure: + """Unit coverage for the OAuth classifier; conservative on purpose.""" + + def test_invalid_grant_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("error: invalid_grant returned by server") + assert hint is not None + assert "codex login" in hint + + def test_token_refresh_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("token refresh failed: network error") + assert hint is not None + assert "codex login" in hint + + def test_401_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure("HTTP 401 Unauthorized") + assert hint is not None + + def test_generic_error_not_classified(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + assert _classify_oauth_failure("connection reset") is None + assert _classify_oauth_failure("model returned bad json") is None + assert _classify_oauth_failure("rate limit exceeded") is None + + def test_empty_inputs(self): + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + assert _classify_oauth_failure() is None + assert _classify_oauth_failure("") is None + assert _classify_oauth_failure("", None) is None # type: ignore[arg-type] + + def test_multi_string_search(self): + """Hint can come from any of the provided strings.""" + from agent.transports.codex_app_server_session import ( + _classify_oauth_failure, + ) + hint = _classify_oauth_failure( + "rpc returned -32603", + "[stderr] token has expired, run codex login", + ) + assert hint is not None diff --git a/tests/agent/transports/test_codex_event_projector.py b/tests/agent/transports/test_codex_event_projector.py new file mode 100644 index 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_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6a4cda173ad..ad70167b09f 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -100,6 +100,44 @@ 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, + ) + assert "prompt_cache_key" not in kw + assert kw.get("extra_body", {}).get("prompt_cache_key") == "conv-xai-1" + 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/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/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 4c7197ad94a..34f5cefe06e 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -71,32 +71,40 @@ 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. + + Additionally, ``_status_bar_suppressed_after_resize`` must be set + so the input rules and status bar hide until the next user input, + preventing duplicated-bar artifacts on column shrink (#19280). + """ app = MagicMock() - out = app.renderer.output events = [] - out.reset_attributes.side_effect = lambda: events.append("reset_attrs") - out.erase_screen.side_effect = lambda: events.append("erase") - out.write_raw.side_effect = lambda text: events.append(("raw", text)) - out.cursor_goto.side_effect = lambda *_: events.append("home") - out.flush.side_effect = lambda: events.append("flush") app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset") - monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + app.invalidate.side_effect = lambda: events.append("invalidate") original_on_resize = lambda: events.append("original_resize") + # bare_cli skips __init__, so seed the attribute the way __init__ would. + bare_cli._status_bar_suppressed_after_resize = False bare_cli._recover_after_resize(app, original_on_resize) assert events == [ - "reset_attrs", - "erase", - ("raw", "\x1b[3J"), - "home", - "flush", "renderer_reset", - "replay", + "invalidate", "original_resize", ] - app.invalidate.assert_not_called() + # Must NOT clear the screen or scrollback — those destroy the banner. + app.renderer.output.erase_screen.assert_not_called() + app.renderer.output.write_raw.assert_not_called() + app.renderer.output.cursor_goto.assert_not_called() + # Status bar / input rules must be suppressed until the next prompt. + assert bare_cli._status_bar_suppressed_after_resize is True def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli): app = MagicMock() diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 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.""" 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" 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/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 16e6699aaac..47bd68aa25d 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -332,6 +332,45 @@ 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_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 + # (avoids negative ``'─' * (w - 2)`` math). + assert HermesCLI._scrollback_box_width(20) == 32 + assert HermesCLI._scrollback_box_width(32) == 32 + # 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() cli_obj._agent_running = True diff --git a/tests/cli/test_cprint_bg_thread.py b/tests/cli/test_cprint_bg_thread.py index bb0e59d064e..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): @@ -258,10 +260,35 @@ 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_chat_console_records_rich_ansi_for_resize_replay(monkeypatch): + cli._configure_output_history(True, 10) + monkeypatch.setattr(cli, "_pt_print", lambda *_args, **_kwargs: None) + + cli.ChatConsole().print("[bold red]Hello[/]") + + assert cli._OUTPUT_HISTORY + assert any("\x1b[" in line for line in cli._OUTPUT_HISTORY) + + def test_suspend_output_history_blocks_recording(): cli._configure_output_history(True, 10) diff --git a/tests/conftest.py b/tests/conftest.py index 5d7f197f195..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", @@ -476,12 +475,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/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/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/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_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) diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c59b27d8001..cf197bd6f7f 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() @@ -372,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_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_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 diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 91b23bd8602..c69af3e7781 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"): @@ -81,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): @@ -88,6 +100,23 @@ def adapter(monkeypatch): monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False) + # Clear DISCORD_* env vars the test file exercises so tests don't leak + # process-env state from the contributor's shell into per-test behaviour. + # Individual tests still monkeypatch.setenv() for their own scenarios. + for _var in ( + "DISCORD_REQUIRE_MENTION", + "DISCORD_THREAD_REQUIRE_MENTION", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_AUTO_THREAD", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_HISTORY_BACKFILL", + "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_ALLOW_BOTS", + ): + monkeypatch.delenv(_var, raising=False) + config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) @@ -111,6 +140,48 @@ def make_message(*, channel, content: str, mentions=None, msg_type=None): ) +def make_history_message( + *, + author, + content: str, + msg_id: int, + msg_type=None, + attachments=None, +): + return SimpleNamespace( + id=msg_id, + author=author, + content=content, + attachments=list(attachments or []), + type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default, + ) + + +class FakeHistoryChannel(FakeTextChannel): + def __init__(self, history_messages, **kwargs): + super().__init__(**kwargs) + self._history_messages = list(history_messages) + + def history(self, *, limit, before, after=None, oldest_first=None): + before_id = int(getattr(before, "id", before)) + after_id = int(getattr(after, "id", after)) if after is not None else None + if oldest_first is None: + oldest_first = after is not None + + messages = [ + message for message in self._history_messages + if int(message.id) < before_id + and (after_id is None or int(message.id) > after_id) + ] + messages.sort(key=lambda message: int(message.id), reverse=not oldest_first) + + async def _iter(): + for message in messages[:limit]: + yield message + + return _iter() + + @pytest.mark.asyncio async def test_discord_defaults_to_require_mention(adapter, monkeypatch): """Default behavior: require @mention in server channels.""" @@ -446,6 +517,37 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t assert event.source.chat_type == "group" +@pytest.mark.asyncio +async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypatch): + """Free-response channels should reply inline, never spawn a new thread. + + Without this, every message in a free-response channel would auto-create + a fresh thread (since the channel bypasses the @mention gate, every + message looks like a fresh trigger). That turns a "lightweight chat" + channel into a thread-spawning machine — see the docs at + website/docs/user-guide/messaging/discord.md which already describe + this as the intended behavior. + """ + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789") + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) # default true + + adapter._auto_create_thread = AsyncMock() + + message = make_message( + channel=FakeTextChannel(channel_id=789), + content="casual chat in free-response channel", + ) + + await adapter._handle_message(message) + + adapter._auto_create_thread.assert_not_awaited() + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "casual chat in free-response channel" + assert event.source.chat_type == "group" + + @pytest.mark.asyncio @@ -463,3 +565,322 @@ async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_default_keeps_responding_after_participation(adapter, monkeypatch): + """Default behavior: once the bot is in a thread, it auto-responds without @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + + thread = FakeThread(channel_id=456, name="follow-up") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="follow-up without mention") + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_gates_followups(adapter, monkeypatch): + """When thread_require_mention=true, even bot-participated threads need @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="ambient chatter — not for me") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_still_responds_when_mentioned(adapter, monkeypatch): + """thread_require_mention=true still lets explicit @mentions through in threads.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + bot_user = adapter._client.user + + message = make_message( + channel=thread, + content=f"<@{bot_user.id}> hey, this one's for you", + mentions=[bot_user], + ) + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypatch): + """thread_require_mention can also be set via config.extra (yaml).""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + adapter.config.extra["thread_require_mention"] = True + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + + message = make_message(channel=thread, content="ambient — should be ignored") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + + +@pytest.mark.asyncio +async def test_fetch_channel_context_stops_at_self_message_and_reverses_to_chronological_order(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + old_human = SimpleNamespace(id=57, display_name="Bob", name="Bob", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="latest human note", msg_id=4), + make_history_message(author=other_bot, content="latest bot note", msg_id=3), + make_history_message(author=adapter._client.user, content="our prior response", msg_id=2), + make_history_message(author=old_human, content="older than boundary", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == ( + "[Recent channel messages]\n" + "[Gemini [bot]] latest bot note\n" + "[Alice] latest human note" + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_context_skips_other_bots_when_allow_bots_none(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="human note", msg_id=3), + make_history_message(author=other_bot, content="bot note", msg_id=2), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == "[Recent channel messages]\n[Alice] human note" + + +@pytest.mark.asyncio +async def test_fetch_channel_context_uses_cache_to_narrow_window(adapter, monkeypatch): + """When _last_self_message_id is cached, the fetch passes after= to skip old messages.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + # Record the after= arg passed to history() + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=200)], + channel_id=777, + ) + + # Seed the cache — bot's last message in this channel was ID 100 + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 # trigger is newer than cache + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Verify cache was used: after= should be set (not None) + assert recorded_after["value"] is not None + + +@pytest.mark.asyncio +async def test_fetch_channel_context_cache_uses_latest_window_when_after_set(adapter, monkeypatch): + """Regression: discord.py defaults oldest_first=True when after= is provided. + + The hot cache path passes both after= and before=. We still want the latest + messages before the trigger, not the earliest messages after our prior + response, otherwise tool traces can crowd out the final answer. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 3 + + codex = SimpleNamespace(id=56, display_name="Codex", name="Codex", bot=True) + human = SimpleNamespace(id=57, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=codex, content="old tool trace 1", msg_id=101), + make_history_message(author=codex, content="old tool trace 2", msg_id=102), + make_history_message(author=codex, content="old tool trace 3", msg_id=103), + make_history_message(author=codex, content="final analysis", msg_id=104), + make_history_message(author=human, content="latest follow-up", msg_id=105), + ], + channel_id=777, + ) + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 200 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert "[Codex [bot]] final analysis" in result + assert "[Alice] latest follow-up" in result + assert "old tool trace 1" not in result + assert "old tool trace 2" not in result + + +@pytest.mark.asyncio +async def test_fetch_channel_context_ignores_stale_cache(adapter, monkeypatch): + """If cached ID is >= trigger ID (stale/future), fall back to cold-start scan.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=50)], + channel_id=777, + ) + + # Cache has a NEWER ID than the trigger — stale/invalid + adapter._last_self_message_id["777"] = "500" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Cache should have been ignored — after= should be None + assert recorded_after["value"] is None + + +@pytest.mark.asyncio +async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = False + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_per_user_channel_backfills_too(adapter, monkeypatch): + """Per-user sessions also benefit from backfill: Alice's session is missing + other-channel-participants' context and her own pre-mention messages.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = True + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_dm_does_not_backfill(adapter, monkeypatch): + """DMs skip backfill — every DM triggers the bot, so there's no mention gap.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + dm_channel = SimpleNamespace( + id=999, + name=None, + guild=None, + topic=None, + ) + # Make isinstance(channel, discord.DMChannel) return True + monkeypatch.setattr( + discord_platform.discord, "DMChannel", type(dm_channel), raising=False, + ) + + message = make_message( + channel=dm_channel, + content="hello in DM", + mentions=[], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_not_awaited() + if adapter.handle_message.await_args is not None: + event = adapter.handle_message.await_args.args[0] + assert event.channel_context is None + + diff --git a/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py index 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 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/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/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): 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/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 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/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/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..3e1d4cf71e8 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/tests/gateway/test_telegram_model_picker.py @@ -43,6 +43,109 @@ def _make_adapter(): class TestTelegramModelPicker: + @pytest.mark.asyncio + async def test_send_model_picker_escapes_dynamic_provider_label(self): + adapter = _make_adapter() + sent = {} + + async def mock_send_message(**kwargs): + sent.update(kwargs) + return SimpleNamespace(message_id=101) + + adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) + + result = await adapter.send_model_picker( + chat_id="12345", + providers=[ + {"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True} + ], + current_model="model_1", + current_provider="provider_one", + session_key="s", + on_model_selected=AsyncMock(), + metadata={"thread_id": "99999"}, + ) + + assert result.success is True + assert "MARKDOWN_V2" in repr(sent["parse_mode"]) + assert "provider\\_one" in sent["text"] + assert "`model_1`" in sent["text"] + + @pytest.mark.asyncio + async def test_back_button_escapes_dynamic_provider_label(self): + adapter = _make_adapter() + adapter._model_picker_state["12345"] = { + "providers": [{"slug": "provider_one", "name": "Provider One", "total_models": 1, "is_current": True}], + "current_model": "model_1", + "current_provider": "provider_one", + "session_key": "s", + "on_model_selected": AsyncMock(), + "msg_id": 42, + } + + query = AsyncMock() + query.data = "mb" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + await adapter._handle_model_picker_callback(query, "mb", "12345") + + edit_kwargs = query.edit_message_text.call_args[1] + assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) + assert "provider\\_one" in edit_kwargs["text"] + assert "`model_1`" in edit_kwargs["text"] + + @pytest.mark.asyncio + async def test_model_selected_edits_message_on_success(self): + """Regression: the mm: (model selected → switch) success path must + edit the picker message to show the confirmation and remove the + buttons. An earlier revision of this PR over-indented the + edit_message_text block so it lived inside the except branch and + only fired when the callback raised.""" + adapter = _make_adapter() + callback = AsyncMock(return_value="Switched to `gpt-5`") + adapter._model_picker_state["12345"] = { + "providers": [ + {"slug": "openai", "name": "OpenAI", "total_models": 1, "is_current": True} + ], + "current_model": "model_1", + "current_provider": "openai", + "session_key": "s", + "on_model_selected": callback, + "selected_provider": "openai", + "model_list": ["gpt-5"], + "msg_id": 42, + } + + query = AsyncMock() + query.data = "mm:0" + query.message = MagicMock() + query.message.chat_id = 12345 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + await adapter._handle_model_picker_callback(query, "mm:0", "12345") + + # The callback was invoked with the selected model + callback.assert_awaited_once() + # edit_message_text MUST be called on the success path (this is the + # regression we're guarding). + query.edit_message_text.assert_awaited() + edit_kwargs = query.edit_message_text.call_args[1] + assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"]) + # The dynamic result text was routed through format_message + # (backtick code blocks survive escaping). + assert "`gpt-5`" in edit_kwargs["text"] + # State is cleaned up after a successful switch. + assert "12345" not in adapter._model_picker_state + @pytest.mark.asyncio async def test_retries_without_thread_when_thread_not_found(self): adapter = _make_adapter() diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index 27c96ad4b2c..d8a2672f4d6 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,60 @@ 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"}, + ] + first_followup_turn = [ + {"role": "user", "content": "First follow-up question"}, + {"role": "assistant", "content": "First follow-up answer"}, + ] + second_followup_turn = [ + {"role": "user", "content": "Second follow-up question"}, + {"role": "assistant", "content": "Second follow-up answer"}, + ] + + current_result = { + "history_offset": len(history_before_chain), + "messages": history_before_chain + first_followup_turn, + } + followup_result = { + "history_offset": len(history_before_chain + first_followup_turn), + "messages": ( + history_before_chain + + first_followup_turn + + second_followup_turn + ), + } + + merged = _preserve_queued_followup_history_offset( + current_result, + followup_result, + ) + assert merged["history_offset"] == len(history_before_chain) + + persisted = merged["messages"][merged["history_offset"]:] + assert persisted == first_followup_turn + second_followup_turn + + def test_recursive_queued_followup_preserves_smaller_existing_offset(self): + """Do not widen the slice if the nested result is already conservative.""" + current_result = {"history_offset": 4} + followup_result = {"history_offset": 3, "messages": []} + + merged = _preserve_queued_followup_history_offset( + current_result, + followup_result, + ) + + assert merged["history_offset"] == 3 diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 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 # --------------------------------------------------------------------------- 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] 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) # ============================================================================= 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/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/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py new file mode 100644 index 00000000000..ebdc9f9ae6b --- /dev/null +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -0,0 +1,865 @@ +"""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, + 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, +) + + +# ---- per-server translation ---- + +class TestTranslateOneServer: + def test_stdio_basic(self): + cfg, skipped = _translate_one_server("filesystem", { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"}, + }) + assert cfg == { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"FOO": "bar"}, + } + assert skipped == [] + + def test_stdio_with_cwd(self): + cfg, _ = _translate_one_server("custom", { + "command": "/usr/bin/myserver", + "cwd": "/var/lib/mcp", + }) + assert cfg["cwd"] == "/var/lib/mcp" + + def test_http_basic(self): + cfg, skipped = _translate_one_server("api", { + "url": "https://x.example/mcp", + "headers": {"Authorization": "Bearer abc"}, + }) + assert cfg == { + "url": "https://x.example/mcp", + "http_headers": {"Authorization": "Bearer abc"}, + } + assert skipped == [] + + def test_sse_falls_under_streamable_http_with_warning(self): + cfg, skipped = _translate_one_server("sse_server", { + "url": "http://localhost:8000/sse", + "transport": "sse", + }) + assert cfg["url"] == "http://localhost:8000/sse" + assert any("sse" in s.lower() for s in skipped) + + def test_timeouts_translate(self): + cfg, _ = _translate_one_server("x", { + "command": "y", + "timeout": 180, + "connect_timeout": 30, + }) + assert cfg["tool_timeout_sec"] == 180.0 + assert cfg["startup_timeout_sec"] == 30.0 + + def test_non_numeric_timeout_skipped(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "timeout": "not-a-number", + }) + assert "tool_timeout_sec" not in cfg + assert any("timeout" in s and "numeric" in s for s in skipped) + + def test_disabled_server_emits_enabled_false(self): + cfg, _ = _translate_one_server("x", { + "command": "y", + "enabled": False, + }) + assert cfg["enabled"] is False + + def test_enabled_true_omitted(self): + cfg, _ = _translate_one_server("x", {"command": "y", "enabled": True}) + assert "enabled" not in cfg # codex defaults to true + + def test_command_and_url_prefers_stdio_warns(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", "url": "http://z", + }) + assert "command" in cfg + assert "url" not in cfg + assert any("url" in s for s in skipped) + + def test_no_transport_returns_none(self): + cfg, skipped = _translate_one_server("broken", {"description": "x"}) + assert cfg is None + assert "no command or url" in skipped[0] + + def test_sampling_dropped_with_warning(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "sampling": {"enabled": True, "model": "gemini-3-flash"}, + }) + assert "sampling" not in cfg + assert any("sampling" in s for s in skipped) + + def test_unknown_keys_warned(self): + cfg, skipped = _translate_one_server("x", { + "command": "y", + "totally_made_up_key": "value", + }) + assert "totally_made_up_key" not in cfg + assert any("totally_made_up_key" in s for s in skipped) + + def test_non_dict_input(self): + cfg, skipped = _translate_one_server("x", "notadict") # type: ignore[arg-type] + assert cfg is None + + +# ---- TOML rendering ---- + +class TestTomlValueFormatter: + def test_string_quoted(self): + assert _format_toml_value("hello") == '"hello"' + + def test_string_with_quotes_escaped(self): + assert _format_toml_value('a"b') == '"a\\"b"' + + def test_bool(self): + assert _format_toml_value(True) == "true" + assert _format_toml_value(False) == "false" + + def test_int(self): + assert _format_toml_value(42) == "42" + + def test_float(self): + assert _format_toml_value(180.0) == "180.0" + + def test_list_of_strings(self): + assert _format_toml_value(["a", "b"]) == '["a", "b"]' + + def test_inline_table(self): + out = _format_toml_value({"FOO": "bar"}) + assert out == '{ FOO = "bar" }' + + def test_empty_inline_table(self): + assert _format_toml_value({}) == "{}" + + def test_string_with_newline_escaped(self): + """TOML basic strings don't allow literal newlines — a path or + env var containing a newline must use \\n. Otherwise codex would + refuse to load the config.""" + out = _format_toml_value("line one\nline two") + assert "\n" not in out # no raw newline in output + assert "\\n" in out + + def test_string_with_tab_escaped(self): + out = _format_toml_value("col1\tcol2") + assert "\t" not in out + assert "\\t" in out + + def test_string_with_other_controls_escaped(self): + for raw, expected in [ + ("\r", "\\r"), + ("\f", "\\f"), + ("\b", "\\b"), + ]: + out = _format_toml_value(f"x{raw}y") + assert raw not in out, f"{raw!r} should be escaped" + assert expected in out, f"{expected!r} should be in output" + + def test_windows_path_escaped_correctly(self): + out = _format_toml_value(r"C:\Users\Alice\.codex") + # Each backslash should be doubled + assert out == r'"C:\\Users\\Alice\\.codex"' + + def test_atomic_write_no_temp_leak_on_success(self, tmp_path): + """The atomic-write path uses tempfile.mkstemp + rename. On + success the temp file should not be left behind.""" + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + expose_hermes_tools=False, + default_permission_profile=None) + # config.toml should exist + assert (tmp_path / "config.toml").exists() + # And no .config.toml.* temp files left behind + leftover = [p.name for p in tmp_path.iterdir() + if p.name.startswith(".config.toml.")] + assert leftover == [], f"temp file leaked after migration: {leftover}" + + def test_atomic_write_cleanup_on_rename_failure(self, tmp_path, monkeypatch): + """If rename fails partway through (out of disk, permissions, + crash), the temp file must be cleaned up. Otherwise repeated + failed migrations would pile up .config.toml.* files.""" + from pathlib import Path as _Path + original_replace = _Path.replace + + def failing_replace(self, target): + raise OSError("simulated disk full") + + monkeypatch.setattr(_Path, "replace", failing_replace) + report = migrate( + {"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + expose_hermes_tools=False, + default_permission_profile=None, + ) + # Error surfaced + assert any("simulated disk full" in e for e in report.errors) + # And no leaked temp file + leftover = [p.name for p in tmp_path.iterdir() + if p.name.startswith(".config.toml.")] + assert leftover == [], f"temp files leaked: {leftover}" + + def test_unsupported_type_raises(self): + with pytest.raises(ValueError): + _format_toml_value(object()) + + +class TestRenderToml: + def test_starts_with_marker(self): + out = render_codex_toml_section({}) + assert out.startswith(MIGRATION_MARKER) + + def test_empty_servers_emits_placeholder(self): + out = render_codex_toml_section({}) + assert "no MCP servers" in out + + def test_servers_sorted_alphabetically(self): + out = render_codex_toml_section({ + "zoo": {"command": "z"}, + "alpha": {"command": "a"}, + "middle": {"command": "m"}, + }) + # Find the section header positions and confirm order + a_pos = out.find("[mcp_servers.alpha]") + m_pos = out.find("[mcp_servers.middle]") + z_pos = out.find("[mcp_servers.zoo]") + assert 0 < a_pos < m_pos < z_pos + + def test_server_with_args_and_env(self): + out = render_codex_toml_section({ + "fs": { + "command": "npx", + "args": ["-y", "filesystem"], + "env": {"PATH": "/usr/bin"}, + } + }) + assert "[mcp_servers.fs]" in out + assert 'command = "npx"' in out + assert 'args = ["-y", "filesystem"]' in out + # Env emitted as inline table + assert 'env = { PATH = "/usr/bin" }' in out + + +# ---- existing-block stripping ---- + +class TestStripExistingManagedBlock: + def test_no_managed_block_unchanged(self): + text = "[other]\nfoo = 1\n" + assert _strip_existing_managed_block(text) == text + + def test_strips_managed_block_alone(self): + text = ( + f"{MIGRATION_MARKER}\n" + "\n" + "[mcp_servers.fs]\n" + 'command = "npx"\n' + ) + assert _strip_existing_managed_block(text).strip() == "" + + def test_preserves_user_content_above_managed_block(self): + text = ( + "[model]\n" + 'name = "gpt-5.5"\n' + "\n" + f"{MIGRATION_MARKER}\n" + "[mcp_servers.fs]\n" + 'command = "x"\n' + ) + out = _strip_existing_managed_block(text) + assert "[model]" in out + assert 'name = "gpt-5.5"' in out + assert "mcp_servers.fs" not in out + + def test_preserves_unrelated_section_after_managed_block(self): + text = ( + f"{MIGRATION_MARKER}\n" + "[mcp_servers.fs]\n" + 'command = "x"\n' + "\n" + "[providers]\n" + 'foo = "bar"\n' + ) + out = _strip_existing_managed_block(text) + assert "mcp_servers.fs" not in out + assert "[providers]" in out + assert 'foo = "bar"' in out + + +# ---- end-to-end migrate(, expose_hermes_tools=False) ---- + +class TestMigrate: + def test_no_servers_no_plugins_no_perms_writes_placeholder(self, tmp_path): + report = migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + assert MIGRATION_MARKER in text + assert "no MCP servers" in text or "no MCP servers, plugins, or permissions" in text + + def test_no_servers_still_writes_permissions_default(self, tmp_path): + """Even with zero MCP servers, enabling the runtime should write the + default permissions profile so users don't get prompted on every + write attempt. This is the fix for quirk #2.""" + report = migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + # Codex's schema: top-level `default_permissions` keying a built-in + # profile name (prefixed with ":"). NOT a [permissions] section + # (which is for *user-defined* profiles with structured fields). + assert 'default_permissions = ":workspace"' in text + assert report.wrote_permissions_default == ":workspace" + + def test_explicit_none_permissions_skips_block(self, tmp_path): + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, expose_hermes_tools=False) + text = (tmp_path / "config.toml").read_text() + assert "default_permissions" not in text + assert "[permissions]" not in text + assert report.wrote_permissions_default is None + + def test_plugin_discovery_writes_plugin_blocks(self, tmp_path, monkeypatch): + """Discovered curated plugins land as [plugins."@"] + blocks. This is what OpenClaw calls 'migrate native codex plugins.'""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + def fake_query(codex_home=None, timeout=8.0): + return [ + {"name": "google-calendar", "marketplace": "openai-curated", + "enabled": True}, + {"name": "github", "marketplace": "openai-curated", + "enabled": True}, + ], None + monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query) + + report = migrate({}, codex_home=tmp_path, discover_plugins=True) + text = (tmp_path / "config.toml").read_text() + assert '[plugins."github@openai-curated"]' in text + assert '[plugins."google-calendar@openai-curated"]' in text + assert "enabled = true" in text + assert "google-calendar@openai-curated" in report.migrated_plugins + assert "github@openai-curated" in report.migrated_plugins + + def test_plugin_discovery_skips_unavailable_plugins(self): + """Plugins where codex reports availability != AVAILABLE should + be skipped — they're broken/uninstallable on codex's side, so + migrating them would write config that fails at activation + time. Cf. openclaw#80815.""" + from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins + from unittest.mock import patch + + # Fake a plugin/list response where one plugin is unavailable + fake_response = { + "marketplaces": [{ + "name": "openai-curated", + "plugins": [ + {"name": "good-plugin", "installed": True, + "enabled": True, "availability": "AVAILABLE"}, + {"name": "broken-plugin", "installed": True, + "enabled": True, "availability": "UNAVAILABLE"}, + {"name": "auth-pending", "installed": True, + "enabled": True, "availability": "REQUIRES_AUTH"}, + # Plugin without availability field — pass through + # (older codex versions or marketplaces that don't + # set it should still work). + {"name": "legacy-plugin", "installed": True, + "enabled": True}, + ] + }] + } + + class FakeClient: + def __init__(self, **kw): pass + def initialize(self, **kw): pass + def request(self, method, params, timeout=None): + return fake_response + def close(self): pass + def __enter__(self): return self + def __exit__(self, *a): pass + + with patch("agent.transports.codex_app_server.CodexAppServerClient", + FakeClient): + plugins, err = _query_codex_plugins() + + assert err is None + names = [p["name"] for p in plugins] + assert "good-plugin" in names + assert "legacy-plugin" in names # no field → don't skip + assert "broken-plugin" not in names + assert "auth-pending" not in names + + def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch): + """If codex isn't installed or RPC fails, MCP migration still + completes. The error surfaces in the report but doesn't abort.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + def fake_query_fails(codex_home=None, timeout=8.0): + return [], "codex CLI not available" + monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query_fails) + + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False) + assert report.written + assert report.migrated == ["x"] + assert report.plugin_query_error == "codex CLI not available" + assert report.migrated_plugins == [] + + def test_discover_plugins_false_skips_query(self, tmp_path, monkeypatch): + """Tests and restricted environments can opt out of the subprocess + spawn entirely.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + called = {"yes": False} + def boom(*a, **kw): + called["yes"] = True + return [], None + monkeypatch.setattr(crpm, "_query_codex_plugins", boom) + + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False) + assert called["yes"] is False + + def test_dry_run_skips_plugin_query(self, tmp_path, monkeypatch): + """Dry run should never spawn codex. Even with discover_plugins=True + the query is skipped because dry_run takes precedence.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + called = {"yes": False} + def boom(*a, **kw): + called["yes"] = True + return [], None + monkeypatch.setattr(crpm, "_query_codex_plugins", boom) + + migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, dry_run=True, discover_plugins=True, expose_hermes_tools=False) + assert called["yes"] is False + + def test_re_run_replaces_plugin_block(self, tmp_path, monkeypatch): + """Plugin blocks are managed and re-runs should replace them + cleanly — same idempotency contract as MCP servers.""" + from hermes_cli import codex_runtime_plugin_migration as crpm + + # First run: only github + monkeypatch.setattr(crpm, "_query_codex_plugins", + lambda codex_home=None, timeout=8.0: ( + [{"name": "github", "marketplace": "openai-curated", "enabled": True}], + None, + )) + migrate({}, codex_home=tmp_path, discover_plugins=True, + default_permission_profile=None, expose_hermes_tools=False) + first = (tmp_path / "config.toml").read_text() + assert "github@openai-curated" in first + + # Second run: only canva (github went away) + monkeypatch.setattr(crpm, "_query_codex_plugins", + lambda codex_home=None, timeout=8.0: ( + [{"name": "canva", "marketplace": "openai-curated", "enabled": True}], + None, + )) + migrate({}, codex_home=tmp_path, discover_plugins=True, + default_permission_profile=None, expose_hermes_tools=False) + second = (tmp_path / "config.toml").read_text() + assert "github@openai-curated" not in second + assert "canva@openai-curated" in second + + def test_expose_hermes_tools_writes_callback_mcp_entry(self, tmp_path): + """When expose_hermes_tools=True (production default), an + [mcp_servers.hermes-tools] entry is written so codex calls back + into Hermes for browser/web/delegate_task/vision/memory tools. + + This is the fix for 'all other tools that codex doesn't provide + should be useable by hermes' — quirk #7.""" + report = migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, + expose_hermes_tools=True) + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.hermes-tools]" in text + assert "hermes_tools_mcp_server" in text + # Must include startup + tool timeouts so codex doesn't give up + assert "startup_timeout_sec" in text + assert "tool_timeout_sec" in text + # And the entry is reported + assert "hermes-tools" in report.migrated + + def test_expose_hermes_tools_disabled_skips_entry(self, tmp_path): + """expose_hermes_tools=False suppresses the callback registration.""" + migrate({}, codex_home=tmp_path, + discover_plugins=False, + default_permission_profile=None, + expose_hermes_tools=False) + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.hermes-tools]" not in text + assert "hermes_tools_mcp_server" not in text + + def test_dry_run_doesnt_write(self, tmp_path): + report = migrate({"mcp_servers": {"x": {"command": "y"}}}, + codex_home=tmp_path, dry_run=True, expose_hermes_tools=False) + assert report.dry_run is True + assert not (tmp_path / "config.toml").exists() + assert "x" in report.migrated + + def test_full_migration_round_trip(self, tmp_path): + hermes_cfg = { + "mcp_servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + }, + "github": { + "url": "https://api.github.com/mcp", + "headers": {"Authorization": "Bearer x"}, + }, + } + } + report = migrate(hermes_cfg, codex_home=tmp_path, expose_hermes_tools=False) + assert report.written + text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.filesystem]" in text + assert "[mcp_servers.github]" in text + assert 'command = "npx"' in text + assert 'url = "https://api.github.com/mcp"' in text + + def test_idempotent_re_run_replaces_managed_block(self, tmp_path): + # First migration + migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False) + first_text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.a]" in first_text + # Second migration with different servers + migrate({"mcp_servers": {"b": {"command": "y"}}}, codex_home=tmp_path, expose_hermes_tools=False) + second_text = (tmp_path / "config.toml").read_text() + assert "[mcp_servers.a]" not in second_text + assert "[mcp_servers.b]" in second_text + + def test_preserves_user_codex_config_above_marker(self, tmp_path): + target = tmp_path / "config.toml" + target.write_text( + "[model]\n" + 'profile = "default"\n' + "\n" + "[providers.openai]\n" + 'api_key = "sk-test"\n' + ) + migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False) + new_text = target.read_text() + # User's codex config preserved + assert "[model]" in new_text + assert 'profile = "default"' in new_text + assert "[providers.openai]" in new_text + # And new MCP block 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 + 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 + + +# ---- 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 new file mode 100644 index 00000000000..7bf1a59e1e7 --- /dev/null +++ b/tests/hermes_cli/test_codex_runtime_switch.py @@ -0,0 +1,238 @@ +"""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) + + # 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")), \ + 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" + assert r.old_value == "auto" + assert r.requires_new_session is True + assert "via MCP" in r.message # hermes-tools callback message + assert cfg["model"]["openai_runtime"] == "codex_app_server" + assert persisted["model"]["openai_runtime"] == "codex_app_server" + + def test_disable_does_not_check_binary(self): + cfg = {"model": {"openai_runtime": "codex_app_server"}} + with patch.object(crs, "check_codex_binary_ok") as bin_check: + r = crs.apply(cfg, "auto") + assert r.success + # Binary check is irrelevant when disabling — should not be called + # with the codex_app_server enable-gate signature. + assert r.new_value == "auto" + assert r.old_value == "codex_app_server" + + def test_persist_callback_failure_reported(self): + cfg = {} + + def persist_boom(c): + raise IOError("disk full") + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")): + r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom) + assert r.success is False + assert "persist failed" in r.message + assert "disk full" in r.message + + def test_enable_triggers_mcp_migration(self): + """Enabling codex_app_server should auto-migrate Hermes mcp_servers + to ~/.codex/config.toml so the spawned subprocess sees them.""" + cfg = { + "mcp_servers": { + "filesystem": {"command": "npx", "args": ["-y", "fs-server"]}, + } + } + + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig: + mig.return_value.migrated = ["filesystem", "hermes-tools"] + mig.return_value.migrated_plugins = [] + mig.return_value.plugin_query_error = None + mig.return_value.wrote_permissions_default = ":workspace" + mig.return_value.errors = [] + mig.return_value.target_path = "/fake/.codex/config.toml" + r = crs.apply(cfg, "codex_app_server") + assert r.success + assert mig.called # migration was triggered + # User MCP servers are reported (excluding internal hermes-tools) + assert "Migrated 1 MCP server" in r.message + assert "filesystem" in r.message + # Permissions default surfaces + assert "Default sandbox: :workspace" in r.message + # Hermes tool callback announcement + assert "via MCP" in r.message + + def test_disable_does_not_trigger_migration(self): + """Switching back to auto must not write to ~/.codex/.""" + cfg = { + "model": {"openai_runtime": "codex_app_server"}, + "mcp_servers": {"x": {"command": "y"}}, + } + with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig: + r = crs.apply(cfg, "auto") + assert r.success + assert not mig.called # disabling does not migrate + + def test_migration_failure_does_not_block_enable(self): + """If MCP migration raises, the runtime change still proceeds — + users can manually re-run migration later.""" + cfg = {"mcp_servers": {"x": {"command": "y"}}} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")), \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate", + side_effect=RuntimeError("disk full")): + r = crs.apply(cfg, "codex_app_server") + assert r.success # change still applied + assert r.new_value == "codex_app_server" + assert "MCP migration skipped" in r.message + assert "disk full" in r.message + + def test_binary_check_cached_within_apply(self): + """check_codex_binary_ok is invoked at most once per apply() call. + + The enable path has three sites that need the version (state report, + enable gate, success message). Without caching, a single + /codex-runtime invocation spawns `codex --version` three times. + Regression guard against a refactor that drops the cache. + """ + cfg = {} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")) as bin_check, \ + patch("hermes_cli.codex_runtime_plugin_migration.migrate"): + r = crs.apply(cfg, "codex_app_server") + assert r.success + assert bin_check.call_count == 1, ( + f"check_codex_binary_ok was called {bin_check.call_count} time(s); " + "should be cached and called exactly once per apply()" + ) + + def test_binary_check_cached_on_read_only_call(self): + """Read-only call (new_value=None) calls the binary check exactly + once and reuses the result for the message.""" + cfg = {"model": {"openai_runtime": "codex_app_server"}} + with patch.object(crs, "check_codex_binary_ok", + return_value=(True, "0.130.0")) as bin_check: + crs.apply(cfg, None) + assert bin_check.call_count == 1 diff --git a/tests/hermes_cli/test_completion.py b/tests/hermes_cli/test_completion.py index 20bde059f2e..2c4e6592c62 100644 --- a/tests/hermes_cli/test_completion.py +++ b/tests/hermes_cli/test_completion.py @@ -140,6 +140,54 @@ 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_preserves_valid_zsh_arguments_alias_syntax(self): + out = generate_zsh(_make_parser()) + 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(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()) + 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 -D; source {path}; [[ ${{_comps[hermes]}} == _hermes ]]", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + finally: + os.unlink(path) + # --------------------------------------------------------------------------- # 4. Fish output 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_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 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_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/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 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() 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 ────────────────────────────────────────────────────── 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) # =================================================================== 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/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.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_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/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)) diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6c23824b9e5..1ed7e35323b 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.spinner.get("waiting_faces", []) == [] + assert skin.tool_emojis == {} + assert skin.tool_prefix == "!" + def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch): from hermes_cli.skin_engine import list_skins skins_dir = tmp_path / "skins" diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 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/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/hermes_cli/test_video_gen_picker.py b/tests/hermes_cli/test_video_gen_picker.py new file mode 100644 index 00000000000..c06e2ea2096 --- /dev/null +++ b/tests/hermes_cli/test_video_gen_picker.py @@ -0,0 +1,237 @@ +"""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 + + +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 diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 95180b2dce3..b6530db9f84 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,25 @@ class TestResolveConfigPath: result = resolve_config_path() assert result == local_cfg - def test_falls_back_to_global_when_no_local(self, tmp_path): - hermes_home = tmp_path / "hermes" - hermes_home.mkdir() - # No honcho.json in HERMES_HOME — also isolate ~/.hermes so - # the default-profile fallback doesn't hit the real filesystem. + def test_falls_back_to_default_profile_when_no_local(self, tmp_path, monkeypatch): + # Profile mode: HERMES_HOME points at ~/.hermes/profiles/, so + # _get_default_hermes_home() must resolve back to ~/.hermes — that's + # the bug the HOME-anchored helper fixes (vs. blindly using Path.home()). fake_home = tmp_path / "fakehome" fake_home.mkdir() + default_home = fake_home / ".hermes" + profile_home = default_home / "profiles" / "work" + profile_home.mkdir(parents=True) + default_cfg = default_home / "honcho.json" + default_cfg.write_text('{"apiKey": "default-key"}') - with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ - patch.object(Path, "home", return_value=fake_home): - result = resolve_config_path() - assert result == fake_home / ".honcho" / "config.json" + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + result = resolve_config_path() + + assert _get_default_hermes_home() == default_home + assert result == default_cfg def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): fake_home = tmp_path / "fakehome" @@ -383,6 +392,28 @@ 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): + # Profile mode: from_global_config() reads the default-profile honcho.json + # via the HOME-anchored helper, not Path.home() / ".hermes". + fake_home = tmp_path / "fakehome" + fake_home.mkdir() + default_home = fake_home / ".hermes" + profile_home = default_home / "profiles" / "work" + profile_home.mkdir(parents=True) + default_cfg = default_home / "honcho.json" + default_cfg.write_text(json.dumps({ + "apiKey": "default-key", + "workspace": "default-ws", + })) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + config = HonchoClientConfig.from_global_config() + + assert config.api_key == "default-key" + assert config.workspace_id == "default-ws" + def test_from_global_config_uses_local_path(self, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir() diff --git a/tests/plugins/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/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() 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/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..bd7a880fdee --- /dev/null +++ b/tests/plugins/video_gen/test_xai_plugin.py @@ -0,0 +1,113 @@ +"""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_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).""" + 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/environments/benchmarks/tblite/__init__.py b/tests/plugins/web/__init__.py similarity index 100% rename from environments/benchmarks/tblite/__init__.py rename to tests/plugins/web/__init__.py 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..6ea154dee1e --- /dev/null +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -0,0 +1,475 @@ +"""Plugin-side tests for the web search provider migration (PR #25182). + +Covers: + +- All seven bundled plugins (brave-free, ddgs, searxng, exa, parallel, + tavily, firecrawl) instantiate and self-report the expected + capabilities + ABC-derived defaults. +- Each plugin's ``is_available()`` correctly reflects env-var presence. +- The web_search_registry resolves an active provider in the documented + scenarios (explicit config wins ignoring availability, fallback walks + legacy preference filtered by availability, unknown name falls back). +- Plugin response shapes match the legacy bit-for-bit contract. + +Per the dev skill: these tests use *real* imports from the plugin +modules — no mocking of provider classes themselves — so the test +catches drift in the ABC interface, the registry, and the plugin +glue layer simultaneously. +""" +from __future__ import annotations + +import asyncio +import inspect +import os +import sys +from typing import Any, Dict, List + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip every web-provider env var so is_available() returns False.""" + for k in ( + "BRAVE_SEARCH_API_KEY", + "SEARXNG_URL", + "TAVILY_API_KEY", + "TAVILY_BASE_URL", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "PARALLEL_SEARCH_MODE", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_USER_TOKEN", + ): + monkeypatch.delenv(k, raising=False) + + +def _ensure_plugins_loaded() -> None: + """Idempotently load plugins so the registry is populated.""" + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + + +# --------------------------------------------------------------------------- +# Per-plugin discovery + capability flags +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Each test starts with a clean web-provider env.""" + _clear_web_env(monkeypatch) + + +class TestBundledPluginsRegister: + """All seven bundled web plugins discover and register correctly.""" + + def test_all_seven_plugins_present_in_registry(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import list_providers + + names = sorted(p.name for p in list_providers()) + assert names == [ + "brave-free", + "ddgs", + "exa", + "firecrawl", + "parallel", + "searxng", + "tavily", + ] + + @pytest.mark.parametrize( + "plugin_name,expected_search,expected_extract,expected_crawl", + [ + ("brave-free", True, False, False), + ("ddgs", True, False, False), + ("searxng", True, False, False), + ("exa", True, True, False), + ("parallel", True, True, False), + ("tavily", True, True, True), + # firecrawl: search + extract + crawl. Crawl was originally + # disabled in the migration (fell through to a legacy inline + # path); the follow-up commit enabled it natively. + ("firecrawl", True, True, True), + ], + ) + def test_capability_flags_match_spec( + self, + plugin_name: str, + expected_search: bool, + expected_extract: bool, + expected_crawl: bool, + ) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None, f"plugin {plugin_name!r} not registered" + assert provider.supports_search() is expected_search + assert provider.supports_extract() is expected_extract + assert provider.supports_crawl() is expected_crawl + + @pytest.mark.parametrize( + "plugin_name", + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl"], + ) + def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + assert provider.name == plugin_name + assert provider.display_name # any non-empty string + + @pytest.mark.parametrize( + "plugin_name", + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl"], + ) + def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: + """``get_setup_schema()`` returns a dict the picker can consume.""" + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + schema = provider.get_setup_schema() + assert isinstance(schema, dict) + assert "name" in schema + assert "env_vars" in schema + + +# --------------------------------------------------------------------------- +# is_available() behavior +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + """Each plugin's ``is_available()`` returns False without env config.""" + + def test_brave_free_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("brave-free") + assert p is not None + assert p.is_available() is False # no BRAVE_SEARCH_API_KEY + monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real") + assert p.is_available() is True + + def test_searxng_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("searxng") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + assert p.is_available() is True + + def test_tavily_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("TAVILY_API_KEY", "real") + assert p.is_available() is True + + def test_exa_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("EXA_API_KEY", "real") + assert p.is_available() is True + + def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("PARALLEL_API_KEY", "real") + assert p.is_available() is True + + def test_firecrawl_requires_either_key_or_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert p.is_available() is False + + # Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL lights it up. + monkeypatch.setenv("FIRECRAWL_API_KEY", "real") + assert p.is_available() is True + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002") + assert p.is_available() is True + + def test_ddgs_always_available_when_package_importable(self) -> None: + """DDGS is the always-on fallback — no API key required. + + It may report unavailable if the ``ddgs`` package itself isn't + installed in the env (legitimate — the plugin's post_setup hook + triggers pip install on first selection). We only assert that + is_available() doesn't raise. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("ddgs") + assert p is not None + # Truthy or falsy, just must not raise. + _ = bool(p.is_available()) + + +# --------------------------------------------------------------------------- +# Registry resolution semantics (Option B — conservative smart fallback) +# --------------------------------------------------------------------------- + + +class TestRegistryResolution: + """``_resolve()`` follows explicit-config + availability-filtered fallback.""" + + def test_explicit_configured_provider_returned_even_when_unavailable( + self, + ) -> None: + """Explicit ``web.search_backend`` wins regardless of is_available(). + + Without availability filtering on the explicit path, the dispatcher + would silently switch backends; with this check the dispatcher + surfaces a precise "FOO_API_KEY is not set" error instead. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve, get_provider + + # No BRAVE_SEARCH_API_KEY (fixture cleared it). + result = _resolve("brave-free", capability="search") + assert result is not None + assert result.name == "brave-free" + # Confirm it's the unavailable one — dispatcher will surface + # a typed credential-missing error to the caller. + assert result.is_available() is False + + def test_unknown_configured_name_falls_back_to_available_provider( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Typo / uninstalled plugin → walk legacy preference, pick available.""" + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + monkeypatch.setenv("EXA_API_KEY", "real") + result = _resolve("not-a-real-provider", capability="search") + # Either ddgs (no-key fallback) or exa (the only available + # premium provider) — both are valid. The point is the unknown + # name shouldn't return None when SOMETHING is available. + assert result is not None + assert result.is_available() is True + + def test_explicit_search_only_provider_for_extract_falls_back( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Asking for extract via a search-only backend → fall back. + + ``brave-free`` is search-only (``supports_extract() is False``). + When the registry resolves it for an extract capability, the + explicit-config branch rejects it as capability-incompatible + and the fallback walk picks an extract-capable provider. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + monkeypatch.setenv("EXA_API_KEY", "real") + result = _resolve("brave-free", capability="extract") + # Should land on exa (only extract-capable available provider). + assert result is not None + assert result.supports_extract() is True + assert result.is_available() is True + + def test_no_config_no_credentials_returns_none( + self, + ) -> None: + """No backend configured AND no available providers → typically None. + + ``ddgs`` is the no-credential fallback; if its ``ddgs`` Python + package is installed in the test env, ddgs will be picked. + Otherwise the resolver returns None. Either outcome is correct. + """ + _ensure_plugins_loaded() + from agent.web_search_registry import _resolve + + result = _resolve(None, capability="search") + if result is not None: + # The only no-credential provider is ddgs; anything else + # means an env var leaked in. + assert result.is_available() is True + + +# --------------------------------------------------------------------------- +# Sync-vs-async extract detection +# --------------------------------------------------------------------------- + + +class TestAsyncExtractDispatch: + """The dispatcher detects async vs sync extract methods correctly.""" + + def test_parallel_extract_is_async(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is True + + def test_firecrawl_extract_is_async(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is True + + def test_exa_extract_is_sync(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is False + + def test_tavily_extract_is_sync(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is False + + +# --------------------------------------------------------------------------- +# Error response shape (preserved bit-for-bit from legacy) +# --------------------------------------------------------------------------- + + +class TestErrorResponseShapes: + """When credentials are missing, plugins return typed errors, not raises.""" + + def test_brave_free_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("brave-free") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_searxng_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("searxng") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_exa_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("exa") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_tavily_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + result = p.search("test", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("parallel") + assert p is not None + result = asyncio.run(p.extract(["https://example.com"])) + assert isinstance(result, list) + assert len(result) == 1 + assert "error" in result[0] + assert result[0]["url"] == "https://example.com" + + def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + # firecrawl extract returns [] when the website-policy gate rejects + # the URL, or a per-URL error dict when the gate passes but the + # firecrawl client fails. Use a URL the policy allows to make sure + # we hit the credential-missing path. + result = asyncio.run(p.extract(["https://example.com"])) + assert isinstance(result, list) + if result: # if anything came back, it should be an error entry + assert "error" in result[0] + + def test_tavily_crawl_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("tavily") + assert p is not None + result = p.crawl("https://example.com") + assert isinstance(result, dict) + assert "results" in result + assert isinstance(result["results"], list) + if result["results"]: + assert "error" in result["results"][0] + + def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self) -> None: + """firecrawl crawl is async (wraps SDK in to_thread); error must be + surfaced via the per-page result shape, not raised.""" + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert inspect.iscoroutinefunction(p.crawl) + result = asyncio.run(p.crawl("https://example.com")) + assert isinstance(result, dict) + assert "results" in result + assert isinstance(result["results"], list) + # Without FIRECRAWL_API_KEY, the plugin's _get_firecrawl_client() + # raises ValueError which is caught and returned as a per-page error. + assert len(result["results"]) >= 1 + assert "error" in result["results"][0] + assert result["results"][0]["url"] == "https://example.com" diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index 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_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 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_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 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"])) 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..46e47bae13e --- /dev/null +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -0,0 +1,418 @@ +"""Integration test for the codex_app_server runtime path through AIAgent. + +Verifies that: + - api_mode='codex_app_server' is accepted on AIAgent construction + - run_conversation() takes the early-return path and never enters the + chat completions loop + - Projected messages from a fake Codex session land in the messages list + - tool_iterations from the codex session tick the skill nudge counter + - Memory nudge counter ticks once per turn + - The returned dict has the same shape as the chat_completions path +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +import run_agent +from agent.transports.codex_app_server_session import CodexAppServerSession, TurnResult + + +@pytest.fixture +def fake_session(monkeypatch): + """Replace CodexAppServerSession with a stub that returns a fixed + TurnResult, so we can drive AIAgent without spawning real codex.""" + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text=f"echo: {user_input}", + projected_messages=[ + {"role": "assistant", "content": None, + "tool_calls": [{"id": "exec_1", "type": "function", + "function": {"name": "exec_command", + "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "exec_1", "content": "ok"}, + {"role": "assistant", "content": f"echo: {user_input}"}, + ], + tool_iterations=1, + interrupted=False, + error=None, + turn_id="turn-stub-1", + thread_id="thread-stub-1", + ) + + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1" + ) + + +def _make_codex_agent(): + """Construct an AIAgent in codex_app_server mode without contacting any + real provider. We pass api_mode explicitly so the constructor takes the + fast path for direct credentials.""" + return run_agent.AIAgent( + api_key="stub", + base_url="https://stub.invalid", + provider="openai", + api_mode="codex_app_server", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +class TestApiModeAccepted: + def test_api_mode_is_codex_app_server(self): + agent = _make_codex_agent() + assert agent.api_mode == "codex_app_server" + + +class TestRunConversationCodexPath: + def test_run_conversation_returns_codex_shape(self, fake_session): + agent = _make_codex_agent() + # No background review fork during tests + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello there") + assert result["final_response"] == "echo: hello there" + assert result["completed"] is True + assert result["partial"] is False + assert result["error"] is None + assert result["api_calls"] == 1 + assert result["codex_thread_id"] == "thread-stub-1" + assert result["codex_turn_id"] == "turn-stub-1" + + def test_projected_messages_are_spliced(self, fake_session): + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello") + msgs = result["messages"] + # User message + 3 projected (assistant tool_call + tool + assistant text) + assert len(msgs) >= 4 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == "hello" + # Last assistant message has the final text + final = [m for m in msgs if m.get("role") == "assistant" + and m.get("content") == "echo: hello"] + assert final, f"expected final assistant message in {msgs}" + + def test_nudge_counters_tick(self, fake_session): + """The skill nudge counter must accumulate tool_iterations across + turns. The memory nudge counter is gated on memory being configured + (which we skip via skip_memory=True), so we don't assert on it here — + a separate test below covers that path explicitly.""" + agent = _make_codex_agent() + agent._iters_since_skill = 0 + agent._user_turn_count = 0 + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("first") + assert agent._iters_since_skill == 1 # one tool_iteration in fake turn + # _user_turn_count is incremented by run_conversation pre-loop, not + # by the codex helper — confirms we delegate that to the standard flow. + assert agent._user_turn_count == 1 + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("second") + assert agent._iters_since_skill == 2 + assert agent._user_turn_count == 2 + + def test_user_message_not_duplicated(self, fake_session): + """Regression guard: the user message must appear exactly once in + the messages list. The standard run_conversation pre-loop appends + it, and the codex helper must NOT append again.""" + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("ping unique 12345") + user_count = sum( + 1 for m in result["messages"] + if m.get("role") == "user" and m.get("content") == "ping unique 12345" + ) + assert user_count == 1, f"user message appeared {user_count}× in {result['messages']}" + + def test_background_review_NOT_invoked_below_threshold(self, fake_session): + """A single turn shouldn't trigger background review — counters + haven't reached the nudge interval (default 10).""" + agent = _make_codex_agent() + agent._memory_nudge_interval = 10 + agent._skill_nudge_interval = 10 + agent._iters_since_skill = 0 + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("ping") + # Below threshold → review should NOT fire (was a real bug: + # the helper was calling _spawn_background_review() with no + # args after every turn, which would crash with TypeError). + assert not spawn.called + + def test_background_review_skill_trigger_fires_above_threshold( + self, monkeypatch + ): + """When tool iterations cross the skill nudge interval, the + background review fires with review_skills=True and the right + messages_snapshot signature.""" + from agent.transports.codex_app_server_session import ( + CodexAppServerSession, TurnResult, + ) + # Make the fake session report 10 tool iterations in one turn + # (matching the default skill threshold). + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text=f"echo: {user_input}", + projected_messages=[ + {"role": "assistant", "content": f"echo: {user_input}"}, + ], + tool_iterations=10, + turn_id="t1", thread_id="th1", + ) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th1" + ) + + agent = _make_codex_agent() + agent._skill_nudge_interval = 10 + agent._iters_since_skill = 0 + # Make valid_tool_names include 'skill_manage' so the gate passes + agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set())) + agent.valid_tool_names.add("skill_manage") + + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("do tool work") + + assert spawn.called, "skill threshold tripped but review didn't fire" + # Verify the call signature matches what _spawn_background_review + # actually expects — this is the regression guard for the original + # bug where the codex path called it with no args at all. + call = spawn.call_args + assert "messages_snapshot" in call.kwargs + assert isinstance(call.kwargs["messages_snapshot"], list) + assert call.kwargs["review_skills"] is True + # Counter should be reset after the review fires + assert agent._iters_since_skill == 0 + + def test_background_review_signature_never_breaks(self, fake_session): + """Even when no trigger fires, the helper must never call + _spawn_background_review with the wrong signature. Run a turn, + then run another turn after manually tripping the skill counter + and confirm the call shape is the kwargs-only form the function + actually accepts.""" + agent = _make_codex_agent() + agent._skill_nudge_interval = 1 # very low so any iter trips it + agent._iters_since_skill = 0 + agent.valid_tool_names = set(getattr(agent, "valid_tool_names", set())) + agent.valid_tool_names.add("skill_manage") + + with patch.object(agent, "_spawn_background_review", + return_value=None) as spawn: + agent.run_conversation("first") + # The fake session reports tool_iterations=1, which trips + # _skill_nudge_interval=1. So review should fire. + assert spawn.called + # Critical invariant: positional args must be empty, all real + # args must be kwargs (matching _spawn_background_review's + # actual signature). + call = spawn.call_args + assert call.args == (), ( + f"expected no positional args, got {call.args!r} — " + "would crash _spawn_background_review at runtime" + ) + assert "messages_snapshot" in call.kwargs + + def test_chat_completions_loop_is_not_entered(self, fake_session): + """The early-return must bypass the regular API call loop entirely. + We confirm by patching the SDK call and asserting it's never invoked.""" + agent = _make_codex_agent() + # The chat_completions loop calls self.client.chat.completions.create(...) + # If our early-return works, that path is dead. + with patch.object(agent, "client") as client_mock, patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("hi") + assert not client_mock.chat.completions.create.called + + +class TestReviewForkApiModeDowngrade: + """When the parent agent runs on codex_app_server, the background + review fork must downgrade to codex_responses — otherwise the fork + can't dispatch agent-loop tools (memory, skill_manage) which is the + whole point of the review.""" + + def test_codex_app_server_parent_downgrades_review_fork(self): + """Live test against the real _spawn_background_review code path: + verify the review_agent gets api_mode=codex_responses when the + parent is codex_app_server.""" + from unittest.mock import MagicMock, patch as _patch + agent = _make_codex_agent() + # Pretend memory + skills are configured so the review fork + # reaches the AIAgent constructor. + agent._memory_store = MagicMock() + agent._memory_enabled = True + agent._user_profile_enabled = True + # Mock _current_main_runtime to return the parent's codex_app_server + # state so we can confirm the helper detects + downgrades it. + agent._current_main_runtime = lambda: { + "api_mode": "codex_app_server", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "stub-token", + } + # Capture what AIAgent gets constructed with inside the helper. + captured = {} + + def _capture_init(self, **kwargs): + captured.update(kwargs) + # Set bare attributes the rest of the spawn function reads + # so it can finish without exploding. + self.api_mode = kwargs.get("api_mode") + self.provider = kwargs.get("provider") + self.model = kwargs.get("model") + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = False + self._user_profile_enabled = False + self._memory_nudge_interval = 0 + self._skill_nudge_interval = 0 + self.suppress_status_output = False + self._session_messages = [] + + def _no_op_run_conv(*a, **kw): + return {"final_response": "", "messages": []} + self.run_conversation = _no_op_run_conv + + def _no_op_close(*a, **kw): + return None + self.close = _no_op_close + + with _patch("run_agent.AIAgent.__init__", _capture_init): + agent._spawn_background_review( + messages_snapshot=[{"role": "user", "content": "x"}], + review_memory=True, + review_skills=False, + ) + # Wait for the spawned thread to actually execute + import time + for _ in range(30): + if "api_mode" in captured: + break + time.sleep(0.1) + + assert captured.get("api_mode") == "codex_responses", ( + f"review fork should be downgraded to codex_responses when " + f"parent is codex_app_server; got {captured.get('api_mode')!r}" + ) + + +class TestErrorHandling: + def test_session_exception_returns_partial_with_error(self, monkeypatch): + def boom_run_turn(self, user_input, **kwargs): + raise RuntimeError("subprocess died") + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "t1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + assert result["completed"] is False + assert result["partial"] is True + assert "subprocess died" in result["error"] + assert "codex-runtime auto" in result["final_response"] + + def test_interrupted_turn_marked_partial(self, monkeypatch): + def interrupted_turn(self, user_input, **kwargs): + return TurnResult( + final_text="", + projected_messages=[], + tool_iterations=0, + interrupted=True, + error="user interrupted", + turn_id="t", + thread_id="th", + ) + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th") + monkeypatch.setattr(CodexAppServerSession, "run_turn", interrupted_turn) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + assert result["completed"] is False + assert result["partial"] is True + assert result["error"] == "user interrupted" + + +class TestSessionRetirementOnRunAgent: + """run_agent.py side: when run_turn returns should_retire=True, the + AIAgent must close + null _codex_session so the next turn respawns.""" + + def test_should_retire_drops_session(self, monkeypatch): + closes = {"count": 0} + + def fake_run_turn(self, user_input, **kwargs): + return TurnResult( + final_text="", + projected_messages=[], + tool_iterations=0, + interrupted=True, + error="turn timed out after 600.0s", + turn_id="tu1", + thread_id="th1", + should_retire=True, + ) + + def fake_close(self): + closes["count"] += 1 + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr(CodexAppServerSession, "close", fake_close) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + + # The session was closed and cleared + assert closes["count"] == 1 + assert getattr(agent, "_codex_session", "MISSING") is None + # Partial result was still returned (caller still sees the error) + assert result["partial"] is True + assert result["error"] == "turn timed out after 600.0s" + + def test_normal_turn_keeps_session(self, fake_session): + """fake_session fixture returns should_retire=False (default). + The session must stay attached for the next turn to reuse.""" + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("hi") + # Session was lazily created and still attached. + assert getattr(agent, "_codex_session", None) is not None + + def test_exception_path_also_drops_session(self, monkeypatch): + """Even if run_turn raises (not just sets should_retire), we must + drop the session — a thrown exception is the strongest possible + signal the process is dead.""" + closes = {"count": 0} + + def boom_run_turn(self, user_input, **kwargs): + raise RuntimeError("codex segfaulted") + + def fake_close(self): + closes["count"] += 1 + + monkeypatch.setattr(CodexAppServerSession, "ensure_started", + lambda self: "th1") + monkeypatch.setattr(CodexAppServerSession, "run_turn", boom_run_turn) + monkeypatch.setattr(CodexAppServerSession, "close", fake_close) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hi") + + assert closes["count"] == 1 + assert agent._codex_session is None + assert result["completed"] is False + assert "codex segfaulted" in result["error"] diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index 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=[], ) 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( 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) diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 47c491c441c..5652281eb42 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-4.3", + 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-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 + 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/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/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/scripts/test_release_acp_registry.py b/tests/scripts/test_release_acp_registry.py new file mode 100644 index 00000000000..4d20cda25bd --- /dev/null +++ b/tests/scripts/test_release_acp_registry.py @@ -0,0 +1,113 @@ +"""Tests for the ACP Registry version-lockstep bump in scripts/release.py. + +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 + +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) + + monkeypatch.setattr(module, "REPO_ROOT", tmp_root) + monkeypatch.setattr( + module, "ACP_REGISTRY_MANIFEST", tmp_root / "acp_registry" / "agent.json" + ) + return module + + +def _write_manifest(root: Path, version: str) -> None: + 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": { + "uvx": { + "package": f"hermes-agent[acp]=={version}", + "args": ["hermes-acp"], + } + }, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +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") + + manifest = json.loads( + (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") + ) + assert manifest["version"] == "0.14.0" + 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_manifest_missing( + monkeypatch, tmp_path +): + """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_manifest_alongside_pyproject( + monkeypatch, tmp_path +): + """End-to-end: update_version_files() is the function release.py actually + 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" + ) + 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"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0" 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" diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py new file mode 100644 index 00000000000..6ec3b565384 --- /dev/null +++ b/tests/test_install_sh_browser_install.py @@ -0,0 +1,60 @@ +"""Regression tests for install.sh browser setup. + +Browser automation is optional. The installer should not leave Hermes +half-installed just because Playwright's managed Chromium download hangs on an +unsupported distribution. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" + + +def test_install_script_skips_playwright_download_when_system_browser_exists() -> None: + text = INSTALL_SH.read_text() + + assert "find_system_browser()" in text + assert "google-chrome google-chrome-stable chromium chromium-browser chrome" in text + assert "Skipping Playwright browser download; Hermes will use the system browser." in text + + +def test_install_script_persists_system_browser_for_agent_browser() -> None: + text = INSTALL_SH.read_text() + + assert "configure_browser_env_from_system_browser()" in text + assert "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" in text + + +def test_playwright_installs_are_timeout_guarded() -> None: + text = INSTALL_SH.read_text() + + assert "run_browser_install_with_timeout()" in text + assert "run_browser_install_with_timeout 600 npx playwright install chromium" in text + # --with-deps is still invoked on apt-based systems, but only when sudo + # is available non-interactively (root or passwordless sudo). Non-sudo + # service users fall back to the browser-only install — see + # install_node_deps() in install.sh. + assert "run_browser_install_with_timeout 600 npx playwright install --with-deps chromium" in text + + +def test_install_script_supports_skip_browser_flag() -> None: + """--skip-browser (and --no-playwright alias) skips the Playwright install.""" + text = INSTALL_SH.read_text() + + assert "--skip-browser|--no-playwright)" in text + assert "SKIP_BROWSER=true" in text + assert 'if [ "$SKIP_BROWSER" = true ]; then' in text + assert "--skip-browser Skip Playwright/Chromium install" in text + + +def test_install_script_skips_with_deps_when_no_sudo() -> None: + """Non-sudo users on apt distros must not block on an interactive sudo prompt.""" + text = INSTALL_SH.read_text() + + # The apt branch must gate --with-deps behind a sudo capability check + # (root or non-interactive sudo), otherwise the installer hangs for + # service-user installs (systemd accounts, operator users, etc.). + assert 'if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then' in text + assert "sudo npx playwright install-deps chromium" in text diff --git a/tests/test_install_sh_symlink_stomp.py b/tests/test_install_sh_symlink_stomp.py new file mode 100644 index 00000000000..450d6fe2088 --- /dev/null +++ b/tests/test_install_sh_symlink_stomp.py @@ -0,0 +1,123 @@ +"""Regression for #21454: re-running install.sh on a symlinked prior install. + +Older versions of ``install.sh`` created ``$command_link_dir/hermes`` as a +symlink to the pip-generated entry point at ``$HERMES_BIN`` (i.e. +``venv/bin/hermes``). When ``setup_path()`` later switched to writing a bash +shim with ``cat > "$command_link_dir/hermes" < str: + """Return the install.sh shim-write block used by setup_path().""" + text = INSTALL_SH.read_text() + match = re.search( + r"(?Pmkdir -p \"\$command_link_dir\".*?chmod \+x \"\$command_link_dir/hermes\")", + text, + re.DOTALL, + ) + assert match is not None, ( + "Could not locate the setup_path shim-write block in scripts/install.sh" + ) + return match["block"] + + +def test_setup_path_shim_block_removes_old_link_before_writing() -> None: + """Static guard: the rm must precede the cat heredoc, not follow it.""" + block = _extract_setup_path_shim_block() + rm_idx = block.find('rm -f "$command_link_dir/hermes"') + cat_idx = block.find('cat > "$command_link_dir/hermes" <` heredoc, otherwise an existing symlink (left by older " + "installs) will be followed and the pip entry point overwritten. " + "See #21454." + ) + assert cat_idx != -1, "expected `cat >` heredoc still present" + assert rm_idx < cat_idx, ( + "`rm -f` must come *before* the `cat >` heredoc, not after." + ) + + +def test_re_running_setup_path_block_preserves_pip_entry_point(tmp_path: Path) -> None: + """Behavioral repro: simulate prior-install symlink + new-install heredoc. + + Layout mirrors a real install: + + tmp/ + venv/bin/hermes <- pip entry point (the one we must preserve) + local_bin/hermes <- symlink → ../venv/bin/hermes (old install) + + Then we run the exact shim-write block from setup_path() with + ``HERMES_BIN`` and ``command_link_dir`` pointed at this fixture. The fix + requires that, after the run: + + * ``venv/bin/hermes`` still contains its original pip-script body + * ``local_bin/hermes`` is a regular file (not a symlink) holding the shim + """ + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + pip_entry = venv_bin / "hermes" + pip_marker = "#!/usr/bin/env python\n# pip-generated entry point — must not be overwritten\n" + pip_entry.write_text(pip_marker) + pip_entry.chmod(pip_entry.stat().st_mode | stat.S_IXUSR) + + command_link_dir = tmp_path / "local_bin" + command_link_dir.mkdir() + shim_path = command_link_dir / "hermes" + # Reproduce the prior-install state: shim path is a symlink to the + # pip-generated entry point. + shim_path.symlink_to(pip_entry) + assert shim_path.is_symlink() + + block = _extract_setup_path_shim_block() + # Drive the block with the real env vars setup_path() sets. + script = f'set -e\nHERMES_BIN={pip_entry!s}\ncommand_link_dir={command_link_dir!s}\n{block}\n' + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert result.returncode == 0, ( + f"shim-write block failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + # The pip entry point must still be the original pip script — not a + # re-written self-recursing bash shim. + assert pip_entry.read_text() == pip_marker, ( + "venv/bin/hermes was overwritten by setup_path() — symlink-stomp " + "regression (#21454)." + ) + + # The shim path itself must now be a regular file holding the launcher. + assert shim_path.exists() + assert not shim_path.is_symlink(), ( + "command_link_dir/hermes must be replaced with a regular file, not " + "left as a symlink — otherwise the next install will stomp again." + ) + shim_text = shim_path.read_text() + assert "unset PYTHONPATH" in shim_text + assert "unset PYTHONHOME" in shim_text + assert f'exec "{pip_entry}"' in shim_text + shim_mode = shim_path.stat().st_mode + assert shim_mode & stat.S_IXUSR, "shim must be user-executable" diff --git a/tests/test_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/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") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 114db6a1203..cc735e2dcfc 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4730,3 +4730,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_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/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") 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" 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? diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 3e1f85c370a..6280b71d29f 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -122,6 +122,27 @@ 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 + + @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/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/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/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_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/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/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_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_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/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/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_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 74dba775e8c..7188487a96b 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -181,7 +181,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 = {} @@ -194,15 +202,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_transcription_tools.py b/tests/tools/test_transcription_tools.py index 01770bff474..56ab134be8d 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -1522,3 +1522,44 @@ class TestTranscribeAudioElevenLabsDispatch: transcribe_audio(sample_ogg, model="scribe_v2") assert mock_elevenlabs.call_args[0][1] == "scribe_v2" + + +# Shell safety — shlex.split on auto-detected templates +# ============================================================================ +class TestShellSafety: + def test_auto_detected_template_is_shlex_safe(self, monkeypatch): + """Auto-detected whisper command should be safely splittable.""" + import shlex + monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) + monkeypatch.setattr( + "tools.transcription_tools._find_whisper_binary", + lambda: "/usr/bin/whisper", + ) + from tools.transcription_tools import _get_local_command_template + template = _get_local_command_template() + assert template is not None + cmd = template.format( + input_path=shlex.quote("/tmp/test.wav"), + output_dir=shlex.quote("/tmp/out"), + language=shlex.quote("en"), + model=shlex.quote("base"), + ) + parts = shlex.split(cmd) + assert parts[0] == "/usr/bin/whisper" + assert "/tmp/test.wav" in parts + + def test_env_var_template_uses_shell_path(self, monkeypatch): + """When HERMES_LOCAL_STT_COMMAND is set, use_shell should be True.""" + import os + from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + monkeypatch.setenv(LOCAL_STT_COMMAND_ENV, "whisper {input_path} | tee log.txt") + use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) + assert use_shell is True + + def test_no_env_var_uses_list_mode(self, monkeypatch): + """When no env var is set, use_shell should be False.""" + import os + from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + monkeypatch.delenv(LOCAL_STT_COMMAND_ENV, raising=False) + use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) + assert use_shell is False diff --git a/tests/tools/test_tts_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/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 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/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/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/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 3c0abb307b0..67d39e9a999 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 # --------------------------------------------------------------------------- @@ -192,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/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/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/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 4573e027650..0e734cbae78 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)) @@ -443,6 +454,9 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + # The dispatcher-level (seed-URL) policy gate still lives on web_tools. + # No per-page gate runs in this test because the dispatcher returns + # immediately when the seed is blocked, before delegating to the plugin. monkeypatch.setattr( web_tools, "check_website_access", @@ -453,10 +467,13 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): "message": "Blocked by website policy", }, ) + # If the dispatcher ever reaches the firecrawl plugin's crawl(), the test + # fails — pin the plugin module's client lookup so we'd notice. + from plugins.web.firecrawl import provider as firecrawl_provider monkeypatch.setattr( - web_tools, + firecrawl_provider, "_get_firecrawl_client", - lambda: pytest.fail("firecrawl should not run for blocked crawl URL"), + lambda: pytest.fail("firecrawl plugin should not run for blocked crawl URL"), ) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) @@ -469,13 +486,17 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch): @pytest.mark.asyncio async def test_web_crawl_blocks_redirected_final_url(monkeypatch): from tools import web_tools + from plugins.web.firecrawl import provider as firecrawl_provider - # web_crawl_tool checks for Firecrawl env before website policy + # Force the firecrawl plugin to be the active crawl provider. monkeypatch.setenv("FIRECRAWL_API_KEY", "fake-key") # Allow test URLs past SSRF check so website policy is what gets tested monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) def fake_check(url): + # Dispatcher seed-URL gate (web_tools.check_website_access call) + # and plugin per-page gate (firecrawl_provider.check_website_access + # call) both flow through this single fake_check. if url == "https://allowed.test": return None if url == "https://blocked.test/final": @@ -501,8 +522,13 @@ 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/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/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/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( diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 40ba7cab25c..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 @@ -1892,8 +1898,8 @@ def _run_browser_command( except OSError: pass if _needs_sandbox_bypass: - browser_env["AGENT_BROWSER_CHROME_FLAGS"] = ( - "--no-sandbox --disable-dev-shm-usage" + browser_env["AGENT_BROWSER_ARGS"] = ( + "--no-sandbox,--disable-dev-shm-usage" ) # Use temp files for stdout/stderr instead of pipes. @@ -3381,8 +3387,8 @@ def _chromium_installed() -> bool: 1. ``AGENT_BROWSER_EXECUTABLE_PATH`` env var — the official way to point agent-browser at a pre-installed Chrome/Chromium. - 2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium-browser``, - ``chrome``). + 2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium``, + ``chromium-browser``, ``chrome``). 3. Playwright's browser cache (current logic) — directories containing ``chromium-*`` or ``chromium_headless_shell-*``. @@ -3405,7 +3411,12 @@ def _chromium_installed() -> bool: return True # 2. System Chrome/Chromium in PATH (common names) - system_chrome = shutil.which("google-chrome") or shutil.which("chromium-browser") or shutil.which("chrome") + system_chrome = ( + shutil.which("google-chrome") + or shutil.which("chromium") + or shutil.which("chromium-browser") + or shutil.which("chrome") + ) if system_chrome: _cached_chromium_installed = True return True diff --git a/tools/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/cronjob_tools.py b/tools/cronjob_tools.py index e63b60047ac..a7a8a0feab9 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) @@ -647,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( - os.getenv("HERMES_INTERACTIVE") - or os.getenv("HERMES_GATEWAY_SESSION") - or os.getenv("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/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 ) 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: 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. diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index d086d117307..258a09ef667 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__) @@ -116,10 +116,16 @@ 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", + "aiohttp==3.13.3", ), "platform.matrix": ( "mautrix[encryption]==0.21.0", @@ -247,12 +253,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: @@ -439,3 +502,107 @@ def feature_install_command(feature: str) -> Optional[str]: return None specs = LAZY_DEPS[feature] return "uv pip install " + " ".join(repr(s) for s in specs) + + +def active_features() -> list[str]: + """Return the list of features the user has ever lazy-installed. + + A feature counts as "active" if at least one of its declared packages + is currently installed in the venv (presence check, ignoring version). + Features the user has never enabled stay quiet. + + Used by ``hermes update`` to figure out which lazy backends need a + refresh pass when pins move in :data:`LAZY_DEPS`. + """ + active = [] + for feature, specs in LAZY_DEPS.items(): + if any(_is_present(s) for s in specs): + active.append(feature) + return active + + +def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: + """Re-run ``ensure`` for every feature the user has previously activated. + + Returns a ``{feature: status}`` map where status is one of: + ``"current"`` — pins already satisfied, no install run + ``"refreshed"`` — pins were stale, reinstall succeeded + ``"failed: "`` — install attempt failed; caller decides + whether to surface it (we don't raise) + ``"skipped: "`` — gated off (config flag, user decline) + + Intended for ``hermes update``. Never raises; lazy-install failures + here must not block the rest of the update flow. + """ + results: dict[str, str] = {} + for feature in active_features(): + missing = feature_missing(feature) + if not missing: + results[feature] = "current" + continue + try: + ensure(feature, prompt=prompt) + results[feature] = "refreshed" + except FeatureUnavailable as e: + # Distinguish "user opted out" from "install failed" so the + # update command can render the right message. + if "lazy installs disabled" in str(e) or "declined" in str(e): + results[feature] = f"skipped: {e.reason}" + else: + results[feature] = f"failed: {e.reason}" + except Exception as e: + results[feature] = f"failed: {e}" + return results + + +def ensure_and_bind( + feature: str, + importer: Callable[[], dict[str, Any]], + target_globals: dict, + *, + prompt: bool = False, +) -> bool: + """Ensure a feature is installed, then rebind names into the caller's globals. + + Combines :func:`ensure` with a post-install import step that rebinds + module-level names. This eliminates the error-prone pattern of manually + listing every global that needs updating after lazy-install. + + ``importer`` is a zero-arg callable that returns a dict of + ``{name: value}`` for all symbols the caller needs rebound. It is called + only after :func:`ensure` succeeds (or if the packages are already + installed). + + Returns True on success, False if deps couldn't be installed or imported. + + Example usage in a platform adapter:: + + def check_slack_requirements() -> bool: + if SLACK_AVAILABLE: + return True + def _import(): + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient + import aiohttp + return { + "AsyncApp": AsyncApp, + "AsyncSocketModeHandler": AsyncSocketModeHandler, + "AsyncWebClient": AsyncWebClient, + "aiohttp": aiohttp, + "SLACK_AVAILABLE": True, + } + return ensure_and_bind("platform.slack", _import, globals(), prompt=False) + """ + try: + ensure(feature, prompt=prompt) + except (FeatureUnavailable, Exception): + return False + + try: + bindings = importer() + except ImportError: + return False + + target_globals.update(bindings) + return True diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 1e10b276f1e..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 @@ -1499,6 +1504,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( @@ -2094,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): diff --git a/tools/process_registry.py b/tools/process_registry.py index 5efebd98d19..1f31527475a 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -827,6 +827,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: @@ -1389,6 +1409,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/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/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/tools/skills_tool.py b/tools/skills_tool.py index 32296729fe2..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")) @@ -956,49 +957,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]] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 4d8512c345e..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) @@ -1544,9 +1546,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 +1601,25 @@ def _foreground_background_guidance(command: str) -> str | None: if _looks_like_help_or_version_command(command): return None - if _SHELL_LEVEL_BACKGROUND_RE.search(command): + # Strip quoted content so keywords inside strings/arguments don't trigger + # false positives (e.g., git commit -m "... setsid ...", python3 -c "os.setsid"). + unquoted = _strip_quotes(command) + + if _SHELL_LEVEL_BACKGROUND_RE.search(unquoted): return ( "Foreground command uses shell-level background wrappers (nohup/disown/setsid). " "Use terminal(background=true) so Hermes can track the process, then run " "readiness checks and tests in separate commands." ) - if _INLINE_BACKGROUND_AMP_RE.search(command) or _TRAILING_BACKGROUND_AMP_RE.search(command): + if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted): return ( "Foreground command uses '&' backgrounding. Use terminal(background=true) for long-lived " "processes, then run health checks and tests in follow-up terminal calls." ) for pattern in _LONG_LIVED_FOREGROUND_PATTERNS: - if pattern.search(command): + if pattern.search(unquoted): return ( "This foreground command appears to start a long-lived server/watch process. " "Run it with background=true, verify readiness (health endpoint/log signal), " diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 169a6320f4d..3b5e8fc56e3 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -269,10 +269,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" @@ -300,9 +302,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 if get_env_value("ELEVENLABS_API_KEY"): logger.info("No local STT available, using ElevenLabs Scribe STT API") return "elevenlabs" @@ -519,7 +526,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: @@ -712,15 +725,23 @@ 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 get_env_value("XAI_STT_BASE_URL") + or creds.get("base_url") or XAI_STT_BASE_URL ).strip().rstrip("/") language = str( @@ -971,9 +992,9 @@ 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, set ELEVENLABS_API_KEY " - "for ElevenLabs Scribe, or set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY for " - "the OpenAI Whisper API." + "Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, " + "set ELEVENLABS_API_KEY for ElevenLabs Scribe, 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 1ea3ba21c63..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 @@ -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_VOICE_ID = "female-shaonv" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech" +DEFAULT_MINIMAX_MODEL = "speech-02-hd" +DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator" +DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2" DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603" DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral DEFAULT_XAI_VOICE_ID = "eve" @@ -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("/") @@ -960,11 +964,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 +988,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any model = mm_config.get("model", DEFAULT_MINIMAX_MODEL) voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID) base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL) + 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) - payload = { - "model": model, - "text": text, - "voice_id": voice_id, - } + # 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}", } + # 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") # =========================================================================== @@ -1867,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/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 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/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.""" diff --git a/tools/web_tools.py b/tools/web_tools.py index 79ddc8d27f2..597edb0c8fd 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 @@ -876,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}") @@ -1005,172 +724,13 @@ def clean_base64_images(text: str) -> str: return cleaned_text -# ─── Exa Client ────────────────────────────────────────────────────────────── - -_exa_client = None - -def _get_exa_client(): - """Get or create the Exa client (lazy initialization). - - Requires EXA_API_KEY environment variable. - """ - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("search.exa", prompt=False) - except ImportError: - pass - except Exception as e: - raise ImportError(str(e)) - from exa_py import Exa - global _exa_client - if _exa_client is None: - api_key = os.getenv("EXA_API_KEY") - if not api_key: - raise ValueError( - "EXA_API_KEY environment variable not set. " - "Get your API key at https://exa.ai" - ) - _exa_client = Exa(api_key=api_key) - _exa_client.headers["x-exa-integration"] = "hermes-agent" - return _exa_client - - -# ─── Exa Search & Extract Helpers ───────────────────────────────────────────── - -def _exa_search(query: str, limit: int = 10) -> dict: - """Search using the Exa SDK and return results as a dict.""" - from tools.interrupt import is_interrupted - if is_interrupted(): - return {"error": "Interrupted", "success": False} - - logger.info("Exa search: '%s' (limit=%d)", query, limit) - response = _get_exa_client().search( - query, - num_results=limit, - contents={ - "highlights": True, - }, - ) - - web_results = [] - for i, result in enumerate(response.results or []): - highlights = result.highlights or [] - web_results.append({ - "url": result.url or "", - "title": result.title or "", - "description": " ".join(highlights) if highlights else "", - "position": i + 1, - }) - - return {"success": True, "data": {"web": web_results}} - - -def _exa_extract(urls: List[str]) -> List[Dict[str, Any]]: - """Extract content from URLs using the Exa SDK. - - Returns a list of result dicts matching the structure expected by the - LLM post-processing pipeline (url, title, content, metadata). - """ - from tools.interrupt import is_interrupted - if is_interrupted(): - return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] - - logger.info("Exa extract: %d URL(s)", len(urls)) - response = _get_exa_client().get_contents( - urls, - text=True, - ) - - results = [] - for result in response.results or []: - content = result.text or "" - url = result.url or "" - title = result.title or "" - results.append({ - "url": url, - "title": title, - "content": content, - "raw_content": content, - "metadata": {"sourceURL": url, "title": title}, - }) - - return results - - -# ─── Parallel Search & Extract Helpers ──────────────────────────────────────── - -def _parallel_search(query: str, limit: int = 5) -> dict: - """Search using the Parallel SDK and return results as a dict.""" - from tools.interrupt import is_interrupted - if is_interrupted(): - return {"error": "Interrupted", "success": False} - - mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - if mode not in {"fast", "one-shot", "agentic"}: - mode = "agentic" - - logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit) - response = _get_parallel_client().beta.search( - search_queries=[query], - objective=query, - mode=mode, - max_results=min(limit, 20), - ) - - web_results = [] - for i, result in enumerate(response.results or []): - excerpts = result.excerpts or [] - web_results.append({ - "url": result.url or "", - "title": result.title or "", - "description": " ".join(excerpts) if excerpts else "", - "position": i + 1, - }) - - return {"success": True, "data": {"web": web_results}} - - -async def _parallel_extract(urls: List[str]) -> List[Dict[str, Any]]: - """Extract content from URLs using the Parallel async SDK. - - Returns a list of result dicts matching the structure expected by the - LLM post-processing pipeline (url, title, content, metadata). - """ - from tools.interrupt import is_interrupted - if is_interrupted(): - return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] - - logger.info("Parallel extract: %d URL(s)", len(urls)) - response = await _get_async_parallel_client().beta.extract( - urls=urls, - full_content=True, - ) - - results = [] - for result in response.results or []: - content = result.full_content or "" - if not content: - content = "\n\n".join(result.excerpts or []) - url = result.url or "" - title = result.title or "" - results.append({ - "url": url, - "title": title, - "content": content, - "raw_content": content, - "metadata": {"sourceURL": url, "title": title}, - }) - - for error in response.errors or []: - results.append({ - "url": error.url or "", - "title": "", - "content": "", - "error": error.content or error.error_type or "extraction failed", - "metadata": {"sourceURL": error.url or ""}, - }) - - return results +# ─── Exa / Parallel inline helpers — moved into plugins ────────────────────── +# After PR #25182, the exa client + search/extract and parallel client + +# search/extract helpers all live in their respective plugins: +# - plugins/web/exa/provider.py +# - plugins/web/parallel/provider.py +# Both plugins register through agent.web_search_registry and the +# dispatchers in this file resolve them via get_active_*_provider(). def web_search_tool(query: str, limit: int = 5) -> str: @@ -1229,105 +789,45 @@ def web_search_tool(query: str, limit: int = 5) -> str: if is_interrupted(): return tool_error("Interrupted", success=False) - # Dispatch to the configured search backend - backend = _get_search_backend() - if backend == "parallel": - response_data = _parallel_search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "exa": - response_data = _exa_search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "searxng": - from tools.web_providers.searxng import SearXNGSearchProvider - response_data = SearXNGSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "brave-free": - from tools.web_providers.brave_free import BraveFreeSearchProvider - response_data = BraveFreeSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "ddgs": - from tools.web_providers.ddgs import DDGSSearchProvider - response_data = DDGSSearchProvider().search(query, limit) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - if backend == "tavily": - logger.info("Tavily search: '%s' (limit: %d)", query, limit) - raw = _tavily_request("search", { - "query": query, - "max_results": min(limit, 20), - "include_raw_content": False, - "include_images": False, - }) - response_data = _normalize_tavily_search_results(raw) - debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) - result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - _debug.log_call("web_search_tool", debug_call_data) - _debug.save() - return result_json - - logger.info("Searching the web for: '%s' (limit: %d)", query, limit) - - response = _get_firecrawl_client().search( - query=query, - limit=limit + # Dispatch through the web search registry. All 7 providers + # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) + # now live as plugins; the dispatcher is just a registry lookup + + # delegation. Sync only — every provider's search() is sync. + from agent.web_search_registry import ( + get_active_search_provider, + get_provider as _wsp_get_provider, ) - web_results = _extract_web_search_results(response) - results_count = len(web_results) - logger.info("Found %d search results", results_count) - - # Build response with just search metadata (URLs, titles, descriptions) - response_data = { - "success": True, - "data": { - "web": web_results + backend = _get_search_backend() + provider = _wsp_get_provider(backend) if backend else None + if provider is None or not provider.supports_search(): + # Fall back to availability-walked active provider when the + # configured backend isn't a registered search provider (typo, + # uninstalled plugin, or capability mismatch). + provider = get_active_search_provider() + + if provider is None: + response_data = { + "success": False, + "error": ( + "No web search provider configured. " + "Run `hermes tools` to set one up." + ), } - } - - # Capture debug information - debug_call_data["results_count"] = results_count - - # Convert to JSON + else: + logger.info( + "Web search via %s: '%s' (limit: %d)", + provider.name, query, limit, + ) + response_data = provider.search(query, limit) + + debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) result_json = json.dumps(response_data, indent=2, ensure_ascii=False) - debug_call_data["final_response_size"] = len(result_json) - - # Log debug information _debug.log_call("web_search_tool", debug_call_data) _debug.save() - return result_json - + except Exception as e: error_msg = f"Error searching web: {str(e)}" logger.debug("%s", error_msg) @@ -1418,129 +918,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: @@ -1608,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) @@ -1728,8 +1173,60 @@ async def web_crawl_tool( auxiliary_available = check_auxiliary_model() backend = _get_backend() - # Tavily supports crawl via its /crawl endpoint - if backend == "tavily": + # Tavily (and any future plugin advertising supports_crawl=True) + # dispatches through agent.web_search_registry. The crawl response + # shape — {"results": [{"url", "title", "content", ...}]} — is then + # post-processed by the shared LLM-summarization path below. + from agent.web_search_registry import ( + get_active_crawl_provider, + get_provider as _wsp_get_provider, + ) + + crawl_provider = _wsp_get_provider(backend) if backend else None + if crawl_provider is not None and not crawl_provider.supports_crawl(): + # When the configured provider is search-only AND cannot + # extract URLs either (brave-free / ddgs / searxng), surface a + # typed "search-only" error rather than silently switching to + # a different crawl backend. When the provider supports extract + # but not crawl (e.g. firecrawl), fall through to the legacy + # firecrawl-via-extract path below. + if not crawl_provider.supports_extract(): + return json.dumps( + { + "success": False, + "error": ( + f"{crawl_provider.display_name} is a search-only " + "backend and cannot crawl URLs. " + "Set FIRECRAWL_API_KEY for crawling, or use " + "web_search instead." + ), + }, + ensure_ascii=False, + ) + crawl_provider = None # let legacy firecrawl path handle it + if crawl_provider is None: + crawl_provider = get_active_crawl_provider() + + # Mirror main's upstream availability gate: when the resolved + # provider is configured-but-unavailable (e.g. firecrawl without + # FIRECRAWL_API_KEY), short-circuit BEFORE we dispatch so the + # error envelope matches the legacy top-level shape + # ``{"success": False, "error": "..."}`` rather than burying the + # configuration message inside a per-page ``results[]`` entry. + if crawl_provider is not None and not crawl_provider.is_available(): + return json.dumps( + { + "success": False, + "error": ( + "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, " + f"FIRECRAWL_API_URL{_firecrawl_backend_help_suffix()}, " + "or use web_search + web_extract instead." + ), + }, + ensure_ascii=False, + ) + + if crawl_provider is not None: # Ensure URL has protocol if not url.startswith(('http://', 'https://')): url = f'https://{url}' @@ -1750,18 +1247,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', [])) @@ -1793,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 @@ -1812,280 +1325,23 @@ 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) - - # 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) @@ -2098,21 +1354,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() diff --git a/tools/xai_http.py b/tools/xai_http.py index b5bce97c2f4..216a51ff10d 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -2,6 +2,28 @@ 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.""" @@ -10,3 +32,52 @@ 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`` 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 + + 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 = 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, + "base_url": base_url, + } diff --git a/toolsets.py b/toolsets.py index 5e34a0548c8..8ec45f11a2f 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, " @@ -158,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)", @@ -379,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/tui_gateway/server.py b/tui_gateway/server.py index c4bfe8274f1..cf77bf5d659 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -288,6 +288,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") @@ -580,6 +583,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, current) @@ -2189,6 +2193,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, _sessions[sid])) @@ -3407,6 +3412,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"]) @@ -3773,6 +3877,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() @@ -5719,94 +5853,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)) @@ -5825,7 +5902,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() @@ -5851,43 +5928,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, @@ -5896,7 +5962,8 @@ def _(rid, params: dict) -> dict: "total_models": 0, "authenticated": True, } - + # picker_hints sets `authenticated` from the row state, but the + # synthetic fallback above doesn't go through that path. provider_data["authenticated"] = True return _ok(rid, {"provider": provider_data}) except Exception as e: diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx index 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-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 c4669847e68..8cdfe781395 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -24,6 +24,7 @@ import { KeyboardEvent } from './events/keyboard-event.js' import { FocusManager } from './focus.js' import { emptyFrame, type Frame, type FrameEvent } from './frame.js' import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js' +import { applyHyperlinkHoverHighlight } from './hyperlinkHover.js' import instances from './instances.js' import { LogUpdate } from './log-update.js' import { nodeCache } from './node-cache.js' @@ -150,6 +151,21 @@ export type Options = { patchConsole: boolean waitUntilExit?: () => Promise onFrame?: (event: FrameEvent) => void + /** + * Called when a click lands on a cell with an OSC 8 hyperlink (or a + * plain-text URL detected by findPlainTextUrlAt). The host is responsible + * for opening the URL — `child_process.spawn` with an argv array (NOT + * shell-mode) to the platform's native opener: `open` on macOS, + * `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid + * `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL + * through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or + * reinterpreted), which both breaks plain URLs with `&` in query + * strings and undermines any caller-side protocol allowlist. Without + * this wired up, links rendered by `` look underlined but do + * nothing on click in any terminal where mouse tracking is on + * (Cmd+click is consumed by the TUI, not Terminal.app). + */ + onHyperlinkClick?: (url: string) => void } export default class Ink { private readonly log: LogUpdate @@ -232,6 +248,19 @@ export default class Ink { // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. private readonly hoveredNodes = new Set() + + // The OSC 8 hyperlink URL under the pointer, or undefined when the cursor + // isn't on a link. Updated from dispatchHover; consumed by the render-pass + // overlay (applyHyperlinkHoverHighlight) to invert link cells under the + // pointer. This is the closest the TUI can get to the desktop's + // cursor-changes-on-hover affordance — terminals don't expose cursor + // shape control to applications. + private hoveredHyperlink: string | undefined = undefined + + // Last value of hoveredHyperlink that we actually painted. Compared in + // onRender so we can scope full-screen damage to enter/leave/change + // transitions, not every steady-state hover frame. + private lastRenderedHoveredHyperlink: string | undefined = undefined // Set by via setAltScreenActive(). Controls the // renderer's cursor.y clamping (keeps cursor in-viewport to avoid // LF-induced scroll when screen.height === terminalRows) and gates @@ -287,6 +316,14 @@ export default class Ink { this.restoreStderr = this.patchStderr() } + // Host-supplied hyperlink-open callback. The mouse-event pipeline + // (App.tsx → onOpenHyperlink → Ink.openHyperlink → onHyperlinkClick) + // is fully wired internally; without this assignment the optional + // chain in openHyperlink() bails silently and clicks on URLs do + // nothing. The field stays writable so tests / debug overlays can + // still rebind it after construction. + this.onHyperlinkClick = options.onHyperlinkClick + this.terminal = { stdout: options.stdout, stderr: options.stderr @@ -447,17 +484,22 @@ export default class Ink { private handleResize = () => { const cols = this.options.stdout.columns || 80 const rows = this.options.stdout.rows || 24 + 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. @@ -484,26 +526,7 @@ export default class Ink { // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { - if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - - // One last repaint after the resize burst settles closes any host-side - // reflow drift the normal diff path can't see. - this.resizeSettleTimer = setTimeout(() => { - this.resizeSettleTimer = null - - if (!this.canAltScreenRepaint()) { - return - } - - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true - this.render(this.currentNode!) - }, 160) + this.prepareAltScreenResizeRepaint() } // Already queued: later events in this burst updated dims/alt-screen @@ -536,6 +559,36 @@ export default class Ink { ) } + private prepareAltScreenResizeRepaint(): void { + // Clear any pending settle timer from a previous resize burst so + // rapid events don't stack redundant delayed repaints. (handleResize + // also clears this, but the defensive clear keeps the method safe + // if it's ever called from other code paths.) + if (this.resizeSettleTimer !== null) { + clearTimeout(this.resizeSettleTimer) + this.resizeSettleTimer = null + } + + if (this.altScreenMouseTracking) { + this.options.stdout.write(ENABLE_MOUSE_TRACKING) + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + + this.resizeSettleTimer = setTimeout(() => { + this.resizeSettleTimer = null + + if (!this.canAltScreenRepaint()) { + return + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + this.render(this.currentNode!) + }, 160) + } + resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} @@ -769,6 +822,26 @@ export default class Ink { // Position-highlight (below) overlays CURRENT (yellow) on top. hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool) + // Hyperlink hover overlay: inverts every cell of the link currently + // under the pointer. Cheap-ish (linear scan of the visible buffer), + // only fires when hoveredHyperlink is set. + // + // hlActive controls full-screen damage (used by selection/search to + // make sure the previous frame's inverted cells get re-diffed when + // the highlight set changes). For hover, the *transition* is what + // needs the full-damage hammer — enter / leave / change-to-other-link. + // During steady-state hover the painted cells don't change and the + // ordinary per-cell diff handles the no-op. Folding the steady-state + // case into hlActive would burn full-screen diffs every frame while + // the pointer just sits on the link. + const hoverApplied = applyHyperlinkHoverHighlight(frame.screen, this.hoveredHyperlink, this.stylePool) + const hoverTransition = this.hoveredHyperlink !== this.lastRenderedHoveredHyperlink + this.lastRenderedHoveredHyperlink = this.hoveredHyperlink + + if (hoverApplied && hoverTransition) { + hlActive = true + } + // Position-based CURRENT: write yellow at positions[currentIdx] + // rowOffset. No scanning — positions came from a prior scan when // the message first mounted. Message-relative + rowOffset = screen. @@ -862,8 +935,9 @@ export default class Ink { const optimized = optimize(diff) const optimizeMs = performance.now() - tOptimize const hasDiff = optimized.length > 0 + const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint - if (this.altScreenActive && hasDiff) { + if (this.altScreenActive && (hasDiff || needsAltScreenErase)) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR @@ -883,7 +957,7 @@ export default class Ink { // resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is // supported, the clear+paint lands atomically; otherwise the final state // is still healed even if the repaint is visible. - if (this.needsEraseBeforePaint) { + if (needsAltScreenErase) { this.needsEraseBeforePaint = false optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH) } else { @@ -1005,7 +1079,7 @@ export default class Ink { this.lastDrainMs = 0 // Only track drain on TTY. Piped/non-TTY stdout bypasses flow control. - const trackDrain = this.options.stdout.isTTY && hasDiff + const trackDrain = this.options.stdout.isTTY && optimized.length > 0 const drainStart = trackDrain ? tWrite : 0 if (trackDrain) { @@ -1182,6 +1256,16 @@ export default class Ink { this.altScreenActive = active this.altScreenMouseTracking = active && mouseTracking + // Hover state is alt-screen-scoped: dispatchHover is gated on + // altScreenActive, so once we leave the alt screen there's no path to + // clear it on our own. Without this reset, remounting + // would render a phantom hover highlight from the previous session + // until the next mouse-move event arrived. Clear both the live value + // and the last-rendered tracker so the next onRender sees no transition + // and no overlay. + this.hoveredHyperlink = undefined + this.lastRenderedHoveredHyperlink = undefined + if (active) { this.resetFramesForAltScreen() } else { @@ -1770,6 +1854,34 @@ export default class Ink { } dispatchHover(this.rootNode, col, row, this.hoveredNodes) + + // Hover affordance for hyperlinks: read the cell at the pointer, store + // its URL (or clear when the pointer leaves a link), and request a + // repaint when the value changes. The render-pass overlay paints the + // highlight; we just track which URL is "hot". + // + // IMPORTANT: bypass getHyperlinkAt() here — its plain-text URL fallback + // (findPlainTextUrlAt) would return URLs for cells whose `cell.hyperlink` + // is undefined, which the overlay (applyHyperlinkHoverHighlight) + // wouldn't match. That'd burn re-renders without ever producing an + // affordance. Read the OSC 8 hyperlink directly off the cell so the + // hover state is a 1:1 fit for what the overlay can paint. The + // plain-text URL fallback still works for clicks; hover is a strictly + // weaker signal and OK to skip on plain-text URLs. + const screen = this.frontFrame.screen + const cell = cellAt(screen, col, row) + let next = cell?.hyperlink + + // SpacerTail (second half of a wide-char / emoji glyph) stores the + // hyperlink on the head cell at col-1. Same logic as getHyperlinkAt. + if (!next && cell?.width === CellWidth.SpacerTail && col > 0) { + next = cellAt(screen, col - 1, row)?.hyperlink + } + + if (next !== this.hoveredHyperlink) { + this.hoveredHyperlink = next + this.scheduleRender() + } } dispatchKeyboardEvent(parsedKey: ParsedKey): void { const target = this.focusManager.activeElement ?? this.rootNode @@ -1814,8 +1926,13 @@ export default class Ink { } /** - * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen - * mode. Set by FullscreenLayout via useLayoutEffect. + * Optional callback fired when clicking a cell that has an associated URL + * in fullscreen mode. `url` may be either an OSC 8 hyperlink (from a + * `` render or external OSC 8 escape that landed in the buffer) or + * a plain-text URL detected on the clicked row by findPlainTextUrlAt + * (App.tsx routes both into the same callback). Set from the host via + * the `onHyperlinkClick` Render/Ink option, or directly on the instance + * for late-bound test scenarios. */ onHyperlinkClick: ((url: string) => void) | undefined diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts index 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/__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, 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 = { diff --git a/uv.lock b/uv.lock index 713cd588fd6..2508637a081 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" @@ -600,12 +538,28 @@ wheels = [ ] [[package]] -name = "cachetools" -version = "5.5.2" +name = "brotlicffi" +version = "1.2.0.1" 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" } +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/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, + { 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]] @@ -809,15 +763,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 +772,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 +881,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 +894,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 +1119,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 +1256,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 +1288,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 +1335,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 +1449,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 +1532,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 +1545,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" @@ -2068,6 +1687,7 @@ mcp = [ ] messaging = [ { name = "aiohttp" }, + { name = "brotlicffi" }, { name = "discord-py", extra = ["voice"] }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "qrcode" }, @@ -2084,14 +1704,8 @@ 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" }, { name = "slack-sdk" }, ] @@ -2137,9 +1751,6 @@ web = [ { name = "fastapi" }, { name = "uvicorn", extra = ["standard"] }, ] -yc-bench = [ - { name = "yc-bench", marker = "python_full_version >= '3.12'" }, -] youtube = [ { name = "youtube-transcript-api" }, ] @@ -2149,14 +1760,15 @@ 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" }, { 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 = "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" }, @@ -2166,7 +1778,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" }, @@ -2238,49 +1849,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" @@ -2431,9 +2006,6 @@ wheels = [ ] [package.optional-dependencies] -http2 = [ - { name = "h2" }, -] socks = [ { name = "socksio" }, ] @@ -2613,27 +2185,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" @@ -2661,112 +2212,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" @@ -2782,42 +2227,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" @@ -2922,82 +2331,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" @@ -3258,35 +2591,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" @@ -3296,21 +2600,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" @@ -3649,60 +2938,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" @@ -3720,115 +2955,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" @@ -3838,34 +2964,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" @@ -4041,56 +3139,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" @@ -4166,18 +3214,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" @@ -4289,19 +3325,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" @@ -4614,110 +3637,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" @@ -4976,41 +3895,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" @@ -5068,15 +3952,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" @@ -5111,59 +3986,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" @@ -5190,35 +4012,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" @@ -5270,77 +4063,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" @@ -5405,26 +4127,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" @@ -5658,53 +4360,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" @@ -5902,109 +4557,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" @@ -6115,21 +4667,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/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 }); +} diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md index 2ef552e266c..89ae398b6af 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 `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. ## 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/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: 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/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/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/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/getting-started/installation.md b/website/docs/getting-started/installation.md index 452d426f645..c56d92112bb 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -128,6 +128,43 @@ If you want to clone the repo and install from source — for contributing, runn --- +## Non-Sudo / System Service User Installs + +Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service account, or any user without `sudo` access) is supported. The only thing on the install path that genuinely needs root is Playwright's `--with-deps` step, which `apt`-installs shared libraries (`libnss3`, `libxkbcommon`, etc.) used by Chromium. The installer detects whether sudo is available and gracefully degrades when it isn't — it will install the Chromium binary into the service user's own Playwright cache and print the exact command an administrator needs to run separately. + +**Recommended split (Debian/Ubuntu):** + +1. **One time, as an admin user with sudo**, install the system libraries Chromium needs: + ```bash + sudo npx playwright install-deps chromium + ``` + (You can run this from anywhere — `npx` will fetch Playwright on the fly.) + +2. **As the unprivileged service user**, run the regular installer. It will detect the missing sudo, skip `--with-deps`, and install Chromium into the user's local Playwright cache: + ```bash + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + ``` + + If you want to skip the Playwright step entirely — for example because you're running headless and don't need browser automation — pass `--skip-browser`: + ```bash + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + ``` + +3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location: + ```bash + # Option A — add to the service user's profile + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + + # Option B — symlink system-wide (run as an admin) + sudo ln -s /home/hermes/.hermes/hermes-agent/venv/bin/hermes /usr/local/bin/hermes + ``` + +4. **Verify:** `hermes doctor` should now run cleanly. If you get `ModuleNotFoundError: No module named 'dotenv'`, you're invoking the repo source `hermes` file (`~/.hermes/hermes-agent/hermes`) with system Python instead of the venv launcher (`~/.hermes/hermes-agent/venv/bin/hermes`) — fix step 3. + +The same pattern works on Arch (the installer uses pacman with the same sudo-detection logic), Fedora/RHEL, and openSUSE — those distros don't support `--with-deps` at all, so an administrator always installs the system libraries separately. The relevant `dnf`/`zypper` commands are printed by the installer. + +--- + ## Troubleshooting | Problem | Solution | diff --git a/website/docs/getting-started/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/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/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md new file mode 100644 index 00000000000..5afccb6d881 --- /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 all stored xAI Grok OAuth credentials: + +```bash +hermes auth logout xai-oauth +``` + +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 + +- [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/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 93e4ba630d3..e7b2e5ab86d 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`, 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`) | @@ -267,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 @@ -316,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. @@ -326,12 +331,37 @@ 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. 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. @@ -1327,7 +1357,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` | @@ -1417,7 +1446,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session. -Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `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/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4ce8a331a94..aa12f431b62 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -40,6 +40,7 @@ hermes [global-options] [subcommand/options] | `hermes model` | Interactively choose the default provider and model. | | `hermes fallback` | Manage fallback providers tried when the primary model errors. | | `hermes gateway` | Run or manage the messaging gateway service. | +| `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). | | `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | @@ -91,7 +92,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `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 b17036ade44..56fe8a13715 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 — AI-native cloud for Model API, Agent Sandbox, and GPU Cloud ([novita.ai/settings/key-management](https://novita.ai/settings/key-management)) | +| `NOVITA_BASE_URL` | Override NovitaAI base URL (default: `https://api.novita.ai/openai/v1`) | | `NVIDIA_API_KEY` | NVIDIA NIM API key — Nemotron and open models ([build.nvidia.com](https://build.nvidia.com)) | | `NVIDIA_BASE_URL` | Override NVIDIA base URL (default: `https://integrate.api.nvidia.com/v1`; set to `http://localhost:8000/v1` for a local NIM endpoint) | | `STEPFUN_API_KEY` | StepFun API key — Step-series models ([platform.stepfun.com](https://platform.stepfun.com)) | @@ -103,7 +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`), `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) | @@ -133,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/)) | @@ -145,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`) | @@ -515,6 +516,8 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_HUMAN_DELAY_MIN_MS` | Custom delay range minimum (ms) | | `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) | | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) | +| `CODEX_HOME` | When [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) is enabled, override the directory Codex CLI reads its config + auth from (default: `~/.codex`). Hermes' migration writes the managed block to `/config.toml`. | +| `HERMES_KANBAN_TASK` | Set by the kanban dispatcher when spawning a worker (task UUID). Workers and the spawned `hermes-tools` MCP subprocess inherit it so kanban tools gate correctly. Don't set manually. | | `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `1800`) | | `HERMES_API_CALL_STALE_TIMEOUT` | Non-streaming stale-call timeout in seconds (default: `300`). Auto-disabled for local providers when left unset. Also configurable via `providers..stale_timeout_seconds` or `providers..models..stale_timeout_seconds` in `config.yaml`. | | `HERMES_STREAM_READ_TIMEOUT` | Streaming socket read timeout in seconds (default: `120`). Auto-increased to `HERMES_API_TIMEOUT` for local providers. Increase if local LLMs time out during long code generation. | diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 1cedabe4ff2..8c4c2f36432 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -38,7 +38,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| -| [**base**](/docs/user-guide/skills/optional/blockchain/blockchain-base) | Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required. | +| [**evm**](/docs/user-guide/skills/optional/blockchain/blockchain-evm) | Read-only EVM client: wallets, tokens, gas across 8 chains. | | [**solana**](/docs/user-guide/skills/optional/blockchain/blockchain-solana) | Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. | ## communication @@ -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/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/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 37bd5aae1d8..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 @@ -66,11 +66,11 @@ 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. | | `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/configuration.md b/website/docs/user-guide/configuration.md index 5ea0c0b1779..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: @@ -1588,7 +1593,7 @@ security: ``` - `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **Off by default** — enable if you commonly work with real credentials in tool output and want a safety net. Set to `true` explicitly to turn on. -- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/StackGuardian/tirith) before execution to detect potentially dangerous operations. +- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/sheeki03/tirith) before execution to detect potentially dangerous operations. - `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location. - `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out. - `tirith_fail_open` — when `true` (default), commands are allowed to execute if tirith is unavailable or fails. Set to `false` to block commands when tirith cannot verify them. diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 1822f7adfad..6540748c889 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 a `uvx` distribution that runs: + +```bash +uvx --from 'hermes-agent[acp]==' hermes-acp +``` + +Make sure `uv` 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,34 @@ 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 +``` + +### 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 @@ -90,7 +126,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 +146,9 @@ Example settings snippet: "hermes-agent": { "type": "custom", "command": "hermes", - "args": ["acp"], - }, - }, + "args": ["acp"] + } + } } ``` @@ -114,18 +162,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 a `uvx` distribution that points directly at the `hermes-agent` PyPI release: ```text -hermes acp +uvx --from 'hermes-agent[acp]==' hermes-acp ``` +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 ACP mode uses the same Hermes configuration as the CLI: @@ -135,7 +188,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 +224,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` 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 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 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..a1aa6a0776e --- /dev/null +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -0,0 +1,444 @@ +--- +title: Codex App-Server Runtime (optional) +sidebar_label: Codex App-Server Runtime +--- + +# Codex App-Server Runtime + +Hermes can optionally hand `openai/*` and `openai-codex/*` turns to the [Codex CLI app-server](https://github.com/openai/codex) instead of running its own tool loop. When enabled, terminal commands, file edits, sandboxing, and MCP tool calls all execute inside Codex's runtime — Hermes becomes the shell around it (sessions DB, slash commands, gateway, memory and skill review). + +This is **opt-in only**. Default Hermes behavior is unchanged unless you flip the flag. Hermes never auto-routes you onto this runtime. + +## Why + +- Run OpenAI agent turns against your **ChatGPT subscription** (no API key required) using the same auth flow Codex CLI uses. +- Use **Codex's own toolset and sandbox** — `shell` for terminal/read/write/search, `apply_patch` for structured edits, `update_plan` for planning, all running inside seatbelt/landlock sandboxing. +- **Native Codex plugins** — Linear, GitHub, Gmail, Calendar, Canva, etc. — installed via `codex plugin` are auto-migrated and active in your Hermes session. +- **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback. Codex calls back into Hermes for tools it doesn't have built in. +- **Memory and skill nudges keep working** — Codex's events are projected into Hermes' message shape so the self-improvement loop sees a normal-looking transcript. + +## What tools the model actually has + +This is the part most users want to know up front. When this runtime is on, the model running your turn has three independent sources of tools: + +### 1. Codex's built-in toolset (always on) + +These ship with `codex app-server` itself — no Hermes involvement, no MCP, no plugins. All five are available the moment the runtime starts: + +- **`shell`** — runs arbitrary shell commands inside the sandbox. This is how the model reads files (`cat`, `head`, `tail`), writes them (`echo > foo`, heredocs), searches them (`find`, `rg`, `grep`), navigates directories (`ls`, `cd`), runs builds, manages processes, and anything else you'd do in bash. +- **`apply_patch`** — applies a structured multi-file diff in Codex's patch format. The model uses this for non-trivial code edits (adding a function, refactoring across files); shell heredocs are still available for one-off writes. +- **`update_plan`** — codex's internal todo / plan tracker. Equivalent of Hermes' `todo` tool, but managed entirely inside codex's runtime. +- **`view_image`** — load a local image file into the conversation so the model can see it. +- **`web_search`** — codex has its own built-in web search when configured. Hermes also exposes `web_search` (Firecrawl-backed) via the callback below; the model picks whichever it prefers. + +So **anything you'd do via terminal — read/write/search/find/run — codex does natively**. The sandbox profile (`:workspace` by default when you enable the runtime) controls what's writable. + +### 2. Native Codex plugins (auto-migrated from your `codex plugin` install) + +When you enable the runtime, Hermes queries codex's `plugin/list` RPC and writes a `[plugins."@openai-curated"]` entry for every plugin you have installed. The plugins themselves are managed by codex and authorized once via codex's own UI. + +Examples (the ones the OpenClaw thread highlighted as "YouTube-video-worthy"): + +- **Linear** — find/update issues +- **GitHub** — search code, view PRs, comment +- **Gmail** — read/send mail +- **Google Calendar** — create/find events +- **Outlook calendar/email** — same shape via the Microsoft connector +- **Canva** — design generation +- ...whatever else you've installed via `codex plugin marketplace add openai-curated` + `codex plugin install ...` + +What's NOT migrated: +- Plugins you haven't installed yet — install them in Codex first. +- ChatGPT app marketplace entries (`app/list`) — these are already enabled inside codex by virtue of your account auth. + +### 3. Hermes tool callback (MCP server, registered in `~/.codex/config.toml`) + +Hermes registers itself as an MCP server so codex can call back for tools codex doesn't ship with. Available via the callback: + +- **`web_search`** / **`web_extract`** — Firecrawl-backed; tends to be cleaner than scraping for structured content. +- **`browser_navigate` / `browser_click` / `browser_type` / `browser_press` / `browser_snapshot` / `browser_scroll` / `browser_back` / `browser_get_images` / `browser_console` / `browser_vision`** — full browser automation via Camofox or Browserbase. +- **`vision_analyze`** — call a separate vision model to inspect an image (different from codex's `view_image` which loads it into the conversation). +- **`image_generate`** — image generation through Hermes' image_gen plugin chain. +- **`skill_view` / `skills_list`** — read from Hermes' skill library. +- **`text_to_speech`** — TTS through Hermes' configured provider. + +When the model wants one of these, codex spawns the `hermes_tools_mcp_server` subprocess via stdio MCP, the call is dispatched through `model_tools.handle_function_call()` (same code path as Hermes' default runtime), and the result is returned to codex like any other MCP response. + +### What's NOT available on this runtime + +These four Hermes tools require the running AIAgent context (mid-loop state) to dispatch, and a stateless MCP callback can't drive them. Switch back to the default runtime (`/codex-runtime auto`) when you need any of them: + +- **`delegate_task`** — spawn subagents +- **`memory`** — Hermes' persistent memory store +- **`session_search`** — cross-session search +- **`todo`** — Hermes' todo store (codex's `update_plan` is the in-runtime equivalent) + +## Workflow features (`/goal`, kanban, cron) + +### `/goal` (the Ralph loop) + +**Works on this runtime.** Goals persist in `state_meta` keyed by session id, the continuation prompt feeds back as a normal user message through `run_conversation()`, and codex executes the next turn natively. The goal judge runs via the auxiliary client (configured via `auxiliary.goal_judge` in config.yaml), independent of which runtime is active. The judge's "blocked, needs user input" verdict is a clean escape if codex stalls on approvals. + +**One thing to be aware of:** each continuation prompt is a fresh codex turn, which means codex re-evaluates command approval policy from scratch. If you're doing a long-running goal with lots of writes, expect more approval prompts than you'd see on a single in-session task. Set `default_permissions = ":workspace"` (which Hermes does automatically when you enable the runtime) so simple workspace writes don't require prompting. + +### Kanban (multi-agent worktree dispatch) + +**Works on this runtime, with one subtle dependency.** The kanban dispatcher spawns each worker as a separate `hermes chat -q` subprocess that reads the user's config — which means if `model.openai_runtime: codex_app_server` is set globally, workers also come up on the codex runtime. + +What works inside a codex-runtime worker: +- Codex's full toolset (shell, apply_patch, update_plan, view_image, web_search) — the worker does its actual task work natively +- The migrated codex plugins — Linear, GitHub, etc. +- The Hermes tool callback for browser_*, vision, image_gen, skills, TTS + +What also works because the MCP callback exposes them: +- **`kanban_complete` / `kanban_block` / `kanban_comment` / `kanban_heartbeat`** — the worker handoff tools. These read `HERMES_KANBAN_TASK` from env (set by the dispatcher), gate access correctly, and write to `~/.hermes/kanban.db`. Without these in the callback, a worker on this runtime could do its task but couldn't report back, hanging until the dispatcher's timeout. +- **`kanban_show` / `kanban_list`** — read-only board queries for the worker to check its own context. +- **`kanban_create` / `kanban_unblock` / `kanban_link`** — orchestrator-only operations. Available for orchestrator agents running on the codex runtime that need to dispatch new tasks. + +The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets — that var is propagated to the codex subprocess (codex inherits env) and from there to the spawned `hermes-tools` MCP server subprocess. So the tools see the right task id and gate correctly. + +### Cron jobs + +**Not specifically tested.** Cron jobs run via `cronjob` → `AIAgent.run_conversation`, the same code path as the CLI. If the cron job's config has `openai_runtime: codex_app_server` it'll run on codex. The same tool-availability rules apply — codex built-ins + plugins + MCP callback work, agent-loop tools (delegate_task, memory, session_search, todo) don't. If your cron job relies on those, scope the cron to a profile that uses the default runtime. + +## Trade-offs + +| | Hermes default runtime | Codex app-server (opt-in) | +|---|---|---| +| `delegate_task` subagents | yes | not available — needs agent loop context | +| `memory`, `session_search`, `todo` | yes | not available — needs agent loop context | +| `web_search`, `web_extract` | yes | yes (via MCP callback) | +| Browser automation (Camofox/Browserbase) | yes | yes (via MCP callback) | +| `vision_analyze`, `image_generate` | yes | yes (via MCP callback) | +| `skill_view`, `skills_list` | yes | yes (via MCP callback) | +| `text_to_speech` | yes | yes (via MCP callback) | +| Codex `shell` (terminal/read/write/search/find/run) | — | yes (Codex built-in) | +| Codex `apply_patch` (structured multi-file edits) | — | yes (Codex built-in) | +| Codex `update_plan` (in-runtime todo) | — | yes (Codex built-in) | +| Codex `view_image` (load image into conversation) | — | yes (Codex built-in) | +| Codex sandbox (seatbelt/landlock, profiles) | — | yes (Codex built-in) | +| ChatGPT subscription auth | — | yes (via `openai-codex` provider) | +| Native Codex plugins (Linear, GitHub, etc.) | — | yes (auto-migrated) | +| User MCP servers | yes | yes (auto-migrated to codex) | +| Memory + skill review (background) | yes | yes (via item projection) | +| Multi-turn conversations | yes | yes | +| `/goal` (Ralph loop) | yes | yes | +| Kanban worker dispatch | yes | yes (via callback) | +| Kanban orchestrator tools | yes | yes (via callback) | +| All gateway platforms | yes | yes | +| Non-OpenAI providers | yes | n/a — OpenAI/Codex-scoped | + +## Prerequisites + +1. **Codex CLI installed:** + ```bash + npm i -g @openai/codex + codex --version # 0.130.0 or newer + ``` +2. **Codex OAuth login.** The codex subprocess reads `~/.codex/auth.json`. Two ways to populate it: + ```bash + codex login # writes tokens to ~/.codex/auth.json + ``` + Hermes' own `hermes auth login codex` writes to `~/.hermes/auth.json` — that's a separate session. **Run `codex login` separately** if you haven't. + +3. **(Optional) Install the Codex plugins you want.** When you enable the runtime, Hermes auto-migrates whichever curated plugins you've already installed via Codex CLI: + ```bash + codex plugin marketplace add openai-curated + # then via codex's TUI, install Linear / GitHub / Gmail / etc. + ``` + Hermes will discover them and write `[plugins."@openai-curated"]` entries to `~/.codex/config.toml` automatically. + +## Enabling + +In a Hermes session: + +``` +/codex-runtime codex_app_server +``` + +That command: +- Verifies the `codex` CLI is installed (blocks with an install hint if not). +- Persists `model.openai_runtime: codex_app_server` to your config.yaml. +- Migrates user MCP servers from `~/.hermes/config.yaml` to `~/.codex/config.toml`. +- **Discovers and migrates installed native Codex plugins** (Linear, GitHub, Gmail, Calendar, Canva, etc.) by querying Codex's `plugin/list` RPC. +- **Registers Hermes' own tools as an MCP server** so the codex subprocess can call back for tools codex doesn't ship with. +- **Writes `default_permissions = ":workspace"`** so the sandbox allows writes within the workspace without prompting for every operation. +- Tells you what was migrated. Takes effect on the **next** session — the current cached agent keeps the prior runtime so prompt caches stay valid. + +Synonyms: `/codex-runtime on`, `/codex-runtime off`, `/codex-runtime auto`. + +To check current state without changing anything: +``` +/codex-runtime +``` + +You can also set it manually in `~/.hermes/config.yaml`: +```yaml +model: + openai_runtime: codex_app_server # default is "auto" (= Hermes runtime) +``` + +## Self-improvement loop (memory + skill nudges) + +Hermes' background self-improvement fires on counter thresholds: + +- Every 10 user prompts → a forked review agent looks at the conversation and decides whether anything should be saved to memory. +- Every 10 tool iterations within a single turn → same idea but for skills (`skill_manage` writes). + +**Both keep working on the codex runtime.** The codex path projects each completed `commandExecution` / `fileChange` / `mcpToolCall` / `dynamicToolCall` item into a synthetic `assistant tool_call` + `tool` result message, so by the time the review runs it sees the same shape it sees on the default Hermes runtime. + +How the wiring stays equivalent: + +| | Default runtime | Codex runtime | +|---|---|---| +| `_turns_since_memory` increments | per user prompt, in run_conversation pre-loop | same code path, before the early-return | +| `_iters_since_skill` increments | per tool iteration in the chat-completions loop | by `turn.tool_iterations` after the codex turn returns | +| Memory trigger (`_turns_since_memory >= _memory_nudge_interval`) | computed in pre-loop, fires after response | computed in pre-loop, passed through to codex helper | +| Skill trigger (`_iters_since_skill >= _skill_nudge_interval`) | computed after the loop | computed after the codex turn | +| `_spawn_background_review(messages_snapshot=..., review_memory=..., review_skills=...)` | called when either trigger fires | called identically when either trigger fires | + +One detail: the review fork itself needs to call Hermes' agent-loop tools (`memory`, `skill_manage`), which require Hermes' own dispatch. So when the parent agent is on `codex_app_server`, the review fork is **downgraded to `codex_responses`** — same OAuth credentials, same `openai-codex` provider, but talks to OpenAI's Responses API directly so Hermes owns the loop and the agent-loop tools work. This is invisible to the user. + +Net effect: enable the codex runtime and your memory + skill nudges keep firing exactly as they would otherwise. + +## How approvals work + +Codex requests approval before executing commands or applying patches. These get translated into Hermes' standard "Dangerous Command" prompt: + +``` +╭───────────────────────────────────────╮ +│ Dangerous Command │ +│ │ +│ /bin/bash -lc 'echo hello > foo.txt' │ +│ │ +│ ❯ 1. Allow once │ +│ 2. Allow for this session │ +│ 3. Deny │ +│ │ +│ Codex requests exec in /your/cwd │ +╰───────────────────────────────────────╯ +``` + +- **Allow once** → approve this single command. +- **Allow for this session** → Codex won't re-prompt for similar commands. +- **Deny** → command is rejected; Codex continues in read-only mode. + +For `apply_patch` (file edit) approvals, Hermes shows a summary of what changed (`1 add, 1 update: /tmp/new.py, /tmp/old.py`) when codex provides the data via the corresponding `fileChange` item. + +## Permission profiles + +Codex has three built-in permission profiles: +- `:read-only` — no writes; every shell command requires approval +- `:workspace` — writes within the current workspace allowed without prompts (Hermes' default when you enable the runtime) +- `:danger-no-sandbox` — no sandbox at all (don't use this unless you understand it) + +You can override the default in `~/.codex/config.toml` outside Hermes' managed block: + +```toml +default_permissions = ":read-only" +``` + +(Hermes will preserve your override on re-migration as long as it lives outside the `# managed by hermes-agent` markers.) + +## Auxiliary tasks and ChatGPT subscription token cost + +When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. + +This isn't specific to `codex_app_server` — it's true for the existing `codex_responses` path too — but it's more visible here because you're explicitly opting in for the subscription billing. + +To route specific aux tasks to a cheaper / different model, set explicit overrides in `~/.hermes/config.yaml`: + +```yaml +auxiliary: + title_generation: + provider: openrouter + model: google/gemini-3-flash-preview + context_compression: + provider: openrouter + model: google/gemini-3-flash-preview + vision_detect: + provider: openrouter + model: google/gemini-3-flash-preview + session_search: + provider: openrouter + model: google/gemini-3-flash-preview + goal_judge: + provider: openrouter + model: google/gemini-3-flash-preview +``` + +The self-improvement review fork inherits the main runtime via `_current_main_runtime()` and Hermes downgrades it from `codex_app_server` to `codex_responses` automatically (so the fork can actually call `memory` and `skill_manage` — Hermes' own agent-loop tools). That fork still uses your subscription auth unless you've routed aux tasks elsewhere. + +## Editing `~/.codex/config.toml` safely + +Hermes wraps everything it manages between two marker comments: + +```toml +# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section +default_permissions = ":workspace" +[mcp_servers.filesystem] +... +[plugins."github@openai-curated"] +... +# end hermes-agent managed section +``` + +Anything **outside** that block is yours. Re-running migration (via `/codex-runtime codex_app_server` or whenever you toggle the runtime on) replaces the managed block in place but preserves user content above and below it verbatim. This means you can: + +- Add your own MCP servers Hermes doesn't know about +- Override `default_permissions` to `:read-only` if you prefer to be prompted +- Configure codex-only options (model, providers, otel, etc.) +- Add user-defined permission profiles in `[permissions.]` tables + +Anything you add **inside** the managed block will get clobbered on the next migration. If you need a tweak that requires editing the managed block, file an issue and we'll add the knob. + +## Multi-profile / multi-tenant setups + +By default, Hermes points the codex subprocess at `~/.codex/` regardless of which Hermes profile is active. This means `hermes -p work` and `hermes -p personal` share the same Codex auth, plugins, and config. For most users this is the right behavior — it matches what running `codex` CLI directly would do. + +If you want per-profile Codex isolation (separate auth, separate installed plugins, separate config), set `CODEX_HOME` explicitly per profile. The cleanest way is to point at a directory under your `HERMES_HOME`: + +```bash +# Inside the work profile, you might wrap hermes: +CODEX_HOME=~/.hermes/profiles/work/codex hermes chat +``` + +You'll need to re-run `codex login` once with that `CODEX_HOME` set so the OAuth tokens land in the profile-scoped location. After that, `hermes -p work` will operate on isolated Codex state. + +We don't auto-scope this because moving an existing user's `~/.codex/` would silently invalidate their Codex CLI auth — anyone who already ran `codex login` would have to re-authenticate. Opt-in feels safer than surprising users. + +## HOME environment variable passthrough + +Hermes does NOT rewrite `HOME` when spawning the codex app-server subprocess (we use `os.environ.copy()` and only overlay `CODEX_HOME` and `RUST_LOG`). This means: + +- Commands codex runs via its `shell` tool see the real user `HOME` and find `~/.gitconfig`, `~/.gh/`, `~/.aws/`, `~/.npmrc`, etc. correctly. +- Codex's internal state stays isolated through `CODEX_HOME` (which points at `~/.codex/` by default). + +This matches the boundary OpenClaw arrived at after some early experimentation: isolate Codex's state, leave the user's home alone. (Cf. openclaw/openclaw#81562.) + +## MCP server migration + +Hermes' `mcp_servers` config is auto-translated to the TOML format Codex expects. The migration runs every time you enable the runtime and is idempotent — re-runs replace the managed section but preserve any user-edited Codex config. + +What translates: + +| Hermes (`config.yaml`) | Codex (`config.toml`) | +|---|---| +| `command` + `args` + `env` | stdio transport | +| `url` + `headers` | streamable_http transport | +| `timeout` | `tool_timeout_sec` | +| `connect_timeout` | `startup_timeout_sec` | +| `enabled: false` | `enabled = false` | + +What's not migrated: +- Hermes-specific keys like `sampling` (Codex's MCP client has no equivalent — these are dropped with a per-server warning). + +## Native Codex plugin migration + +Plugins installed via `codex plugin` (Linear, GitHub, Gmail, Calendar, Canva, etc.) are discovered through Codex's `plugin/list` RPC. For each plugin where `installed: true`, Hermes writes a `[plugins."@openai-curated"]` block enabling it in your Hermes session. + +This means: when your friend says "I have Calendar and GitHub set up in my Codex CLI" and they enable Hermes' codex runtime, Hermes activates those automatically. No re-configuration needed. + +What's NOT migrated: +- Plugins you haven't installed yet — install them in Codex first. +- Plugins where codex reports `availability != AVAILABLE` (broken install, expired OAuth, removed from marketplace, etc.). These are skipped to avoid writing config that would fail at activation time. +- ChatGPT app marketplace entries (the per-account `app/list` results — these are already enabled inside codex by virtue of your account auth). +- Plugin OAuth — you authorize each plugin once in Codex itself; Hermes doesn't touch credentials. + +## Hermes tool callback (the new MCP server) + +Codex's built-in toolset covers shell/file ops/patches but doesn't have web search, browser automation, vision, image generation, etc. To keep those usable in a codex turn, Hermes registers itself as an MCP server in `~/.codex/config.toml`: + +```toml +[mcp_servers.hermes-tools] +command = "/path/to/python" +args = ["-m", "agent.transports.hermes_tools_mcp_server"] +env = { HERMES_HOME = "/your/.hermes", PYTHONPATH = "...", HERMES_QUIET = "1" } +startup_timeout_sec = 30.0 +tool_timeout_sec = 600.0 +``` + +When the model calls `web_search` (or another exposed Hermes tool), codex spawns the `hermes_tools_mcp_server` subprocess via stdio, the request is dispatched through `model_tools.handle_function_call()`, and the result is projected back to codex like any other MCP response. + +**Tools available via the callback:** `web_search`, `web_extract`, `browser_navigate`, `browser_click`, `browser_type`, `browser_press`, `browser_snapshot`, `browser_scroll`, `browser_back`, `browser_get_images`, `browser_console`, `browser_vision`, `vision_analyze`, `image_generate`, `skill_view`, `skills_list`, `text_to_speech`. + +**Tools NOT available:** `delegate_task`, `memory`, `session_search`, `todo`. These need the running AIAgent context to dispatch (mid-loop state) and a stateless MCP callback can't drive them. Use the default Hermes runtime (`/codex-runtime auto`) when you need these. + +## Disabling + +Switch back at any time: + +``` +/codex-runtime auto +``` + +Effective on the next session. The Codex managed block stays in `~/.codex/config.toml` so you can re-enable later without losing config — or remove it manually if you prefer. + +## Limitations + +This runtime is **opt-in beta**. Working as of Hermes Agent 2026.5 + Codex CLI 0.130.0: + +- Multi-turn conversations +- `commandExecution` and `fileChange` (apply_patch) approvals via Hermes UI +- MCP tool calls (verified against `@modelcontextprotocol/server-filesystem` and the new `hermes-tools` callback) +- Native Codex plugin migration (verified against Linear / GitHub / Calendar inventory) +- Deny/cancel paths +- Toggle on/off cycle +- Memory and skill nudge counters (verified live via integration tests) +- Hermes web_search through codex (verified live: "OpenAI Codex CLI – Getting Started" returned end-to-end) + +Known limitations: + +- **Hermes auth and codex auth are separate sessions.** You need both `codex login` AND `hermes auth login codex` for the cleanest UX (the runtime uses codex's session for the LLM call). This is a deliberate design choice in Hermes' `_import_codex_cli_tokens` — Hermes won't share OAuth state with codex CLI to avoid clobbering each other on token refresh. +- **`delegate_task`, `memory`, `session_search`, `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need these. +- **No inline patch preview in approval prompts when codex doesn't track the changeset.** Codex's `fileChange` approval params don't always carry the changeset. Hermes caches the data from the corresponding `item/started` notification when possible, but if approval arrives before the item has streamed, the prompt falls back to whatever `reason` codex provides. +- **Sub-second cancellation isn't guaranteed.** Mid-stream interrupts (Ctrl+C while codex is responding) are sent via `turn/interrupt`, but if codex has already flushed the final message, you get the response anyway. + +If you find a bug, [open an issue](https://github.com/NousResearch/hermes-agent/issues) with the output of `hermes logs --since 5m`. Mention `codex-runtime` in the title so it's easy to triage. + +## Architecture + +``` + ┌─── Hermes shell (CLI / TUI / gateway) ───┐ + │ sessions DB · slash commands · memory │ + │ & skill review · cron · session pickers │ + └──┬──────────────────────────────────────┬┘ + │ user_message final │ + ▼ text + │ + ┌──────────────────────────────────┐ projected │ + │ AIAgent.run_conversation() │ messages │ + │ if api_mode == codex_app_server │ │ + │ → CodexAppServerSession │ │ + │ else: chat_completions / codex_responses (default) + └────┬─────────────────────────────┘ │ + │ JSON-RPC over stdio │ + ▼ │ + ┌──────────────────────────────────┐ │ + │ codex app-server (subprocess) │──────────────┘ + │ thread/start, turn/start │ + │ item/* notifications │ + │ shell + apply_patch + update_plan│ + │ view_image + sandbox │ + │ ┌─────────────────────────┐ │ + │ │ MCP client │ │ + │ │ ├─ user MCP servers │ │ + │ │ ├─ native plugins │ │ + │ │ │ (linear, github, │ │ + │ │ │ gmail, calendar, │ │ + │ │ │ canva, ...) │ │ + │ │ └─ hermes-tools ───────┼─────────────────┐ + │ │ (callback to │ │ │ + │ │ Hermes' richer │ │ │ + │ │ tools) │ │ │ + │ └─────────────────────────┘ │ │ + └──────────────────────────────────┘ │ + │ + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ hermes_tools_mcp_server.py (subprocess on demand) │ + │ web_search, web_extract, browser_*, vision_analyze, │ + │ image_generate, skill_view, skills_list, text_to_speech│ + └──────────────────────────────────────────────────────────┘ +``` + +For implementation details, see [PR #24182](https://github.com/NousResearch/hermes-agent/pull/24182) and the [Codex app-server protocol README](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md). diff --git a/website/docs/user-guide/features/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`: 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) | 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. 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/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/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 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/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 375d682f92d..50f1641f093 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -277,6 +277,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. | | `DISCORD_COMMAND_SYNC_POLICY` | No | `"safe"` | Controls native slash-command startup sync. `"safe"` diffs existing global commands and only updates what changed, recreating commands when Discord metadata changes cannot be applied via patch. `"bulk"` preserves the old `tree.sync()` behavior. `"off"` skips startup sync entirely. | | `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. | +| `DISCORD_THREAD_REQUIRE_MENTION` | No | `false` | When `true`, the in-thread mention shortcut is disabled — threads are gated the same as channels, requiring `@mention` even after the bot has already participated. Use this when multiple bots share a thread and you want each to fire only on explicit `@mention`. | | `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. | | `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. | | `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. | @@ -285,6 +286,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. | | `DISCORD_ALLOWED_CHANNELS` | No | — | Comma-separated channel IDs. When set, the bot **only** responds in these channels (plus DMs if allowed). Overrides `config.yaml` `discord.allowed_channels`. Combine with `DISCORD_IGNORED_CHANNELS` to express allow/deny rules. | | `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. | +| `DISCORD_HISTORY_BACKFILL` | No | `true` | When `true`, prepend recent channel scrollback (since the bot's last response) to the user message when the bot is mentioned. Recovers context the bot would otherwise miss with `require_mention`. Skipped in DMs and free-response channels. Set to `false` to disable. | +| `DISCORD_HISTORY_BACKFILL_LIMIT` | No | `50` | Maximum number of messages to scan backwards when assembling the backfill block. In practice the scan usually stops earlier — at the bot's own last message in the channel. | | `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. | | `DISCORD_ALLOW_MENTION_EVERYONE` | No | `false` | When `false` (default), the bot cannot ping `@everyone` or `@here` even if its response contains those tokens. Set to `true` to opt back in. See [Mention Control](#mention-control) below. | | `DISCORD_ALLOW_MENTION_ROLES` | No | `false` | When `false` (default), the bot cannot ping `@role` mentions. Set to `true` to allow. | @@ -302,11 +305,14 @@ The `discord` section in `~/.hermes/config.yaml` mirrors the env vars above. Con # Discord-specific settings discord: require_mention: true # Require @mention in server channels + thread_require_mention: false # If true, require @mention in threads too (multi-bot threads) free_response_channels: "" # Comma-separated channel IDs (or YAML list) auto_thread: true # Auto-create threads on @mention reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading + history_backfill: true # Prepend recent channel scrollback on mention (default: true) + history_backfill_limit: 50 # Max messages to scan backwards (default: 50) channel_prompts: {} # Per-channel ephemeral system prompts allow_mentions: # What the bot is allowed to ping (safe defaults) everyone: false # @everyone / @here pings (default: false) @@ -324,6 +330,20 @@ group_sessions_per_user: true # Isolate sessions per user in shared channels When enabled, the bot only responds in server channels when directly `@mentioned`. DMs always get a response regardless of this setting. +#### `discord.thread_require_mention` + +**Type:** boolean — **Default:** `false` + +By default, once the bot has participated in a thread (auto-created on `@mention` or replied in once), it keeps responding to every subsequent message in that thread without needing to be `@mentioned` again. That's the right default for one-on-one conversations. + +In **multi-bot threads** where users address one bot per turn, this default becomes a footgun — every other bot in the thread also fires on every message, burning credits and spamming the channel. Set `thread_require_mention: true` to disable the in-thread shortcut and gate threads the same way channels are gated. Explicit `@mentions` still work as before. + +```yaml +discord: + require_mention: true + thread_require_mention: true # multi-bot setup +``` + #### `discord.free_response_channels` **Type:** string or list — **Default:** `""` @@ -350,7 +370,7 @@ Free-response channels also **skip auto-threading** — the bot replies inline r **Type:** boolean — **Default:** `true` -When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. +When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. Set [`thread_require_mention`](#discordthread_require_mention) to `true` to disable this in-thread shortcut for multi-bot setups. Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead. @@ -421,6 +441,47 @@ Behavior: - If a message arrives inside a thread or forum post and that thread has no explicit entry, Hermes falls back to the parent channel/forum ID. - Prompts are applied ephemerally at runtime, so changing them affects future turns immediately without rewriting past session history. +#### `discord.history_backfill` + +**Type:** boolean — **Default:** `true` + +When enabled, the bot recovers missed channel messages on each `@mention`. With `require_mention: true`, the bot only processes messages that tag it directly — everything else in the channel is invisible to the session transcript. History backfill scans backwards through recent channel history when triggered, collecting messages between the bot's last response and the current mention, and includes them as context. + +Behavior by surface: + +- **Server channels** (with `require_mention: true`): backfill scans the channel since the bot's last response. Useful when other participants posted while the bot wasn't addressed. +- **Threads**: backfill scans the thread only — Discord's `channel.history()` on a thread returns only that thread's messages, not the parent channel. This is the right scope because threads are usually self-contained conversations. +- **DMs**: skipped. Every DM message triggers the bot, so the session transcript is already complete — there's no mention gap to fill. +- **Free-response channels** and **bot's own auto-created threads**: skipped for the same reason — no mention gating means no gap. + +Per-user sessions (`group_sessions_per_user: true`, the default) also benefit: a user's session is missing the context posted by other channel participants and the user's own messages from before they tagged the bot. Backfill fills both gaps. + +```yaml +discord: + history_backfill: true # default +``` + +To turn it off: + +```yaml +discord: + history_backfill: false +``` + +> **Note:** Messages that arrive *while* the bot is processing (between a trigger and its response) are not captured. This is an accepted simplification — the user can re-send or tag again. + +#### `discord.history_backfill_limit` + +**Type:** integer — **Default:** `50` + +Maximum number of messages to scan backwards when recovering channel context. In practice the scan usually stops much earlier — at the bot's own last message in the channel, which is the natural boundary between turns. This limit is a safety cap for cold starts and long gaps where no prior bot message exists in recent history. + +```yaml +discord: + history_backfill: true + history_backfill_limit: 50 +``` + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true` diff --git a/website/docs/user-guide/messaging/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. 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 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: 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..01006870ee4 --- /dev/null +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-evm.md @@ -0,0 +1,227 @@ +--- +title: "Evm — Read-only EVM client: wallets, tokens, gas across 8 chains" +sidebar_label: "Evm" +description: "Read-only EVM client: wallets, tokens, gas across 8 chains" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Evm + +Read-only EVM client: wallets, tokens, gas across 8 chains. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/blockchain/evm` | +| Path | `optional-skills/blockchain/evm` | +| Version | `1.0.0` | +| Author | Mibayy (@Mibayy), youssefea (@youssefea), ethernet8023 (@ethernet8023), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `EVM`, `Ethereum`, `BNB`, `BSC`, `Base`, `Arbitrum`, `Polygon`, `Optimism`, `Avalanche`, `zkSync`, `Blockchain`, `Crypto`, `Web3`, `DeFi`, `NFT`, `ENS`, `Whale`, `Security` | +| Related skills | [`solana`](/docs/user-guide/skills/optional/blockchain/blockchain-solana) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# EVM Blockchain Skill + +Query EVM-compatible blockchain data across 8 chains with USD pricing. +14 commands: wallet portfolio, token info, transactions, activity, gas tracker, +network stats, price lookup, multi-chain scan, whale detection, ENS resolution, +allowance checker, contract inspector, and transaction decoder. + +Supports 8 chains: Ethereum, BNB Chain (BSC), Base, Arbitrum One, Polygon, +Optimism, Avalanche (C-Chain), zkSync Era. + +No API key needed. Zero external dependencies — Python standard library only +(urllib, json, argparse, threading). + +> **Supersedes the standalone `base` skill.** Base-specific tokens (AERO, DEGEN, +> TOSHI, BRETT, WELL, cbETH, cbBTC, wstETH, rETH) and all Base RPC functionality +> previously living under `optional-skills/blockchain/base/` have been folded +> into this skill. Pass `--chain base` to any command for Base coverage. + +--- + +## When to Use +- User asks for a wallet balance or portfolio on any EVM chain +- User wants to check the same wallet across ALL chains at once +- User wants to inspect a transaction by hash (or decode what it did) +- User wants ERC-20 token metadata, price, supply, or market cap +- User wants recent transaction history for an address +- User wants current gas prices or to compare fees across chains +- User wants to find large whale transfers in recent blocks +- User asks to resolve an ENS name (vitalik.eth) or reverse-lookup an address +- User wants to check if a contract has dangerous token approvals +- User wants to inspect a smart contract (proxy? ERC-20? ERC-721? bytecode size?) +- User wants to compare gas costs across chains before a transaction + +--- + +## Prerequisites +Python 3.8+ standard library only. No pip installs required. +Pricing: CoinGecko free API (rate-limited, ~10-30 req/min). +ENS: ensideas.com public API. +Tx decoding: 4byte.directory public API. + +Override RPC endpoint: `export EVM_RPC_URL=https://your-rpc.com` + +Helper script path: `~/.hermes/skills/blockchain/evm/scripts/evm_client.py` + +--- + +## Quick Reference + +``` +SCRIPT=~/.hermes/skills/blockchain/evm/scripts/evm_client.py + +# Network & prices +python3 $SCRIPT stats # Ethereum stats +python3 $SCRIPT stats --chain arbitrum # Arbitrum stats +python3 $SCRIPT compare # Gas + prices ALL 8 chains + +# Wallet +python3 $SCRIPT wallet 0xd8dA...96045 # Portfolio (ETH + ERC-20) +python3 $SCRIPT wallet 0xd8dA...96045 --chain bsc +python3 $SCRIPT multichain 0xd8dA...96045 # Same wallet on ALL chains + +# Tokens & prices +python3 $SCRIPT price ETH +python3 $SCRIPT price 0xdAC1...1ec7 # By contract address +python3 $SCRIPT token 0xdAC1...1ec7 # ERC-20 metadata + market cap + +# Transactions +python3 $SCRIPT tx 0x5c50...f060 # Transaction details +python3 $SCRIPT decode 0x5c50...f060 # Decode input data (4byte.directory) +python3 $SCRIPT activity 0xd8dA...96045 # Recent transactions + +# Gas +python3 $SCRIPT gas # Gas prices + cost estimates +python3 $SCRIPT gas --chain optimism + +# Security +python3 $SCRIPT allowance 0xd8dA...96045 # Dangerous ERC-20 approvals +python3 $SCRIPT contract 0xdAC1...1ec7 # Contract inspection (proxy? standards?) + +# ENS +python3 $SCRIPT ens vitalik.eth # Name -> address + profile +python3 $SCRIPT ens 0xd8dA...96045 # Address -> ENS name + +# Whale detection +python3 $SCRIPT whale # Large transfers (last 20 blocks, >$10k) +python3 $SCRIPT whale --blocks 50 --min-usd 100000 --chain arbitrum +``` + +--- + +## Procedure + +### 0. Setup Check +```bash +python3 --version # 3.8+ required +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats +``` + +### 1. Wallet Portfolio +Native balance + known ERC-20 tokens, sorted by USD value. +```bash +python3 $SCRIPT wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +python3 $SCRIPT wallet 0xd8dA... --chain bsc --no-prices # faster +``` + +### 2. Multi-Chain Scan +Scans all 8 chains simultaneously for the same address using threads. +```bash +python3 $SCRIPT multichain 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` +Output: per-chain native balance + token holdings + grand total USD. + +### 3. Compare (Gas + Prices) +All 8 chains queried in parallel. Shows cheapest/most expensive chain. +```bash +python3 $SCRIPT compare +``` + +### 4. Transaction Details & Decode +```bash +python3 $SCRIPT tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 +python3 $SCRIPT decode 0x5c504ed... # Shows human-readable function signature +``` +Decode uses 4byte.directory to translate 0xa9059cbb -> transfer(address,uint256). + +### 5. ENS Resolution +```bash +python3 $SCRIPT ens vitalik.eth # -> 0xd8dA... + avatar + social links +python3 $SCRIPT ens 0xd8dA...96045 # -> vitalik.eth +``` + +### 6. Allowance Checker (Security) +Checks ERC-20 approvals granted to known DEX/bridge contracts. +```bash +python3 $SCRIPT allowance 0xYourWallet +``` +Flags UNLIMITED approvals as HIGH risk. + +### 7. Contract Inspector +```bash +python3 $SCRIPT contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # USDC (proxy) +python3 $SCRIPT contract 0xdAC17F958D2ee523a2206206994597C13D831ec7 # USDT (ERC-20) +``` +Detects: proxy (EIP-1967/EIP-1167), ERC-20, ERC-721, ERC-165. Shows bytecode size and implementation address for proxies. + +### 8. Whale Detection +```bash +python3 $SCRIPT whale # ETH, last 20 blocks, >$10k +python3 $SCRIPT whale --blocks 50 --min-usd 50000 --chain bsc +``` + +### 9. Gas Tracker +```bash +python3 $SCRIPT gas +python3 $SCRIPT gas --chain polygon +``` +Shows gwei price + USD cost for: transfer, ERC-20 transfer, approve, swap, NFT mint, NFT transfer. + +--- + +## Supported Chains +| Key | Name | Native | Chain ID | +|-----------|----------------|--------|----------| +| ethereum | Ethereum | ETH | 1 | +| bsc | BNB Chain | BNB | 56 | +| base | Base | ETH | 8453 | +| arbitrum | Arbitrum One | ETH | 42161 | +| polygon | Polygon | POL | 137 | +| optimism | Optimism | ETH | 10 | +| avalanche | Avalanche C | AVAX | 43114 | +| zksync | zkSync Era | ETH | 324 | + +--- + +## Pitfalls +- CoinGecko free tier: ~10-30 req/min. Use `--no-prices` for faster wallet scans. +- Public RPCs may throttle. Set EVM_RPC_URL to a private endpoint for production. +- `wallet` and `allowance` only check known token list (~30 tokens per chain). Use a block explorer for complete token discovery. +- `activity` scans recent blocks only (max 200). For full history, use Etherscan API. +- `multichain` runs 8 parallel threads — can trigger rate limits on public RPCs. +- ENS resolution depends on a single public endpoint (ensideas.com / ens.vitalik.ca) with no fallback. If that endpoint is down, `ens` will fail — re-run later or use a block explorer. +- Tx decoding depends on a single public endpoint (4byte.directory) with no fallback. Selectors not in their database show up as `unknown`. +- **L2 gas estimates are L2-execution only.** On rollups like Base, Arbitrum, Optimism, and zkSync, the actual transaction cost also includes an L1 data-posting fee that depends on calldata size and current L1 gas prices. The `gas` command does not estimate that L1 component. For Base specifically, see the network's L1 fee oracle (contract `0x420000000000000000000000000000000000000F`). +- Address / tx-hash inputs are validated for 0x-prefix + correct length + hex, but EIP-55 checksum casing is **not** enforced (RPC endpoints accept any-case hex). + +--- + +## Verification +```bash +# Should print current block, gas price, ETH price +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py stats + +# Should resolve vitalik.eth to 0xd8dA... +python3 ~/.hermes/skills/blockchain/evm/scripts/evm_client.py ens vitalik.eth +``` diff --git a/website/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 67a256bcc09..a0fb24b8c50 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -68,6 +68,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/cron', 'user-guide/features/delegation', 'user-guide/features/kanban', + 'user-guide/features/codex-app-server-runtime', 'user-guide/features/kanban-tutorial', 'user-guide/features/kanban-worker-lanes', 'user-guide/features/goals', @@ -95,13 +96,13 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/web-dashboard', 'user-guide/features/extending-the-dashboard', + 'user-guide/features/subscription-proxy', ], }, { type: 'category', label: 'Advanced', items: [ - 'user-guide/features/rl-training', 'user-guide/features/spotify', ], }, @@ -144,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', ], @@ -189,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', ], @@ -223,6 +226,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', @@ -235,7 +239,6 @@ const sidebars: SidebarsConfig = { 'developer-guide/tools-runtime', 'developer-guide/acp-internals', 'developer-guide/cron-internals', - 'developer-guide/environments', 'developer-guide/trajectory-format', ], },